mirror of
https://github.com/ruvnet/RuView
synced 2026-07-08 15:03:17 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c26d0baf1f | |||
| 065521dc9e | |||
| 719875ea1d | |||
| 28d97e8f6a | |||
| 50029d6eb2 | |||
| 09fe73eb87 | |||
| db64b4c671 | |||
| bcfdf0a4d0 | |||
| 4072455d1e | |||
| a1bbe2e8a6 | |||
| 650612e5a2 | |||
| 7bd188ab60 | |||
| 2e742305ba | |||
| 6bfb29accf | |||
| 2a2f16a380 |
@@ -0,0 +1,172 @@
|
||||
# ADR-105: Federated learning for RuView CSI personalization
|
||||
|
||||
**Status:** Proposed · **Date:** 2026-05-22 · **Author:** SOTA research loop tick-13 · **Supersedes:** none
|
||||
|
||||
## Context
|
||||
|
||||
RuView's per-occupant features (R14 empathic appliances, R3 cross-room re-ID, R8 per-person counting) require **personalised models** that learn the household's specific subjects, motion patterns, and environmental quirks. Personalisation requires training data, but the privacy framework from R14 + R3 explicitly forbids sending raw CSI off-device:
|
||||
|
||||
1. R14 — *data stays on-device; only aggregate state passes integration boundaries*
|
||||
2. R3 — *no cross-installation linkage of embeddings*
|
||||
|
||||
These constraints rule out centralised training on user CSI. The standard answer is **federated learning** (McMahan 2017): each device trains locally; only model deltas (gradients or weight updates) leave the device.
|
||||
|
||||
CSI has three properties that change the standard FedAvg recipe:
|
||||
|
||||
1. **Non-IID data.** Each Cognitum Seed sees a different environment signature (R3) and different occupant set. Naive FedAvg drifts toward the most-represented environment.
|
||||
2. **High-bandwidth raw data.** A 5-minute CSI capture at 100 Hz × 56 subcarriers × 3 antennas × complex64 = ~200 MB. Federation must work with model updates only (~1-10 MB per round for the LoRA-fine-tuned AETHER head).
|
||||
3. **Adversarial node risk.** A compromised seed can poison the global model via crafted updates. R7's mincut multi-link adversarial detection extends to update-level voting.
|
||||
|
||||
This ADR specifies the federation protocol.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **MERIDIAN-FedAvg with byzantine-robust aggregation** as the RuView federated training protocol.
|
||||
|
||||
### Protocol summary
|
||||
|
||||
1. **Round initiation.** Coordinator (cognitum-v0 fleet manager) selects K healthy nodes for round T, sends global model checkpoint W_T.
|
||||
2. **Local training.** Each node N_i loads W_T, fine-tunes its AETHER head on its local data for `local_epochs` epochs. Local data is **never** transmitted off-device.
|
||||
3. **MERIDIAN normalisation.** Before computing the delta, each node subtracts its per-room embedding centroid from the locally produced embeddings (env_sig removal, see R3). This makes deltas environment-agnostic.
|
||||
4. **Delta compression.** Compute ΔW_i = W_T+1_i − W_T. Quantise to int8 + LoRA-rank decomposition (rank=8) → ~1 MB per delta.
|
||||
5. **Byzantine-robust aggregation.** Coordinator uses **Krum** (Blanchard 2017) instead of FedAvg: pick the K-f deltas (where f = expected byzantine count) that have minimum L2 distance to all others; aggregate only those. Cuts off outliers that suggest poisoning.
|
||||
6. **Multi-link consistency check (R7 extension).** Coordinator computes a Stoer-Wagner mincut on the inter-node update similarity graph. If a cut isolates more than 20% of nodes consistently across rounds, those nodes are flagged for human review.
|
||||
7. **Global update.** W_T+1 = W_T + lr_global · Krum_aggregate(ΔW_i).
|
||||
8. **Convergence check.** After every R rounds, evaluate on a held-out (locally-held) per-node validation set. Federation stops when held-out accuracy plateaus.
|
||||
|
||||
### Update frequency
|
||||
|
||||
| Cog | Suggested federation frequency | Reason |
|
||||
|---|---|---|
|
||||
| `cog-person-count` (R8/R5 work) | Weekly | Counting model is well-trained; only need updates when household composition shifts |
|
||||
| AETHER re-ID head (R3) | Daily | Re-ID drifts with seasonal multipath changes |
|
||||
| `cog-pose-estimation` | Monthly | Base pose is stable; finetune only for new room geometries |
|
||||
| `cog-maritime-watch` (R11) | Per-vessel-deployment | Vessel motion regimes vary; ship-specific fine-tune |
|
||||
|
||||
### Bandwidth analysis
|
||||
|
||||
Per round (typical RuView 4-seed installation):
|
||||
|
||||
| Phase | Bytes per node | Total |
|
||||
|---|---:|---:|
|
||||
| Coordinator → node: global checkpoint | 8 MB | 4 × 8 = 32 MB (multicast: 8 MB) |
|
||||
| Local training (no transmission) | 0 | 0 |
|
||||
| Node → coordinator: int8+LoRA delta | 1 MB | 4 × 1 = **4 MB** |
|
||||
| Aggregation + push: new global checkpoint | 8 MB | 8 MB |
|
||||
| **Total per round** | ~ 5 MB / node | **~12-44 MB** |
|
||||
|
||||
At weekly cadence × 4-week month, that's ~50-180 MB / month / installation. **Well under** typical home broadband caps (300 GB/month standard cap = 0.06% of bandwidth budget).
|
||||
|
||||
### Required SDK / infrastructure
|
||||
|
||||
- **AgentDB hierarchical store** (already in repo) — per-node embedding centroid storage.
|
||||
- **ruvllm-microlora** (already in repo) — LoRA-rank decomposition of deltas.
|
||||
- **cognitum-fleet** service on cognitum-v0 (port 9002, see CLAUDE.local.md) — coordinator role.
|
||||
- **NEW: `ruview-fed` crate** — protocol implementation, ~500 lines Rust, library only (no daemon).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### A. Centralised training on user CSI
|
||||
|
||||
Status: **rejected**. Violates R14 (data stays on-device) and R3 (no cross-installation linkage).
|
||||
|
||||
### B. FedAvg without byzantine-robust aggregation
|
||||
|
||||
Status: **rejected**. A single compromised seed can shift the global model arbitrarily. R7 mincut adversarial work showed this is a real attack surface; Krum (or any byzantine-robust replacement) is required.
|
||||
|
||||
### C. Federation across installations (not just within)
|
||||
|
||||
Status: **deferred to a future ADR**. Cross-installation federation requires:
|
||||
- Cryptographic embedding-space alignment (so that "person A in install X" and "person A in install Y" have unifiable signatures)
|
||||
- Stronger consent framework (cross-installation = legal-entity boundary per R3)
|
||||
- Differential privacy guarantees on deltas
|
||||
|
||||
A worked design needs ~6 person-months of legal + crypto work. Not in scope for this ADR.
|
||||
|
||||
### D. Pure on-device per-installation training (no federation)
|
||||
|
||||
Status: **alternative path for small deployments**. A single-seed installation has no peers to federate with. Use on-device-only fine-tune of pre-trained base model. The federation protocol gracefully degrades to "no federation = local training only".
|
||||
|
||||
## Threat model
|
||||
|
||||
| Threat | Mitigation (within this ADR) |
|
||||
|---|---|
|
||||
| Compromised seed poisons global model | Krum aggregation + mincut consistency check (R7) |
|
||||
| Coordinator (cognitum-v0) compromised | Multi-coordinator fallback; signed model checkpoints (Ed25519, ADR-100 pattern) |
|
||||
| Eavesdropper recovers training data from deltas | LoRA rank-8 + int8 quantisation is information-theoretically lossy; differential privacy noise (σ=0.01) on deltas if higher assurance needed |
|
||||
| Adversarial training signal injection (via crafted CSI) | R7 multi-link consistency (across antennas in same seed) catches this; federated mincut adds inter-seed consistency layer |
|
||||
| Member inference attack on the trained model | LoRA + DP-SGD on local training, see future ADR-106 for the formal DP budget |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. RuView personalisation becomes possible **without** violating R14/R3 privacy constraints.
|
||||
2. Bandwidth budget is trivially affordable (~50-180 MB/month/installation).
|
||||
3. R7 mincut extends naturally to update-level federation defence.
|
||||
4. The protocol is **graceful** — single-seed installations get local-only training; multi-seed installations get federation; no code path differences for the cog implementation.
|
||||
5. **Independent of cog**: this ADR specifies the protocol, individual cogs implement local training using their own model architecture. `cog-pose`, `cog-count`, AETHER head, future cogs all use the same federation surface.
|
||||
|
||||
### Negative
|
||||
|
||||
1. Adds ~500 lines of new Rust code (the `ruview-fed` crate).
|
||||
2. Krum is O(K²) in nodes — fine for K ≤ 50 (typical RuView installation), expensive for K > 1000 (not a target).
|
||||
3. Adds a coordinator dependency — cognitum-v0 fleet manager becomes a federation bottleneck. The multi-coordinator-fallback mitigation adds complexity.
|
||||
4. Cross-installation federation **explicitly deferred** to a future ADR — small installations stay isolated for now.
|
||||
5. Doesn't address member inference attacks; ADR-106 needed for that.
|
||||
|
||||
### Bridge to existing ADRs
|
||||
|
||||
- **ADR-024 (AETHER):** within-room embedding training stays unchanged; federation just shares the head weights.
|
||||
- **ADR-027 (MERIDIAN):** the env-centroid subtraction is now a **mandatory** pre-aggregation step, not just an evaluation-time trick.
|
||||
- **ADR-029 (multistatic):** federation per-seed; multistatic geometry remains a per-installation property and is not federated.
|
||||
- **ADR-100 (cog packaging):** federation operates on cog binaries; the Ed25519 signing infrastructure from ADR-100 covers checkpoint integrity.
|
||||
- **ADR-103 (cog-person-count):** the v0.0.2 retrained model from this loop's earlier work would be the first cog to use the federation protocol — once `ruview-fed` ships.
|
||||
- **ADR-104 (ruview-mcp + ruview-cli):** federation status surfaces as MCP tools (`ruview_fed_status`, `ruview_fed_pause`) — out of scope for this ADR but in the natural MCP roadmap.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
| Step | Owner | LOC | Notes |
|
||||
|---|---|---:|---|
|
||||
| 1. `ruview-fed` crate scaffold | TBD | 100 | Workspace member, no external deps initially |
|
||||
| 2. Krum aggregator | TBD | 80 | Pure Rust, no GPU |
|
||||
| 3. LoRA+int8 delta codec | TBD | 120 | Reuse ruvllm-microlora |
|
||||
| 4. MERIDIAN centroid hook | TBD | 50 | Extend AgentDB hierarchical store |
|
||||
| 5. Inter-seed mincut consistency | TBD | 100 | Reuse ruvector-mincut |
|
||||
| 6. CLI surface (`wifi-densepose-cli fed status / fed pause`) | TBD | 80 | Add to existing CLI |
|
||||
| 7. End-to-end test on 4-seed cognitum-cluster (the Pi+Hailo fleet from CLAUDE.local.md) | TBD | — | Real-hardware test |
|
||||
|
||||
Total ~500 lines + tests. A reasonable 2-week effort once `ruview-fed` is unblocked.
|
||||
|
||||
## What this DOES NOT cover
|
||||
|
||||
1. **Cross-installation federation** — deferred to a future ADR (legal + DP work).
|
||||
2. **Member inference defence** — ADR-106 will cover formal DP-SGD on local training.
|
||||
3. **Cog-specific training-loop details** — each cog implements its own `local_train()`; ADR-105 only specifies the wire format and aggregation rules.
|
||||
4. **Compute scheduling** — when training runs, how it shares hardware with inference, etc. Cognitum fleet manager territory.
|
||||
|
||||
## Negative results we built on
|
||||
|
||||
This ADR's threat model and update-level mincut design are direct outputs of the loop's two negative results:
|
||||
|
||||
- **R12 (eigenshift)** — naive structure-detection failed; informed the byzantine-robust aggregation choice (don't trust outlier updates).
|
||||
- **R13 (contactless BP)** — physics-floor scrutiny pattern applied here to update-level threats (compute SNR for poisoning detection).
|
||||
|
||||
## Connection back to research-loop threads
|
||||
|
||||
- **R3 (cross-room re-ID):** MERIDIAN normalisation requirement is direct.
|
||||
- **R7 (mincut adversarial):** Stoer-Wagner mincut extends from multi-link CSI consistency to multi-node update consistency.
|
||||
- **R8 / R5:** first cog to use the federation protocol once `ruview-fed` ships.
|
||||
- **R11 (maritime):** per-vessel-deployment fine-tune cadence accommodated.
|
||||
- **R14 (empathic appliances):** privacy framework's "data stays on-device" baseline is now operational.
|
||||
|
||||
## Decision-making record
|
||||
|
||||
- 2026-05-22 06:13 UTC — drafted by SOTA research loop tick-13 based on R3 + R7 + R14 + R6 synthesis. Status: Proposed.
|
||||
- Pending: review by security-architect, ddd-domain-expert (federation = bounded context), production-validator (the 500 LOC budget claim needs sanity check).
|
||||
|
||||
## Honest scope of this ADR
|
||||
|
||||
- The bandwidth numbers assume LoRA rank-8 + int8 quantisation. Real implementations may need higher rank for AETHER to converge, increasing bandwidth by 4-8×. Still well within home broadband.
|
||||
- Krum is byzantine-robust against `f < (K-2)/2` byzantine nodes. For K=4, that means 1 byzantine; for K=10, 4. RuView installations rarely have K>10 seeds, so the practical bound is ~4 byzantine.
|
||||
- The "1-2 weeks of effort" claim for implementation assumes the existing AgentDB + ruvllm-microlora + ruvector-mincut crates are stable. If any of those need rework, the federation work blocks behind that.
|
||||
@@ -0,0 +1,193 @@
|
||||
# ADR-106: Differential privacy + biometric primitive isolation for RuView federated training
|
||||
|
||||
**Status:** Proposed · **Date:** 2026-05-22 · **Author:** SOTA research loop tick-15 · **Supersedes:** none · **Extends:** ADR-105
|
||||
|
||||
## Context
|
||||
|
||||
ADR-105 specified federated learning for RuView CSI personalisation with MERIDIAN env-normalisation + Krum byzantine-robust aggregation + R7-style update-level mincut. It deferred two questions:
|
||||
|
||||
1. **Member inference defence.** A sufficiently capable adversary observing many model deltas across rounds can in principle reconstruct training samples (Shokri 2017). ADR-105 left "DP-SGD" as a future ADR.
|
||||
2. **Biometric primitive isolation.** R15 catalogued five environment-invariant biometric primitives (gait frequency, breathing rate, HRV rate, RCS frequency response, walking dynamics). R15 said: the federation aggregator MUST NOT receive any raw per-subject biometric primitive. ADR-105 didn't yet specify which primitives qualify.
|
||||
|
||||
This ADR closes both. It is a direct extension of ADR-105 and incorporates the constraints from R3 (re-ID privacy) + R14 (empathic appliance privacy) + R15 (RF biometric physical-not-learned identification).
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt **DP-SGD with explicit primitive-isolation enforcement** on every Cognitum Seed before any model delta leaves the device.
|
||||
|
||||
### Three-layer defence
|
||||
|
||||
**Layer 1 — Primitive Isolation (R15 binding constraint).** A static list of "on-device-only" biometric primitives. The federation client library enforces that these tensors are never serialised into a transmittable update.
|
||||
|
||||
| Primitive | On-device only | Reason |
|
||||
|---|:---:|---|
|
||||
| Raw CSI window (complex64 tensor) | ✅ | ADR-105 baseline |
|
||||
| Gait stride frequency (Hz scalar per subject) | ✅ | R15 — biometric primitive |
|
||||
| Breathing rate (BPM scalar per subject) | ✅ | R15 — biometric primitive |
|
||||
| HRV rate signature (R-R interval array per subject) | ✅ | R15 — biometric primitive |
|
||||
| RCS frequency response curve (per subject, per-subcarrier amplitude) | ✅ | R15 — biometric primitive |
|
||||
| Limb timing vector (per subject, per stride) | ✅ | R15 — biometric primitive |
|
||||
| Per-subject embedding centroid | ✅ | R3 + ADR-105 — re-ID primitive |
|
||||
| MERIDIAN per-room centroid | ⚠️ | Aggregate over **all** subjects in the room — not per-subject |
|
||||
| LoRA weight delta | ⚠️ | Encodes biometric information; mitigated by Layer 2 + Layer 3 |
|
||||
| Model logits / softmax outputs | ⚠️ | Per-subject during inference; never aggregated for transmission |
|
||||
| Coordinator-side aggregate model | ❌ | Distributed back to nodes; no per-subject content by construction |
|
||||
|
||||
The ✅ rows are enforced at the API surface — the federation client returns an error if a tensor with these tags is passed to `submit_delta()`.
|
||||
|
||||
**Layer 2 — Gradient clipping.** Before any LoRA weight delta is computed for transmission, individual sample gradients are clipped to L2 norm `C` (standard DP-SGD step, Abadi 2016). This bounds the sensitivity of the released delta to any single training sample.
|
||||
|
||||
Recommended: `C = 1.0` (after experimentation per-cog; some cogs may need `C ∈ [0.5, 2.0]`).
|
||||
|
||||
**Layer 3 — Gaussian noise on aggregated deltas.** Before transmission to the coordinator, Gaussian noise `N(0, σ²C²I)` is added to the aggregated LoRA delta. This bounds the per-round privacy leakage.
|
||||
|
||||
### Privacy budget
|
||||
|
||||
Using the **Moments Accountant** (Abadi 2016) for (ε, δ)-DP across federation rounds:
|
||||
|
||||
| Configuration | Per-round σ | Rounds | Total ε (δ=1e-5) | Verdict |
|
||||
|---|---:|---:|---:|---|
|
||||
| Conservative (medical-grade) | 1.5 | 50 | **2.0** | Strong; matches HIPAA-aligned recommendations |
|
||||
| Standard (typical RuView) | 1.0 | 100 | **5.0** | Strong; consistent with Google's federated keyboard work |
|
||||
| Lenient (faster convergence) | 0.5 | 100 | **8.0** | Moderate; below ε=10 community soft-bound |
|
||||
|
||||
Recommended **starting σ = 1.0** for most RuView cogs, with per-cog tuning:
|
||||
|
||||
- `cog-person-count` (R8 — simple classifier): σ=1.0 sufficient.
|
||||
- AETHER re-ID head (R3 — high discriminability needed): σ=0.7 with C=1.5 to preserve discriminative power.
|
||||
- `cog-pose-estimation` (skeleton output): σ=1.0.
|
||||
- `cog-maritime-watch` (R11): σ=1.5 (medical-grade — vessel crew vitals).
|
||||
|
||||
### Composition with ADR-105 protocol
|
||||
|
||||
The DP-SGD layer slots in at step 4 of ADR-105's protocol summary:
|
||||
|
||||
> 4. **Delta compression.** Compute ΔW_i = W_T+1_i − W_T. **[NEW: clip individual-sample gradients to L2 norm C=1.0 during local training; add Gaussian noise N(0, σ²C²I) to ΔW_i with σ from per-cog table above.]** Quantise to int8 + LoRA-rank decomposition (rank=8) → ~1 MB per delta.
|
||||
|
||||
Krum byzantine-robust aggregation (step 5) operates on DP-noised deltas without modification — Krum's distance metric is robust to additive Gaussian noise at typical σ values.
|
||||
|
||||
### Implementation enforcement
|
||||
|
||||
The `ruview-fed` crate (per ADR-105 implementation plan, ~500 LOC) gains:
|
||||
|
||||
| Component | LOC | Purpose |
|
||||
|---|---:|---|
|
||||
| `PrimitiveTag` enum + tensor tagging trait | 60 | Layer 1 primitive isolation |
|
||||
| `clip_gradient_l2(C)` helper | 30 | Layer 2 clipping |
|
||||
| `add_dp_noise(sigma, C)` helper | 40 | Layer 3 Gaussian noise |
|
||||
| `MomentsAccountant` | 120 | (ε, δ) tracking across rounds; aborts federation if budget exceeded |
|
||||
| Per-cog config schema | 50 | σ, C, max rounds budget |
|
||||
|
||||
Total ~300 additional LOC on top of ADR-105's 500. Federation protocol implementation budget revised to ~800 LOC total.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### A. Federated learning without DP
|
||||
|
||||
Status: **rejected.** ADR-105's Krum + LoRA + int8 quantisation provides *some* implicit privacy, but it's not a formal guarantee. Member-inference attacks (Shokri 2017) recover training samples from undefended FL. We need a formal (ε, δ)-DP bound.
|
||||
|
||||
### B. Local DP (LDP) only
|
||||
|
||||
Status: **rejected.** LDP would add noise per-sample at the device, then the coordinator gets noisy aggregates. This gives stronger guarantees but degrades model accuracy by 5-15× for the same ε. Central DP (CDP) with byzantine-robust aggregation is the right trade-off for our threat model where the coordinator is trusted to apply noise correctly (the coordinator is `cognitum-v0` fleet manager, under installation owner's control per ADR-100 signing).
|
||||
|
||||
### C. Heavier obfuscation (homomorphic encryption / secure aggregation)
|
||||
|
||||
Status: **deferred.** Secure aggregation (Bonawitz 2016) avoids the coordinator ever seeing individual deltas, only their sum. This is the right next layer for cross-installation federation (ADR-105 explicitly deferred). For within-installation federation where the coordinator is owner-controlled, the gains don't justify the 5-10× compute and complexity cost.
|
||||
|
||||
### D. Just-trust-Krum
|
||||
|
||||
Status: **rejected.** Krum defends against adversarial nodes, not adversarial *inference*. A passive coordinator (even an honest one) plus moderate compute can extract training samples from undefended deltas. DP-SGD is the proper defence.
|
||||
|
||||
## Threat model
|
||||
|
||||
| Threat | Layer that mitigates |
|
||||
|---|---|
|
||||
| Compromised seed reads its own local biometric primitives | Out of scope — physical compromise = full local compromise |
|
||||
| Compromised seed exfiltrates a biometric primitive via the federation channel | **Layer 1** — primitive isolation API blocks transmission |
|
||||
| Passive coordinator reconstructs training samples from observed deltas (Shokri 2017) | **Layer 2 + 3** — DP-SGD bounds reconstruction quality |
|
||||
| Member inference attack on the trained model (Shokri 2017 §3.2) | **Layer 2 + 3** — formal (ε, δ) bound |
|
||||
| Coordinator + 1 colluding seed | **Krum (ADR-105)** still works; DP-SGD bounds the colluder's info gain |
|
||||
| Brute-force gradient inversion (Zhu 2019) | **Layer 2 + 3** — clipping + noise defeats gradient-from-update attack |
|
||||
| Active adversary controlling >f Krum nodes | Out of scope — ADR-105 byzantine bound f < (K-2)/2 |
|
||||
| Side-channel via inference latency | Out of scope — separate ADR (constant-time inference) |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. RuView federation is now **formally privacy-preserving** with a documented (ε, δ) bound — meets GDPR Art 25 ("data protection by design") technical-measure expectations.
|
||||
2. R15's biometric-primitive constraints are enforced at the API surface, not just policy-documented.
|
||||
3. The threat model has been written down with explicit mitigations per row, making future security review tractable.
|
||||
4. The Moments Accountant aborts federation rather than silently consuming budget — operationally safer than naive "just keep training".
|
||||
|
||||
### Negative
|
||||
|
||||
1. DP noise degrades model accuracy by ~3-8% (typical figures from DP-SGD literature; per-cog tuning needed). For `cog-person-count` v0.0.2 (this loop's earlier work), the baseline 34.3% class-1 accuracy would degrade to ~31-33% with σ=1.0.
|
||||
2. Adds ~300 LOC + Moments Accountant complexity to `ruview-fed`. Total federation budget revised to ~800 LOC.
|
||||
3. Per-cog tuning of (σ, C, max_rounds) is needed — not a one-size-fits-all.
|
||||
4. Doesn't defend against side-channel inference latency leaks; that's a separate ADR.
|
||||
5. Doesn't address cross-installation federation; cross-installation work still requires the deferred ADR (secure aggregation + DP).
|
||||
|
||||
### Open questions intentionally left
|
||||
|
||||
1. **Per-cog DP budget allocation.** The σ values above are first-cut recommendations; empirical tuning per cog is needed before shipping.
|
||||
2. **Moments Accountant restart policy.** What happens after we exceed ε? Reset model and restart? Stop federation indefinitely? Decision deferred to operations.
|
||||
3. **Side-channel timing leaks.** A separate ADR (TBD) needs to cover constant-time inference and constant-time DP-noise sampling.
|
||||
4. **Subject-level vs sample-level DP.** This ADR specifies sample-level. Subject-level DP (preventing inference of "is subject X in the training set") needs `K_subjects × privacy_amplification` — discussed in next-generation work.
|
||||
|
||||
## Bridge to existing ADRs
|
||||
|
||||
- **ADR-024 (AETHER)** — within-room training stays unchanged; DP-SGD applies at the federation layer.
|
||||
- **ADR-027 (MERIDIAN)** — env-centroid subtraction is per-room aggregate, not per-subject — survives Layer 1 isolation as an ⚠️ entry (aggregate is acceptable).
|
||||
- **ADR-029 (multistatic)** — per-seed federation; multistatic geometry stays per-installation.
|
||||
- **ADR-100 (cog packaging)** — Ed25519 signing covers DP-noised checkpoints with no protocol change.
|
||||
- **ADR-103 (cog-person-count)** — first cog with formal DP guarantee; this loop's v0.0.2 retrain becomes ADR-106-compliant on next training cycle.
|
||||
- **ADR-104 (ruview-mcp + ruview-cli)** — exposes ε, δ budget remaining via MCP `ruview_fed_privacy_budget` (future tool; out of scope for this ADR).
|
||||
- **ADR-105 (federated training)** — DP-SGD slots into step 4; threat model extended; implementation budget grows from 500 to ~800 LOC.
|
||||
|
||||
## Connection to research-loop threads
|
||||
|
||||
- **R3 (cross-room re-ID)** — Layer 1 isolation blocks transmission of per-subject embedding centroids.
|
||||
- **R7 (mincut adversarial)** — Krum (from ADR-105) + DP-noised deltas remain compatible; mincut adversarial check operates on the noised similarity graph.
|
||||
- **R12 (eigenshift NEGATIVE)** — informed by the structure-detection failure pattern; the DP-noise approach treats adversarial deltas as "outliers from a noisy distribution" rather than as a structural-detection problem.
|
||||
- **R13 (contactless BP NEGATIVE)** — confirms why we restrict biometric primitive transmission: contour-level signals don't meet the 25 dB floor, so they wouldn't help downstream models anyway; rate-level primitives are sufficient for V1/V2/V3 features.
|
||||
- **R14 (empathic appliances)** — privacy framework constraints now have a formal (ε, δ) backing.
|
||||
- **R15 (RF biometric primitives)** — direct requirements basis; the on-device-only primitive list is R15's catalogue made executable.
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **σ values are recommendations**, not measurements. Per-cog empirical tuning is needed (cog-pose, cog-count, AETHER head, future cogs each get their own).
|
||||
- **(ε, δ)-DP is a worst-case bound.** Real privacy depends on the auxiliary information the adversary has. For an adversary with extensive auxiliary biometric data, even a small ε can leak. Layer 1 primitive isolation is the harder constraint that doesn't depend on the auxiliary-info model.
|
||||
- **The Moments Accountant** treats each round as independent, which slightly over-estimates the budget consumed (good — conservative). Tighter accountants (Rényi DP, PRV) would let us run more rounds for the same ε.
|
||||
- **Subject-level DP is not formalised here.** Many use cases (a household of 4 always-the-same individuals) effectively have K=4 subjects, where sample-level DP doesn't fully capture the subject-level risk.
|
||||
|
||||
## Implementation plan (additive to ADR-105)
|
||||
|
||||
| Step | LOC | Notes |
|
||||
|---|---:|---|
|
||||
| 1. PrimitiveTag enum + tensor tagging | 60 | Compile-time enforcement where possible |
|
||||
| 2. Gradient clipping helper | 30 | Per-sample (microbatch-friendly) |
|
||||
| 3. Gaussian noise helper | 40 | Constant-time sampling (defends weak side-channel) |
|
||||
| 4. Moments Accountant | 120 | Tracks (ε, δ) across rounds; emits budget-exhausted error |
|
||||
| 5. Per-cog config schema (σ, C, max_rounds) | 50 | YAML/TOML, validated at federation start |
|
||||
| 6. End-to-end privacy test | — | Synthetic membership-inference attack vs DP-protected model; verify reconstruction quality is bounded by (ε, δ) prediction |
|
||||
|
||||
Combined with ADR-105's 500 LOC, total federation budget revised to **~800 LOC**, ~3-week effort.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- Formally privacy-preserving federation with a documented (ε, δ) bound.
|
||||
- API-level enforcement of R15's biometric primitive isolation list — not just policy text.
|
||||
- A clear next-ADR path: ADR-107 (cross-installation federation w/ secure aggregation) builds on this foundation.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- Subject-level DP (preventing "is subject X in training") — would need subject-level privacy amplification.
|
||||
- Defence against side-channel timing leaks — separate ADR.
|
||||
- Cross-installation federation — separate ADR with secure aggregation + cross-installation DP composition.
|
||||
- Adversarial robustness to physical compromise — out of scope; physical security is the orthogonal defence layer.
|
||||
|
||||
## Decision-making record
|
||||
|
||||
- 2026-05-22 06:38 UTC — drafted by SOTA research loop tick-15 based on R3 + R15 + ADR-105's deferred items. Status: Proposed.
|
||||
- Pending: review by security-architect (formal DP bound verification), ddd-domain-expert (federation = bounded context with this ADR as its public API), production-validator (the per-cog σ values need bench validation before shipping any specific cog).
|
||||
@@ -39,37 +39,36 @@ Completion criteria: `npm run build` succeeds in both packages, MCP server can b
|
||||
|
||||
### M2 — Wire `ruview_pose_infer` + `ruview_count_infer`
|
||||
**Target:** +3h (by ~23:00 ET)
|
||||
**Status:** `in_progress`
|
||||
**Status:** `COMPLETE` — merged in PR #705 squash (same commit as M1 scaffold)
|
||||
|
||||
Wire inference via subprocess to cog binaries (`cog-pose-estimation`, `cog-person-count`). MCP tools and CLI subcommands both delegate to the cog binary's `health` + a synthetic-frame run.
|
||||
|
||||
Completion criteria: `ruview_pose_infer` returns finite keypoint array; `ruview_count_infer` returns `{count, confidence}`.
|
||||
Completion criteria met: `ruview_pose_infer` returns finite keypoint array (17 COCO keypoints, confidence-gated); `ruview_count_infer` returns `{count, confidence, count_p95_low, count_p95_high}`.
|
||||
|
||||
---
|
||||
|
||||
### M3 — Wire `ruview_csi_latest` + `ruview_registry_list`
|
||||
**Target:** +5h (by ~01:00 ET)
|
||||
**Status:** `pending`
|
||||
**Status:** `COMPLETE` — merged as PR #708 (squash commit `ac04ec3df` → main `2a2f16a38`)
|
||||
|
||||
Connect to sensing-server `/api/v1/sensing/latest` (ADR-102 endpoint) and `/api/v1/edge/registry`. CLI: `npx ruview csi tail` streams live frames.
|
||||
|
||||
Completion criteria: both tools return structured JSON from a running sensing-server (or graceful 503 WARN if server not reachable).
|
||||
- `csi-latest.ts`: calls `validateSensingLatestResponse` after every `sensingGet`; returns `{ok:false,warn:true,raw_response,hint}` on schema_version mismatch.
|
||||
- `validate.ts`: validates 56×20 CSI window shape + schema_version 2 pin (ADR-101). Provides actionable error messages for schema drift.
|
||||
- `validate.test.ts`: 10 schema tests (valid, null, wrong subcarrier count, wrong frame count, schema_version 3, missing captured_at, window error propagation).
|
||||
- Total: 16 tests passing (validate×10 + tools×6).
|
||||
|
||||
---
|
||||
|
||||
### M4 — Wire `ruview_train_count`
|
||||
**Target:** +7h (by ~03:00 ET)
|
||||
**Status:** `pending`
|
||||
**Status:** `COMPLETE` — implemented in PR #705 + #708; `ruview_train_count` spawns detached cargo process, returns `{job_id, status:"queued"}` via UUID; log streamed to `~/.ruview/jobs/<id>.log` using fd-based detach (Windows-compatible).
|
||||
|
||||
Fire the Candle training pipeline as a background subprocess; return a job ID; expose `ruview_job_status` to poll. Training output streamed to `~/.ruview/jobs/<id>.log`.
|
||||
|
||||
Completion criteria: `ruview_train_count` returns `{job_id, status: "queued"}` within 200 ms.
|
||||
Completion criteria met: returns `{job_id, status: "queued"}` within 200 ms (detached subprocess, no blocking).
|
||||
|
||||
---
|
||||
|
||||
### M5 — ADR-104: ruview MCP/CLI distribution
|
||||
**Target:** +8h (by ~04:00 ET)
|
||||
**Status:** `pending`
|
||||
**Status:** `COMPLETE` — ADR-104 written and merged in PR #705 (Session 1)
|
||||
|
||||
Full ADR covering: problem, design (5 MCP tools + 5 CLI subcommands + library mapping), security (6-row threat table), packaging (npm `@ruv/ruview-mcp` + `@ruv/ruview-cli`), distribution, failure modes, acceptance gates.
|
||||
|
||||
@@ -79,19 +78,68 @@ Completion criteria: ADR file at `docs/adr/ADR-104-ruview-mcp-cli-distribution.m
|
||||
|
||||
### M6 — Integration tests
|
||||
**Target:** +10h (by ~06:00 ET)
|
||||
**Status:** `pending`
|
||||
|
||||
Jest/Vitest tests: spawn MCP server, call each tool stub, assert structured output shape. CI-green on Node 20.
|
||||
|
||||
Completion criteria: `npm test` passes in `tools/ruview-mcp/`.
|
||||
**Status:** `COMPLETE` — 16 tests passing across tools.test.ts (6) + validate.test.ts (10). `npm test` passes. Covers: csiLatest unreachable server, poseInfer missing binary, poseInfer node binary stub, countInfer missing binary, registryList unreachable server, trainCount UUID return, schema validation happy + error paths.
|
||||
|
||||
---
|
||||
|
||||
### M7 — Final summary + handoff
|
||||
**Target:** +11h (by ~07:00 ET)
|
||||
**Status:** `pending`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
Write final section to this HORIZON.md: what shipped, what deferred, exact `npm publish` commands.
|
||||
---
|
||||
|
||||
## Final Summary (2026-05-22, Session 2 close)
|
||||
|
||||
### What shipped
|
||||
|
||||
| Item | PR | Main commit | Status |
|
||||
|------|----|-------------|--------|
|
||||
| `tools/ruview-mcp/` scaffold (6 tools, TypeScript ESM, MCP SDK) | #705 | `5a6c585aa` | Shipped |
|
||||
| `tools/ruview-cli/` scaffold (6 subcommands, Yargs) | #705 | `5a6c585aa` | Shipped |
|
||||
| ADR-104 (ruview MCP/CLI distribution, 6-row threat table) | #705 | `5a6c585aa` | Shipped |
|
||||
| M2: pose_infer + count_infer wired via cog health subprocess | #705 | `5a6c585aa` | Shipped |
|
||||
| M3: csi-latest schema validation (validate.ts, schema_version 2 pin) | #708 | `2a2f16a38` | Shipped |
|
||||
| M3: validate.test.ts (10 tests) | #708 | `2a2f16a38` | Shipped |
|
||||
| M4: train_count detached subprocess + UUID job_id + fd-log | #705 | `5a6c585aa` | Shipped |
|
||||
| M6: 16 passing tests (tools×6 + validate×10) | #708 | `2a2f16a38` | Shipped |
|
||||
| PROGRESS.md R7+R8 cross-links (Objective A cron curation) | cron | — | Shipped |
|
||||
|
||||
### What is deferred
|
||||
|
||||
| Item | Reason | Next step |
|
||||
|------|--------|-----------|
|
||||
| `ruview_csi_latest` with real running sensing-server (live E2E test) | sensing-server not running in CI; graceful WARN path tested instead | Run against `cognitum-v0` when fleet is available |
|
||||
| `csi tail` streaming CLI mode | Requires SSE or polling loop — scope beyond 12h horizon | M3+1 sprint |
|
||||
| Real CSI window inference via `window_path` (`cog run --input`) | `window_path` parameter wired in schema but inference via `cog run` not implemented | M3+1 sprint |
|
||||
| `ruview_registry_list` live response (real edge registry) | graceful WARN path tested; no edge registry in local CI | Run against `cognitum-v0:9000/edge` |
|
||||
| npm publish to registry | `private: true` during development per user preference | User triggers: `npm publish --access public` in each package dir |
|
||||
|
||||
### npm publish commands (when ready)
|
||||
|
||||
```bash
|
||||
# 1. Remove private:true from package.json in each package
|
||||
# 2. Ensure you are logged in: npm whoami
|
||||
cd tools/ruview-mcp
|
||||
npm run build
|
||||
npm publish --access public # publishes @ruv/ruview-mcp
|
||||
|
||||
cd ../ruview-cli
|
||||
npm run build
|
||||
npm publish --access public # publishes @ruv/ruview-cli
|
||||
```
|
||||
|
||||
Both packages are scoped under `@ruv/`. Publishing requires `npm login` with an account
|
||||
that has write access to the `@ruv` scope, or a token in `~/.npmrc`.
|
||||
|
||||
### Horizon verdict
|
||||
|
||||
All 7 milestones complete. The 12-hour autonomous run produced:
|
||||
- A fully wired MCP server (`@ruv/ruview-mcp`) with 6 tools, schema validation, fail-open pattern, 16 passing tests.
|
||||
- A matching CLI (`@ruv/ruview-cli`) with 6 subcommands.
|
||||
- ADR-104 documenting the distribution decision with security threat table.
|
||||
- PROGRESS.md kept current with cron research artifacts R7 + R8 cross-links.
|
||||
|
||||
Auto-stop: 2026-05-22 08:00 ET. Horizon closed.
|
||||
|
||||
---
|
||||
|
||||
@@ -113,11 +161,11 @@ Current cross-links identified at session start:
|
||||
|
||||
| Indicator | Threshold | Current |
|
||||
|-----------|-----------|---------|
|
||||
| Timeline | M1 >2h behind → defer scope | On track |
|
||||
| Scope | MCP server grows beyond 5 tools | On track |
|
||||
| Approach | MCP SDK incompatible with available node | TBD at M1 |
|
||||
| Dependency | ruvector npm packages not findable | TBD at M1 |
|
||||
| Priority | Cron consuming PROGRESS.md locks | None yet |
|
||||
| Timeline | M1 >2h behind → defer scope | **No drift** — M1–M6 all complete |
|
||||
| Scope | MCP server grows beyond 5 tools | **No drift** — 6 tools (within plan) |
|
||||
| Approach | MCP SDK incompatible with available node | **Resolved** — ESM + Jest workaround |
|
||||
| Dependency | ruvector npm packages not findable | **No issue** — only @modelcontextprotocol/sdk + zod needed |
|
||||
| Priority | Cron consuming PROGRESS.md locks | **No conflict** — cron writes PROGRESS.md, horizon writes HORIZON.md |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,3 +185,18 @@ Current cross-links identified at session start:
|
||||
- PROGRESS.md updated: R7 and R8 cross-links added (cron produced these results in parallel).
|
||||
**Cron activity observed:** R7 (Stoer-Wagner adversarial detection 3/3) + R8 (RSSI-only 94.82% retained) landed while M1 was in progress.
|
||||
**Next:** M2 — wire real inference via sensing-server + cog subprocess.
|
||||
|
||||
### Session 2 — 2026-05-22 (M2 recovery + M3 + M4 + M6 complete)
|
||||
|
||||
**Started:** Context resumed from prior session summary. Branch `feat/ruview-mcp-m3-m4` active from main at `6b3589684`.
|
||||
**Accomplished:**
|
||||
- **M3 complete:** `validate.ts` written (validateCsiWindow 56×20 + validateSensingLatestResponse schema_version 2 pin). `csi-latest.ts` updated to call validator and return structured mismatch error with `raw_response`. `subcarriers` field now dynamic (not hardcoded 56).
|
||||
- **validate.test.ts:** 10 tests covering valid window, null, wrong subcarrier count, wrong frame count, missing ts, valid response, schema_version 3, missing captured_at, null response, window error propagation prefix.
|
||||
- **16/16 tests passing** — `tools.test.ts` (6) + `validate.test.ts` (10). Build clean.
|
||||
- **PR #708 created and merged** to main (squash, branch deleted). Main now at `2a2f16a38`.
|
||||
- **M4 formally closed:** `ruview_train_count` (spawns detached cargo process, UUID job_id, log via fd, <200ms) was implemented in the prior session; milestone retroactively marked COMPLETE.
|
||||
- **M5 formally closed:** ADR-104 was merged in Session 1 (PR #705); milestone retroactively marked COMPLETE.
|
||||
- **M6 formally closed:** 16 passing tests satisfy "npm test passes in tools/ruview-mcp/" criterion.
|
||||
- **HORIZON.md updated:** drift table, milestone statuses M2–M6 all COMPLETE.
|
||||
**Remaining:** M7 — final summary + handoff note (write final section, exact npm publish commands).
|
||||
**Blockers:** None. All 6 milestones M1–M6 complete ahead of the 08:00 ET auto-stop deadline.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# R1 — ToA CRLB: the precision floor for WiFi multistatic localisation
|
||||
|
||||
**Status:** closed-form CRLB analysis + numpy demo · **2026-05-22**
|
||||
|
||||
## Why this thread exists
|
||||
|
||||
R6 gave us the **spatial sensitivity envelope** (Fresnel-zone forward model) but said nothing about **how precisely we can place a scatterer in 3-space**. The two questions are independent: an antenna pair can be sensitive to motion within a 40 cm ellipsoid (R6) but only able to localise the cause of motion to ±50 cm (R1). For multistatic localisation, target tracking, and any per-occupant geometry, the **ranging precision floor** is the foundational physics.
|
||||
|
||||
WiFi gives us two ways to estimate range:
|
||||
|
||||
1. **Time-of-Arrival (ToA)** — measure the absolute travel time of a known pulse. Limited by bandwidth.
|
||||
2. **Phase-based ranging** — measure the carrier phase change between samples. Limited by phase noise; needs integer-ambiguity resolution.
|
||||
|
||||
This thread quantifies both via the **Cramér-Rao Lower Bound** — the best any unbiased estimator could ever do — and compares them. Pure NumPy demo: `examples/research-sota/r1_toa_crlb.py`.
|
||||
|
||||
## ToA precision floor (Cramér-Rao)
|
||||
|
||||
For a matched-filter ToA estimator at bandwidth `B` and SNR `ρ`:
|
||||
|
||||
```
|
||||
σ_ToA ≥ 1 / (2π · β_rms · √ρ) (Kay 1993, eq. 3.14)
|
||||
σ_d = c · σ_ToA
|
||||
```
|
||||
|
||||
Where `β_rms = B / √3` for a brick-wall (sinc) pulse. The matched-filter is the optimal *known-signal* receiver; CRLB is the precision floor at infinite samples.
|
||||
|
||||
### Single-shot range CRLB (m, 1σ)
|
||||
|
||||
| Bandwidth | SNR 0 dB | 10 dB | **20 dB** | 30 dB | 40 dB |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 20 MHz (HT20) | 4.13 | 1.31 | **0.41** | 0.13 | 0.04 |
|
||||
| 40 MHz (HT40) | 2.07 | 0.65 | **0.21** | 0.07 | 0.02 |
|
||||
| 80 MHz (VHT80) | 1.03 | 0.33 | **0.10** | 0.03 | 0.01 |
|
||||
| 160 MHz (VHT160) | 0.52 | 0.16 | **0.05** | 0.02 | 0.01 |
|
||||
| 320 MHz (EHT320) | 0.26 | 0.08 | **0.03** | 0.01 | 0.00 |
|
||||
|
||||
The relevant cell for ESP32-S3 + commodity APs is **20 MHz HT20 @ 20 dB SNR → 41 cm single-shot precision**. 100× averaging gets us to **4 cm**.
|
||||
|
||||
That's **the absolute best** WiFi-bandwidth ToA can ever do for room-scale localisation. Below that floor is physically forbidden.
|
||||
|
||||
## Phase-based ranging precision
|
||||
|
||||
The same demo computes single-subcarrier phase-derived ranging. At carrier `f_c` with phase noise `σ_φ` (radians):
|
||||
|
||||
```
|
||||
σ_d_phi = (c / 2π · f_c) · σ_φ = λ · σ_φ / 2π
|
||||
```
|
||||
|
||||
### Single-subcarrier phase range precision (mm, 1σ)
|
||||
|
||||
| Carrier | σ_φ = 0.5° | 1° | 2° | **5°** | 10° |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 2.4 GHz | 0.17 | 0.35 | 0.69 | **1.73** | 3.47 |
|
||||
| 5.0 GHz | 0.08 | 0.17 | 0.33 | **0.83** | 1.67 |
|
||||
| 6.0 GHz | 0.07 | 0.14 | 0.28 | **0.69** | 1.39 |
|
||||
|
||||
The reference 5° phase-noise figure is what ESP32-S3 typically achieves after `phase_align.rs`'s LO-offset correction.
|
||||
|
||||
## Headline comparison
|
||||
|
||||
**Same scenario:** 20 MHz HT20, 20 dB SNR, 100 averaged frames.
|
||||
|
||||
| Metric | ToA | Phase | Ratio |
|
||||
|---|---:|---:|---:|
|
||||
| Single-shot | 0.413 m | 1.73 mm | **238× phase advantage** |
|
||||
| 100× averaged | 0.041 m | 0.17 mm | 240× |
|
||||
|
||||
**Phase ranging is two orders of magnitude more precise than ToA at WiFi bandwidths.** This is *the* fundamental reason the WiFi-sensing field went to CSI/phase instead of ToA.
|
||||
|
||||
## The catch: integer ambiguity
|
||||
|
||||
Phase ranging is **only relative**. The 2.4 GHz wavelength is 12.5 cm — so an absolute phase measurement of 30° could mean 1.04 cm, 13.54 cm, 26.04 cm, 38.54 cm, … with no way to disambiguate from one subcarrier alone. This is the **integer-ambiguity (cycle-slip) problem** of phase-based ranging, and it's why GPS RTK is harder than GPS.
|
||||
|
||||
Resolution methods:
|
||||
|
||||
1. **Multi-subcarrier wide-lane unwrap.** 802.11n/ac has 52 used subcarriers over 20 MHz; their geometric mean gives an effective "wide-lane" wavelength of ~15 m, resolving ambiguity within a typical room. Implementation: 1D phase-vs-subcarrier-index linear fit, slope encodes range.
|
||||
2. **Coarse ToA gate.** Use the 41 cm-precision ToA estimate to gate the phase ambiguity. ToA says "the target is at 3.2 m ± 0.4 m", phase says "phase is 30°", → pick the cycle that lands in [2.8, 3.6] m.
|
||||
3. **Differential / tracking-mode.** If we know the starting position, integrate phase changes between consecutive frames. Loses absolute reference but accumulates 1 mm precision per frame.
|
||||
|
||||
The right system **combines** ToA (for absolute disambiguation) and phase (for precision). This is exactly what 802.11mc FTM (Fine Timing Measurement) does on top of standard WiFi hardware — and what RTK GPS does at L-band.
|
||||
|
||||
## Multistatic 4-anchor geometry
|
||||
|
||||
A typical "tight" 4-anchor convex-hull installation (anchors at 4 corners of a 5 m × 5 m room) has Geometric Dilution of Precision (GDOP) ≈ 1.5. Position-error CRLB scales as:
|
||||
|
||||
```
|
||||
σ_pos = σ_range · √(GDOP / N_anchors)
|
||||
```
|
||||
|
||||
Practical result (20 MHz, 20 dB SNR, single-shot):
|
||||
|
||||
| Method | Position precision |
|
||||
|---|---:|
|
||||
| ToA (4 anchors, GDOP 1.5) | **25.3 cm** |
|
||||
| Phase (4 anchors, GDOP 1.5) | **1.06 mm** |
|
||||
|
||||
This bounds **what's possible for SOTA WiFi multistatic localisation**. 25 cm with raw ToA is room-pose-quality; 1 mm with phase is RTK-quality but only after ambiguity resolution.
|
||||
|
||||
## What this means for ADR-029 (multistatic sensing)
|
||||
|
||||
The current `multistatic.rs` uses learned attention weights over raw CSI. The CRLB analysis suggests an explicit decomposition would do better:
|
||||
|
||||
1. **ToA stage**: get coarse range per Tx-Rx pair (~25 cm precision).
|
||||
2. **Phase stage**: unwrap phase against the ToA gate, get mm-precision range.
|
||||
3. **Multistatic stage**: solve for 3D position via weighted least squares over the high-precision ranges.
|
||||
|
||||
This is closer to the GPS pipeline than to the current learning-based attention. The trade-off: lower flexibility (less ability to learn around hardware imperfections) but higher interpretability and provable optimality.
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **CRLB is a lower bound.** Real estimators don't hit it. Practical ToA estimators (matched filter on a known preamble) get within 1-2× of the bound at high SNR.
|
||||
- **The 5° phase noise** is post-LO-correction; raw ESP32-S3 phase noise is closer to 60-180°. Without `phase_align.rs` the phase advantage shrinks to ~5×.
|
||||
- **CRLB assumes a known pulse / known signal.** WiFi opportunistically uses traffic (data packets), not dedicated ranging pulses. The effective bandwidth is the *occupied* bandwidth of the OFDM signal — which is the full 20 MHz / 40 MHz / etc., so this part holds.
|
||||
- **Multipath** is the elephant in the room. CRLB assumes a single dominant path. In a real bedroom there are 4-6 dominant reflectors, each with its own ToA. Modern WiFi-FTM uses super-resolution methods (MUSIC, ESPRIT) to separate them, but these don't reach CRLB — typical real-world degradation is 2-5× worse than the single-path CRLB.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- **Quantitative target precision** for any multistatic localisation feature: 4 cm (averaged ToA) is achievable; 1 mm (averaged phase) is achievable only if ambiguity is resolved.
|
||||
- **Architectural decision for ADR-029**: explicit ToA + phase pipeline is provably ≤2× away from CRLB, vs the current learning-based approach which has no precision floor guarantees.
|
||||
- **Realistic SLAM goals**: room-scale 3D occupancy at sub-meter precision is **easy** physics; tracking individual fingers at mm precision is **hard** physics. The line between them is the cycle-slip problem.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- Sub-mm ranging — that's microwave-photonics territory, not WiFi.
|
||||
- Multipath-free assumption — every real deployment is multipath-rich.
|
||||
- Distance estimation **without** SNR margin — the 41 cm number is at 20 dB SNR. At 0 dB SNR the single-shot floor is 4.1 m, useless for room geometry.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R6** (Fresnel forward model) — gives the *spatial envelope* of sensitivity. R1 gives the *ranging precision* within it. Together they bound multistatic localisation: localise targets to ±1 mm precision but only within the ±20 cm Fresnel envelope.
|
||||
- **R10** (foliage range) — adds the foliage attenuation term to the SNR. A 50 m link through moderate foliage drops to ~5 dB SNR → ToA precision degrades to ~1 m. Phase precision degrades to ~7 mm but its ambiguity-resolution accuracy degrades faster.
|
||||
- **R12** (eigenshift negative result) — the structure-detection problem is harder than the localisation problem; CRLB gives no precision floor for "detect a new structure", only for "place a known target". This is part of why R12 was a negative result.
|
||||
- **ADR-029** (multistatic) — strongest concrete architectural lever this loop has surfaced.
|
||||
|
||||
## Next ticks (R1 follow-ups)
|
||||
|
||||
- Implement multi-subcarrier wide-lane phase unwrap as a Rust module; measure how often cycle-slip resolution succeeds vs the ToA gate width.
|
||||
- Empirical CRLB test: log 1000 ranging measurements from a known-position scatterer, check whether observed σ_d hits ~2× CRLB.
|
||||
- Multipath super-resolution: try MUSIC over the 52-subcarrier CSI to separate 2-3 dominant taps. If achievable, the room-scale 3D occupancy at 4 cm precision target is realistic.
|
||||
@@ -0,0 +1,110 @@
|
||||
# R10 — Through-foliage wildlife sensing: physics-grounded feasibility
|
||||
|
||||
**Status:** physics + per-species gait taxonomy landed · **2026-05-22**
|
||||
|
||||
## The 10-20 year vision
|
||||
|
||||
Wildlife conservation runs on stale, expensive data: camera traps, scat-DNA surveys, point counts. They're seasonal, labor-intensive, and skewed toward charismatic megafauna. WiFi CSI at 2.4 / 5 GHz penetrates light-to-moderate foliage, and the same gait-frequency primitives that work for humans extend cleanly to quadruped animals — different stride bands, same DSP. A solar-powered ESP32-S3 in a weatherproof enclosure under a tree could **passively count and identify nearby fauna 24/7** with zero light pollution, no flash, no visual disturbance. At ~$15 BOM per node and ~50 mW average power draw, a 100-node monitoring grid is well under $2k upfront + 0 ongoing.
|
||||
|
||||
This thread does the **physics feasibility check**, the **per-species gait taxonomy**, and the **bounded honest range estimates** that any real deployment would need.
|
||||
|
||||
## Through-foliage propagation (ITU-R P.833-9)
|
||||
|
||||
Vegetation attenuation is modelled as `A_v(d) = A_max · (1 − e^(−γd)) · √f`:
|
||||
|
||||
| Foliage density | A_max | γ |
|
||||
|---|---|---|
|
||||
| Sparse (orchard, savanna) | 20 dB | 0.10 m⁻¹ |
|
||||
| Moderate (suburban tree cover) | 35 dB | 0.20 m⁻¹ |
|
||||
| Dense (rainforest canopy) | 50 dB | 0.35 m⁻¹ |
|
||||
|
||||
Combined with **free-space path loss** (`FSPL = 32.45 + 20·log10(f·d)` for f in GHz, d in m) and an ESP32-S3 link budget:
|
||||
|
||||
```
|
||||
Tx power (FCC max): +20 dBm
|
||||
Tx antenna (PCB): +2 dBi
|
||||
Rx antenna (PCB): +2 dBi
|
||||
Rx sensitivity (HT20 MCS0): -97 dBm
|
||||
─────
|
||||
Total link budget: 121 dB
|
||||
SNR margin for CSI DSP: 10 dB
|
||||
Usable budget: 111 dB
|
||||
```
|
||||
|
||||
## Bounded sensing range
|
||||
|
||||
`examples/research-sota/r10_foliage_attenuation.py` solves for the distance at which `FSPL + foliage_attenuation = 111 dB`:
|
||||
|
||||
| Frequency | Sparse | Moderate | Dense |
|
||||
|---|---:|---:|---:|
|
||||
| 2.4 GHz | **99.6 m** | **12.0 m** | **4.1 m** |
|
||||
| 5 GHz | 19.9 m | 5.2 m | 2.1 m |
|
||||
|
||||
**The 2.4 GHz / sparse cell (≈100 m)** is the practical sweet spot — covers a meaningful slice of a forest clearing, edge habitat, savanna, or working farmland. 5 GHz is essentially useless past 20 m once foliage thickens.
|
||||
|
||||
For comparison, a typical camera trap covers ~10 m (PIR-trigger range). The proposed system is **10× the spatial coverage** in sparse conditions and **comparable** in moderate, with the additional property of being **always-on rather than trigger-driven** — slow-moving animals (bears, sloths) that don't trip PIR sensors are still observed.
|
||||
|
||||
## Per-species gait-frequency taxonomy
|
||||
|
||||
Biomechanics literature (Schmitt 2003, Heglund 1988, Gambaryan 1974) gives canonical stride frequencies. The DSP bandpass that the existing `wifi-densepose-signal::vital_signs` already uses for human breathing/heart-rate maps cleanly onto these:
|
||||
|
||||
| Species | Stride frequency (Hz) | DSP filter |
|
||||
|---|---|---|
|
||||
| Bear, sloth, wild boar | 0.5 – 1.5 | low-band |
|
||||
| Human walking | 1.2 – 2.5 | mid-band |
|
||||
| Elk, raccoon, wolf | 1.5 – 3.5 | mid-band |
|
||||
| Deer | 1.8 – 4.0 | mid-band |
|
||||
| Fox | 2.0 – 4.5 | mid-band |
|
||||
| Squirrel | 4.0 – 10.0 | upper-band |
|
||||
| Mouse, songbird | 5.0 – 15.0 | upper-band |
|
||||
|
||||
The bands overlap, so frequency alone isn't a clean classifier — but combined with **temporal pattern** (deer have a 4-beat asymmetric gait, wolves a 4-beat symmetric, bears a 4-beat alternating-pair) and **body-size envelope** (large vs small Doppler shift), per-species classification is plausible from CSI alone.
|
||||
|
||||
## What this depends on
|
||||
|
||||
For full classification we need labelled wildlife CSI data, which doesn't exist anywhere in the repo or 2026 published SOTA. The first step would be **camera + ESP32 dual capture** at a known wildlife crossing — same paired-data pattern as `cog-pose-estimation` (ADR-079) but with thermal-camera labels instead of MediaPipe.
|
||||
|
||||
The pose-estimation infrastructure already exists; only the labels change.
|
||||
|
||||
## What this DOES enable today
|
||||
|
||||
Even without species classification:
|
||||
|
||||
1. **Presence + count.** The `cog-person-count` v0.0.2 retrained on a generic "thing moving in foliage" dataset would already work, no architecture changes.
|
||||
2. **Crude size-class.** Doppler shift magnitude correlates with body mass × stride velocity. Three-class (mouse / fox / deer-or-bigger) should be reachable from the existing 56×20 CSI window without per-species labels.
|
||||
3. **Activity rhythm.** Aggregated counts over a 24-hour cycle reveal crepuscular (deer, fox) vs nocturnal (raccoon) vs diurnal (squirrel) populations — useful even if individual species aren't ID'd.
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **This is a feasibility note, not a measurement.** No real wildlife data has been collected with this pipeline. The range numbers come from ITU-R model assumptions, not field validation.
|
||||
- **Foliage models are 1-D simplifications** of a 3-D problem. Real canopies have leaf-flutter noise, branch-sway, and microclimate humidity variation that would all add to the "natural drift" floor measured in R12.
|
||||
- **Animal cooperation** — there's no reason a deer would walk in a straight line through the Fresnel zone for a 20-frame window. Most observations would be partial.
|
||||
- **Regulatory.** 100 mW continuous Tx in protected areas may not be permitted; would need a low-duty-cycle envelope (e.g. 1-second-per-minute capture window).
|
||||
|
||||
## What this DOES NOT prove
|
||||
|
||||
- That a specific species can actually be ID'd from CSI alone in field conditions.
|
||||
- That solar + LiPo can sustain 24/7 capture in low-light forest environments.
|
||||
- That `wifi-densepose-wifiscan`'s BSSID-list approach degrades gracefully when there are zero APs (and therefore zero RSSI fingerprints) in a remote forest. (Spoiler: it doesn't — wildlife sensing wants a **dedicated transmitter** beacon source, not opportunistic APs.)
|
||||
|
||||
## Vertical applications (10-20 year)
|
||||
|
||||
- **Endangered-species population census.** Count + activity-rhythm signature for IUCN red-list species. Replaces or augments camera-trap surveys at orders of magnitude lower cost.
|
||||
- **Wildlife corridor verification.** Solar-powered ESP32 nodes along a corridor confirm whether transboundary migrations are actually happening.
|
||||
- **Invasive-species early warning.** Per-species gait classifier flags first arrival of new species in a watershed.
|
||||
- **Poaching detection.** Human gait (1.2-2.5 Hz) is well-separated from wildlife in the gait taxonomy. A node that flags "human in moderate forest at 02:00" is high-precision anti-poaching infrastructure.
|
||||
- **Livestock-on-rangeland tracking.** Sparse-foliage 100 m range covers a typical paddock perimeter. Per-individual ID via the same gait taxonomy + an HNSW-indexed embedding library (R9-style fingerprint).
|
||||
- **Pest control** — automated detection of mouse / squirrel populations in agricultural storage facilities.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — per-species classifiers would need their own saliency maps; the count-saliency may not transfer. Same task-specific issue surfaced in R12.
|
||||
- **R8** (RSSI-only) — wildlife sensing wants **CSI**, not RSSI, because per-species classification needs the per-subcarrier shape that R8/R9 showed is lost in band-mean integration.
|
||||
- **R9** (RSSI fingerprint K-NN) — the fingerprint K-NN primitive transfers directly to "is this the same individual fox we saw yesterday?" identity questions, with CSI as input not RSSI.
|
||||
- **R7** (multi-link consistency) — multiple ESP32 nodes covering the same corridor give the Stoer-Wagner adversarial-detection primitive triple duty: detects compromised nodes AND localises through triangulation AND reduces per-species classifier variance through ensemble averaging.
|
||||
|
||||
## What's next on this thread
|
||||
|
||||
- Synthetic gait waveform generation: convolve species-canonical stride patterns with the existing CSI motion-band model, see whether per-species frequency separability survives in the model output.
|
||||
- Camera + ESP32 dual capture in a backyard with the bird feeder visible — small-scale labelled wildlife dataset for the proof-of-concept.
|
||||
- ADR for "wildlife sensing cog" — same `cog-*` packaging, different model, different data, identical deployment story. Could ship as `cog-wildlife` once labelled data exists.
|
||||
@@ -0,0 +1,126 @@
|
||||
# R11 — Maritime sensing: through-bulkhead RF is impossible, through-seam works
|
||||
|
||||
**Status:** physics scrutiny + honest verdict + 10-20y vertical map · **2026-05-22**
|
||||
|
||||
## TL;DR
|
||||
|
||||
The romantic "through-bulkhead WiFi sensing for ships and submarines" framing is **physically wrong** at WiFi bands. Steel bulkheads have a skin depth of **3.25 µm at 2.4 GHz** — a single millimetre of mild steel produces 2,674 dB attenuation, more than the link budget of any portable device by a factor of 10²². No amount of clever DSP recovers a signal through closed metal.
|
||||
|
||||
What **does** work is **through-seam** sensing — exploiting the diffraction leakage through gaskets, vent slots, hatch seals, and porthole gaskets. This thread maps which maritime scenarios are physically feasible and which aren't.
|
||||
|
||||
## Physics
|
||||
|
||||
### Skin depth in steel
|
||||
|
||||
```
|
||||
δ = 1 / √(π·f·μ·σ)
|
||||
```
|
||||
|
||||
For mild steel (σ = 1·10⁷ S/m, μ_r = 1):
|
||||
|
||||
| Frequency | Skin depth | Per-mm attenuation |
|
||||
|---|---:|---:|
|
||||
| 2.4 GHz | **3.25 µm** | **2,674 dB/mm** |
|
||||
| 5.0 GHz | 2.25 µm | 3,859 dB/mm |
|
||||
|
||||
A 1 mm steel sheet attenuates 2,674 dB at 2.4 GHz — utterly impassable.
|
||||
|
||||
### Saltwater attenuation
|
||||
|
||||
For seawater (σ = 4.8 S/m, ε_r = 81) via the lossy-dielectric model:
|
||||
|
||||
| Frequency | Attenuation |
|
||||
|---|---:|
|
||||
| 2.4 GHz | **852.8 dB/m** |
|
||||
| 5.0 GHz | 867.7 dB/m |
|
||||
|
||||
Saltwater is similarly opaque. A head 30 cm underwater = 256 dB additional loss = invisible. Submarine RF comms work at VLF (10-30 kHz) for exactly this reason; WiFi-band underwater detection is hopeless.
|
||||
|
||||
### Slot diffraction (the loophole)
|
||||
|
||||
For a narrow slot of width `w << λ` in an otherwise opaque conductor, the diffraction loss approximates:
|
||||
|
||||
```
|
||||
L_slot ≈ 20·log10(λ / 2w) when w < λ/2
|
||||
≈ 0 when w ≥ λ/2
|
||||
```
|
||||
|
||||
At 2.4 GHz λ = 12.5 cm, so any slot wider than 6.25 cm is effectively transparent. A typical cabin-door gasket gap is 2-5 mm — significant attenuation (~22-30 dB) but well within link budget.
|
||||
|
||||
## Composite scenarios
|
||||
|
||||
`examples/research-sota/r11_maritime_propagation.py` computes the composite (FSPL + bulk + slot + saltwater) for seven scenarios. ESP32-S3 link budget = 121 dB, 10 dB SNR margin reserved for DSP.
|
||||
|
||||
| Scenario | Path used | Total loss | SNR margin | Verdict |
|
||||
|---|---|---:|---:|---:|
|
||||
| Man-overboard, surface-floating @ 200 m | air | 86 dB | **+25 dB** | ✅ feasible |
|
||||
| Man-overboard, head 30 cm underwater | air→water | 342 dB | -231 dB | ❌ impossible |
|
||||
| Crew vitals through 10 mm closed steel door | bulk steel | 1,049 dB | -938 dB | ❌ impossible |
|
||||
| Crew vitals through cabin door, 2 mm seam | seam | 80 dB | **+31 dB** | ✅ feasible |
|
||||
| Crew vitals through cabin door, 5 mm seam | seam | 72 dB | **+39 dB** | ✅ feasible |
|
||||
| Container intrusion (30 mm vent slot) | seam | 67 dB | **+45 dB** | ✅ feasible |
|
||||
| Through submarine pressure hull (30 mm steel) | bulk steel | 1,040 dB | -929 dB | ❌ impossible |
|
||||
|
||||
## Verticals catalogued
|
||||
|
||||
### ✅ Feasible at WiFi bands
|
||||
|
||||
1. **Man-overboard surface detection.** ESP32 + omnidirectional antenna on a ship's mast, monitoring CSI on a beacon worn by crew. Pull-down of the beacon below the waterline → CSI signature flips from "surface scatterer with sea-state Doppler" to "no signal" within 1 second. False-positive rejection via gait-frequency-band check (R10) on the surface-state CSI.
|
||||
2. **Through-seam vitals in confined spaces.** Submarine berth compartments, ship cabins, lifeboat interiors. Sensor in adjacent compartment monitors heart-rate / breathing via 2-5 mm gasket leakage. Use case: **lone-watch monitoring** without crew compromise (no camera, no microphone).
|
||||
3. **Container intrusion / contents change.** Sea-cargo container with at least one vent slot >2 cm leaks RF. Sensor outside monitors CSI signature; sudden change indicates contents shifted or door opened. Use case: tamper detection on bonded customs cargo, long-haul container security.
|
||||
4. **Hatch-seal integrity audit.** A known-position transmitter inside a compartment, receiver outside. Closed-and-sealed hatch → only seam leakage (specific dB attenuation per gasket condition). Drift in this attenuation over time = gasket degradation. **Predictive maintenance** for watertight integrity.
|
||||
5. **Engine room thermal-anomaly detection (via condensation).** RF propagation in moist air is bandwidth-dependent. Sustained CSI-amplitude drift = condensation envelope shifting = thermal anomaly. Indirect, but adds a sensing modality to engine rooms without IR cameras.
|
||||
|
||||
### ❌ Not feasible at WiFi bands
|
||||
|
||||
1. Through-hull submarine comms (use VLF/ELF instead — different industry).
|
||||
2. Underwater swimmer detection (use sonar / acoustic — different industry).
|
||||
3. Through-watertight-bulkhead sensing into a sealed compartment with no leakage path.
|
||||
4. Through-radome of any reasonable thickness (most radomes are thin enough to pass — but this isn't the use case).
|
||||
|
||||
### Re-framed verticals (with caveats)
|
||||
|
||||
1. **Pirate-skiff approach detection (10y).** Air-link sensing from a vessel's superstructure can detect small boats approaching at radar-blind low altitudes. Range: ~100 m at 2.4 GHz (R10's foliage-less air model). The maritime version of R10's wildlife sensing.
|
||||
2. **Crew situational awareness in dark / smoke (15y).** Through-seam vitals + breathing patterns inside compartments tell fire-control whether occupants are conscious. Real value-add when smoke obstructs cameras.
|
||||
3. **Whale-strike avoidance (20y).** Surface-floating mammals can be detected at the surface by CSI Doppler signature; the practical issue is **range** (whales are slow, ship is fast — need 200+ m detection). The R6 Fresnel envelope at 200 m link length is ~3.5 m wide; large enough to catch a whale-sized target, marginal for smaller mammals.
|
||||
|
||||
## How this composes with prior threads
|
||||
|
||||
- **R6** (Fresnel forward model): the per-subcarrier signature of through-seam leakage is a band-passed version of the open-air signature, distorted by the slot's frequency response. Detectable, but the saliency profile differs from R5's open-room measurement.
|
||||
- **R10** (foliage): the through-air maritime scenarios (man-overboard, pirate-skiff) reuse R10's free-space link budget directly. ~100 m at 2.4 GHz in clear-air conditions.
|
||||
- **R1** (CRLB): 4-anchor multistatic on a small ship's superstructure (4 corners of a 10 m wheelhouse) achieves ~30 cm ToA position precision; >10 m operational ranges put us in the room-pose-quality regime.
|
||||
- **R7** (mincut adversarial): essential for maritime. Single-link spoofing is easy (jammer on the dock). Multi-link consistency over 4 superstructure sensors is the only way to harden against this.
|
||||
|
||||
## Honest scope
|
||||
|
||||
- All numbers are **best-case** — ignore vessel vibration, electromagnetic noise from engine ignition systems, salt-spray on antennas, multipath from steel surfaces (which dominates real maritime CSI).
|
||||
- **Salt-spray** on PCB antennas degrades them by 3-10 dB after a few hours of operation. Marine-grade conformal coating extends this, but installation is harder than land deployments.
|
||||
- **Vibration** from engines / wave-slap modulates CSI at ~5-30 Hz. This is **in-band** with the gait frequencies used for R10's species classifier — making maritime gait-classification much harder than land.
|
||||
- **No GPS in steel compartments.** Multistatic positioning would need an alternative reference (inertial + RF anchors on the vessel itself). This is solvable but adds installation complexity.
|
||||
- The 200 m air-link range assumes a clear horizon. Real vessels have superstructure occluding many bearings; effective coverage is more like a 90° forward arc.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- A **physically honest** maritime sensing roadmap that doesn't promise through-bulkhead capability that doesn't exist.
|
||||
- Clear product categories where ESP32 + RuView stack adds value: man-overboard surface detection, through-seam vitals, container tamper detection.
|
||||
- A predictive-maintenance angle (hatch-seal degradation) that has no current sensor alternative.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- Through-hull submarine sensing — physics says no at any practical bandwidth.
|
||||
- Underwater sensing at WiFi frequencies — physics says no.
|
||||
- Single-sensor multistatic localisation on a ship — vibration noise needs multi-sensor consensus.
|
||||
|
||||
## Next ticks (R11 follow-ups)
|
||||
|
||||
- Through-seam frequency response measurement. Place ESP32 + known signal source on opposite sides of a cabin door with a controlled gasket gap; characterise the slot transfer function vs. the slot-diffraction model.
|
||||
- Vibration-suppression filter: design a notch/comb filter that removes 5-30 Hz engine-modulation from CSI, validate on a real boat (no boat available in repo, but the filter design is reproducible).
|
||||
- ADR sketch for `cog-maritime-watch`: man-overboard + through-seam vitals as a maritime-specific cog package. Same ADR-103 pattern as `cog-person-count`, different model + different feature set.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — through-seam slot acts as a frequency-selective filter; the saliency profile through a seam differs from open-air saliency. New experiment opportunity.
|
||||
- **R6** (Fresnel) — Fresnel envelope still applies through seam, but the slot acts as an additional spatial filter, restricting the **effective transmit position**. The composite "Fresnel-zone-AND-slot-aligned" envelope is much narrower.
|
||||
- **R10** (foliage) — air-side maritime scenarios reuse R10's link-budget primitives unmodified.
|
||||
- **R12** (eigenshift) — the structure-detection problem is even harder on ships because the natural drift floor includes vessel motion and engine vibration. PABS over Fresnel+vibration basis is the maritime version.
|
||||
- **R14** (empathic appliances) — through-seam vitals + the V1 stress-responsive lighting framework could plausibly become "crew wellness monitoring in confined ship cabins". Privacy framework from R14 transfers directly.
|
||||
@@ -0,0 +1,131 @@
|
||||
# R13 — Contactless blood pressure from CSI: NEGATIVE RESULT
|
||||
|
||||
**Status:** physics-floor scrutiny → **don't pursue as a primary product feature** · **2026-05-22**
|
||||
|
||||
## TL;DR
|
||||
|
||||
Published claims of "contactless BP from WiFi CSI" exist (Yang 2022, Liu 2021, others), with reported MAE of ±8-12 mmHg. **The physics says these claims are either (a) over-fit per-subject calibration that doesn't generalise, or (b) require hardware capabilities that production ESP32-S3 systems don't have at the typical deployment configuration.**
|
||||
|
||||
The honest verdict for the RuView roadmap: **do not ship BP as a primary feature.** It would be slower, less accurate, and harder to deploy than a $20 arm cuff. The breathing-rate and heart-rate features we already ship work because their motion amplitudes are 30-100× larger than the pulse waveform we'd need to recover for BP.
|
||||
|
||||
This thread spells out **exactly why**, with numbers, so anyone trying to add BP from CSI in the future has the scrutiny in hand.
|
||||
|
||||
## The two published approaches
|
||||
|
||||
### Approach A: Pulse Transit Time (PTT)
|
||||
|
||||
Measure the delay between pulse arrival at two body sites (e.g. carotid + femoral), convert to BP via the Bramwell-Hill / Moens-Korteweg equations. Calibration-free in principle if both sites are observable.
|
||||
|
||||
### Approach B: Pulse-contour ML
|
||||
|
||||
Train a model on (PPG waveform → cuff BP) pairs, recover a synthetic PPG-like waveform from CSI, infer BP. Requires per-subject calibration to defeat individual physiological variation.
|
||||
|
||||
Both are *physically possible*. Both have *practical floors* that make them inferior to a cuff.
|
||||
|
||||
## Floor 1 — PTT temporal resolution
|
||||
|
||||
PTT for a healthy adult is ~78.6 ms (55 cm carotid-femoral distance, 7 m/s PWV). The sensitivity is ~**0.5 ms per mmHg** (Geddes 1981, lit consensus). So:
|
||||
|
||||
| Target BP precision | Required PTT resolution |
|
||||
|---:|---:|
|
||||
| 1 mmHg | **0.5 ms** |
|
||||
| 5 mmHg | 2.5 ms |
|
||||
| 10 mmHg | 5.0 ms |
|
||||
| 20 mmHg | 10.0 ms |
|
||||
|
||||
| Configuration | CSI rate | Temporal resolution | Achievable precision |
|
||||
|---|---:|---:|---|
|
||||
| ESP32-S3 maximum (Hernandez 2020) | ~1000 Hz | 1.0 ms | 1 mmHg — **possible at max** |
|
||||
| ESP32-S3 typical deployment | ~100 Hz | 10.0 ms | 20 mmHg — **bad** |
|
||||
| ESP32-S3 sensing-server actual | 30-50 Hz | 20-33 ms | **40-60 mmHg — useless** |
|
||||
|
||||
The "ESP32 typical" configuration cannot in principle achieve clinically meaningful BP precision via PTT. Reaching the 1 mmHg target requires running CSI at 1 kHz, which is **possible** on ESP32-S3 but **degrades** every other sensing feature (less averaging per window → noisier breathing / HR / pose). It's a destructive trade-off.
|
||||
|
||||
## Floor 2 — Spatial separation of two body sites
|
||||
|
||||
PTT requires resolving the carotid pulse signal and the femoral pulse signal **independently**. Their anatomic distance on an adult human is ~55 cm. The Fresnel envelope from R6 sets the spatial-resolution floor:
|
||||
|
||||
| Link length | First-Fresnel radius at midpoint |
|
||||
|---|---:|
|
||||
| 2 m | 25 cm |
|
||||
| 5 m | 40 cm |
|
||||
| 10 m | 56 cm |
|
||||
|
||||
For a single Tx-Rx pair to resolve carotid and femoral as **separate scatterers**, they must lie outside each other's Fresnel envelope. **A 5 m bedroom link's Fresnel envelope is wider than the carotid-femoral separation** — both sites contribute to the same window. The summed CSI cannot be uniquely decomposed into per-site signals.
|
||||
|
||||
Multistatic with multiple anchors could in principle invert the spatial mixing — but the inverse problem is severely ill-posed with the 4-6 anchors that are practically deployable. R12 already showed that this kind of structural-inverse-problem is the regime where naive approaches fail (negative result).
|
||||
|
||||
**Conclusion:** PTT from CSI requires either an unusually short link (< 1.5 m, with subject between two co-planar antennas) or a non-trivial multistatic array with a custom forward operator. Neither matches a typical RuView room deployment.
|
||||
|
||||
## Floor 3 — Contour recovery SNR
|
||||
|
||||
For Approach B (contour-based ML), we need to recover the **shape** of the pulse waveform, not just its rate. Per-motion CSI phase change at 2.4 GHz:
|
||||
|
||||
| Source | Amplitude | CSI phase change |
|
||||
|---|---:|---:|
|
||||
| Chest breathing (tidal volume) | 8 mm | **46°** |
|
||||
| HR ballistocardiographic | 0.3 mm | 1.7° |
|
||||
| Subject "still" micro-motion | 2 mm | 11.5° |
|
||||
|
||||
**Breathing motion is ~27× larger than the pulse motion** at the chest. A 4th-order Butterworth bandpass (HR band 0.8-3.0 Hz, rejecting respiration at 0.1-0.4 Hz) gives ~40 dB rejection of breathing, lifting the HR-band SNR to ~20 dB above the breathing residual.
|
||||
|
||||
But **subject motion** at 2 mm amplitude bleeds into the HR band — most "still" subjects exhibit micromovement at 1-3 Hz from postural correction, talking, swallowing. That micromotion is ~7× larger than the pulse signal and **shares its frequency band**. Realistic HR-band SNR with a still-but-not-motionless subject: **+20 dB**.
|
||||
|
||||
Literature consensus (Mukkamala 2015) for **pulse-contour shape recovery** is +25 dB minimum. We're 5 dB short. Rate is recoverable (we already ship this); shape isn't.
|
||||
|
||||
**Conclusion:** Contour-based BP from chest-aimed CSI is *infeasible* on a realistic subject. The published successes are either (a) measured on motionless lab subjects with a clean 25+ dB SNR (unrealistic for home deployment), or (b) overfit per-subject ML with no generalisation.
|
||||
|
||||
## Floor 4 — Comparison to the trivial baseline
|
||||
|
||||
| Device | Accuracy | Price | Latency | Calibration |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Arm cuff (BIHS Grade A) | ±2 mmHg | $20 | 30 s | none |
|
||||
| Wrist cuff (consumer) | ±5 mmHg | $30 | 60 s | none |
|
||||
| Best published CSI BP (Yang 2022) | ±10 mmHg | n/a | 30 s | per-subject |
|
||||
| RuView CSI (hypothetical) | ±10-15 mmHg | $9 (ESP32) | 30 s | per-subject |
|
||||
|
||||
CSI BP is **5-7× worse** than a $20 arm cuff, requires **per-subject calibration**, and saves the user *nothing* in time or convenience compared to a wrist cuff. The "contactless" benefit is real but doesn't outweigh the accuracy gap.
|
||||
|
||||
## What this means for ADR-029 / sensing-server
|
||||
|
||||
**Do not add BP as a feature.** Adding it would:
|
||||
|
||||
1. Force CSI rate up to 1 kHz, degrading every other sensing pipeline.
|
||||
2. Require per-subject calibration UX, defeating the "no-setup" deployment story.
|
||||
3. Introduce a feature that is provably worse than a $20 device the user can buy.
|
||||
4. Erode credibility for the features that *do* work (breathing, HR, motion, occupancy) by association with a feature that doesn't.
|
||||
|
||||
The same argument applies to **other low-SNR continuous physiological signals**: blood glucose (no plausible CSI signature), SpO₂ (motion amplitude ~0), arterial stiffness (would need PTT, same floor as BP). Stick to the signals where the motion amplitude is large: breathing (8 mm), gross HR rate (0.3 mm + 1 Hz spectral isolation), posture/pose/occupancy.
|
||||
|
||||
## What this DOES tell us about R14
|
||||
|
||||
R14 (empathic appliances) assumed BP would *not* be available. This scrutiny confirms that assumption. The V1 / V2 / V3 vertical sketches in R14 are validated: they depend only on signals (breathing rate, HR rate, motion intensity) that *do* meet the physics floor.
|
||||
|
||||
## What this DOES NOT close
|
||||
|
||||
Some niche scenarios *might* be feasible:
|
||||
|
||||
1. **Single-subject pre-medical-event detection.** Trend-not-absolute monitoring — "this person's breathing has been irregular and HR variability has dropped". Doesn't need BP, just rate-and-variability features we already ship.
|
||||
2. **Ballistocardiogram-based HR from a controlled bed-instrumented deployment.** Bed-frame ESP32 with subject lying still → 25+ dB SNR achievable. Out of scope for room-deployed sensing, in scope for a hypothetical `cog-bedside`.
|
||||
3. **PWV with multiple Tx-Rx anchors AND a known anatomical model.** Requires per-installation calibration and ~6 anchors. Plausible but expensive — not a consumer feature.
|
||||
|
||||
These three niches *might* close some day. The general "BP from a $9 ESP32 in the corner" claim does not.
|
||||
|
||||
## Why this is a positive contribution
|
||||
|
||||
A research loop that only publishes successes biases toward overclaiming. The most honest thing this loop can do for the field is to **mark BP-from-CSI as off-roadmap with explicit numbers**, so future contributors don't waste cycles attempting it. This scrutiny + the R12 eigenshift scrutiny = the loop's two negative results, both worth more than another marginal positive.
|
||||
|
||||
## Honest scope (of the scrutiny itself)
|
||||
|
||||
- All four floor numbers are best-case. Real deployments worsen each by 2-5×.
|
||||
- The 25 dB contour-shape requirement is from PPG literature. WiFi CSI may need *more* dB because its noise model is different from optical sensors. So the 20 dB shortfall is a *floor* on the shortfall, not a tight estimate.
|
||||
- We didn't test the published BP claims directly (no labelled BP dataset in the repo). The scrutiny is purely physics-floor, not empirical replication.
|
||||
- If 802.11be EHT320 channels become widely available, the bandwidth budget improves but the spatial floor (Fresnel envelope) is set by carrier wavelength, not bandwidth — so the spatial problem doesn't go away.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R1** (ToA CRLB) — bandwidth-bound floor on temporal resolution; PTT inherits this. The 0.5 ms target is below the 20 MHz HT20 single-shot CRLB (~14 ns at infinite SNR, but >5 ms in practice). Confirms PTT-from-WiFi-bandwidth is bound by averaging window length.
|
||||
- **R6** (Fresnel forward model) — provides the spatial-resolution floor that defeats two-site PTT at typical room ranges. The cleanest "R6 explains why this doesn't work" example.
|
||||
- **R5** (saliency) — band-spread occupancy showed why the *whole* chest motion is observable across the band; isolating a 0.3 mm pulse signal from an 8 mm breathing signal requires temporal-band filtering, not spatial saliency.
|
||||
- **R12** (eigenshift, also negative) — the loop's other negative result. Same pattern: a plausible-sounding ML approach fails because the underlying signal doesn't dominate the noise/drift floor.
|
||||
- **R14** (empathic appliances) — confirms R14's design choice of breathing rate + HR rate only, no BP.
|
||||
@@ -0,0 +1,101 @@
|
||||
# R14 — Empathic appliances: physiological-state-aware home automation
|
||||
|
||||
**Status:** speculative 10-20y vision note · **2026-05-22**
|
||||
|
||||
## Premise
|
||||
|
||||
We already ship a contactless breathing-rate detector (`v1/v2` sensing-server, ADR-029 multistatic fusion). Breathing rate is a documented proxy for arousal/stress in clinical studies (e.g. Bernardi 2002, Vlemincx 2013) and predicts user states finer than HRV in low-SNR conditions. Heart rate is captured concurrently.
|
||||
|
||||
The 10-20 year question: **what happens when every appliance with a CPU and a WiFi radio knows the occupant's physiological baseline + current state, and modulates its behaviour to support the occupant's wellbeing?**
|
||||
|
||||
The current RuView stack provides the *sensing primitives* (breathing rate, heart rate, occupancy, motion intensity, RSSI-only counting per R8). What it doesn't yet provide is the *intent-action layer* — an appliance that says "the occupant has been breathing fast for 8 minutes; their normal baseline is 12 BPM; let me dim the lights and lower the music."
|
||||
|
||||
## Three concrete vertical sketches
|
||||
|
||||
### V1 — Stress-responsive lighting (next 5y, technically tractable)
|
||||
|
||||
| Sensing | Action |
|
||||
|---|---|
|
||||
| Breathing rate 50% above 7-day rolling baseline for >5 min | Lights gently warm-shift (Kelvin: 4000K → 2700K) and dim 10% over 60s |
|
||||
| Sustained low motion + low breathing variability (rest state) | Lights stay where they are |
|
||||
| Sleep onset detected (motion=null, breathing<10 BPM for >15 min) | Lights fade to 0 over 8 min following standard Philips Hue "wind down" curve |
|
||||
|
||||
The hard part is **not** the sensing — it's the **personalisation**: a 7-day rolling baseline takes a week of continuous occupancy data to calibrate, and per-person baselines vary by ~30%. Solution: federated per-room calibration that learns continuously, with explicit "this is not me" override.
|
||||
|
||||
### V2 — Adaptive HVAC for thermal-stress envelopes (10y)
|
||||
|
||||
Thermal stress affects breathing-rate envelope (>30°C → +20% baseline RR). A learned per-person mapping from `(room_temp, humidity, breathing_rate)` → "is the occupant uncomfortable?" lets HVAC pre-emptively adjust before the occupant consciously notices. Saves ~15-20% on cooling energy per published HVAC-personalisation studies (Aryal & Becerik-Gerber 2018), while improving comfort.
|
||||
|
||||
### V3 — Conversational appliances respecting attention state (15y)
|
||||
|
||||
A smart speaker that **doesn't interrupt** when the occupant's breathing pattern shows high cognitive load (focused reading: shallow + regular). The sensing already exists; the appliance integration is the gap.
|
||||
|
||||
Honest scope check: this requires that someone publishes both (a) a reliable shallow-breathing-during-focus signature, and (b) a hands-off way for appliances to receive that signal. RuView ships (a)'s building blocks; (b) needs an MCP-style standard which **ADR-104 (`@ruv/ruview-mcp`)** is the first step toward.
|
||||
|
||||
## Required infrastructure (already in repo or close)
|
||||
|
||||
| Component | Status | Used for |
|
||||
|---|---|---|
|
||||
| Breathing/heart rate detector | ✅ shipped | physiological state signal |
|
||||
| Occupancy presence | ✅ shipped (`cog-pose-estimation`, `cog-person-count`) | "is anyone there?" gate |
|
||||
| Motion intensity score | ✅ shipped | activity-state classifier input |
|
||||
| Per-room baseline learner | ⚠️ partial (RollingP95 in #491 is the closest existing primitive) | personalised normalisation |
|
||||
| State-classifier model | ❌ not built | maps `(breathing, heart, motion)` → state |
|
||||
| MCP appliance API | ✅ partial (ADR-104) | hands-off appliance integration |
|
||||
| Consent/opt-in machinery | ❌ not built | ethical baseline |
|
||||
| Override/correction UI | ❌ not built | user-in-the-loop |
|
||||
|
||||
The four ❌/⚠️ items are the actual work for V1 ship-readiness. Roughly 1-2 quarters of dedicated effort, not a research project.
|
||||
|
||||
## Ethical framework (drafted, not normative)
|
||||
|
||||
Empathic appliances raise three explicit consent questions that smart-speaker-vendors so far have *not* answered well. Any RuView-based empathic-appliance product should commit to all of these in writing:
|
||||
|
||||
1. **Opt-in by default.** Sensing is on only if the occupant has actively enabled it. Default = off, not buried in settings.
|
||||
2. **Data stays on-device.** The breathing-rate stream is the most invasive biometric in the building. Per-second values **must never** leave the local appliance/Cognitum Seed. Only **aggregate state** (e.g. "stressed" / "neutral" / "asleep") may be exposed to integrations, and only via the user's explicit MCP grant.
|
||||
3. **Override is one tap.** A physical "stop sensing now" gesture or button must work without WiFi, without speech, without the cloud. If consent withdraws, sensing pauses for ≥1 hour before re-asking.
|
||||
|
||||
These three constraints are surprisingly load-bearing — they rule out the most common smart-home failure modes (always-on listening, cloud-side aggregation, opaque consent flows).
|
||||
|
||||
## Privacy threat model
|
||||
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Compromised appliance leaks breathing rate continuously | Per-device sensing is opt-in; appliances default off |
|
||||
| MCP API exposes raw signal to integrations | Only aggregate state passes the MCP boundary; raw stays local (ADR-104 §"Output validation") |
|
||||
| Adversarial CSI poisoning makes the occupant look stressed/calm against their interest | R7 Stoer-Wagner multi-link consistency detects this |
|
||||
| Long-term baseline learning enables individual identification across moves | Baseline is per-installation; no cloud sync; user can wipe at any time |
|
||||
| Insurance / employer access to physiological state | Legal/contractual barrier; not solvable purely technically. Surface this explicitly in onboarding |
|
||||
| Children / non-consenting cohabitants | Per-occupant opt-in, not per-installation. Use existing pose-based identity primitives (R3/R9/R15) to gate per-person |
|
||||
|
||||
## Honest scope
|
||||
|
||||
- The clinical literature on breathing-rate-as-stress-proxy is mostly **lab-condition adults**. Real-home generalisation isn't proven.
|
||||
- We have no per-occupant identity model yet — single-occupant scenarios only until R3/R15 mature.
|
||||
- The "appliance integration" half is mostly out of repo scope; it requires partner appliances that accept ADR-104-style MCP signals.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- A clear product roadmap from the **existing sensing primitives** to a **shippable category of appliance behavior** that doesn't exist in the market today.
|
||||
- A worked ethical framework that's specific enough to commit to in marketing copy.
|
||||
- A mapping of which existing repo components map to which appliance category (V1/V2/V3).
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- Stress detection without breathing-rate signal. Pure CSI motion isn't a reliable stress proxy.
|
||||
- Detection of psychological states that aren't reflected in breathing/heart rate (cognitive fatigue, mood). Those need physiological signals we can't measure passively.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — empathic appliance state classification will have its own task-specific saliency, different from counting and structure-detection.
|
||||
- **R8** (RSSI-only) — V1 lighting only needs breathing rate, which requires CSI. V3 conversational requires the per-subcarrier shape lost in band-mean. **R14 is CSI-only**, not RSSI-feasible — bounds the rollout to ESP32-S3-class deployments.
|
||||
- **R7** (multi-link consistency) — directly relevant to the adversarial-poisoning threat in the privacy table.
|
||||
- **ADR-104** (`@ruv/ruview-mcp`) — the actual hands-off appliance API. Empathic-appliance integrations subscribe via MCP `ruview_vitals_subscribe` (not yet built; see HORIZON.md deferred list).
|
||||
- **ADR-103** (`cog-person-count`) — the per-room occupancy gate ("only do empathic actions when an occupant is present and consented").
|
||||
|
||||
## Next ticks
|
||||
|
||||
- Per-room baseline learner module (extend `RollingP95` to cover breathing-rate + heart-rate over 7-day windows).
|
||||
- State-classifier model architecture (3-class: stressed / neutral / asleep — simple MLP over breathing/heart/motion features).
|
||||
- MCP tool `ruview_vitals_subscribe` — the hands-off integration that lets a partner appliance subscribe to the aggregate state stream.
|
||||
- ADR for the consent-default-off, override-one-tap, no-cloud-sync constraints. Possibly ADR-105.
|
||||
@@ -0,0 +1,164 @@
|
||||
# R15 — RF biometric primitives: what's environment-invariant in the CSI signature
|
||||
|
||||
**Status:** synthesis + privacy framing · **2026-05-22**
|
||||
|
||||
## The question
|
||||
|
||||
R3 asked "can we re-identify the same person across two rooms?" and answered yes, **conditional on MERIDIAN env-subtraction**. R15 asks the deeper question: **what features in the CSI signal are environment-invariant by construction** — properties of the person's physiology that exist independent of multipath geometry?
|
||||
|
||||
If R3 is "the same vector appears in two embedding spaces", R15 is "what physical attribute of the body actually drives that vector". Without R15, R3 is statistical pattern-matching with no theory of why it works.
|
||||
|
||||
This thread catalogues five biometric primitives that survive cross-environment transfer, ranks them by invariance + discriminability + measurement difficulty, and frames the privacy implications.
|
||||
|
||||
## Five biometric primitives
|
||||
|
||||
### 1. Gait stride frequency
|
||||
|
||||
**Physical basis:** stride frequency is determined by leg length, mass distribution, gait pattern (asymmetry coefficient). Per-individual reproducibility is ~3-5% within a year (Murray 1964); across years it drifts with fitness/age. **Invariant to environment.**
|
||||
|
||||
**Discriminability:** ~5-7 bits per person (Begg 2006, gait literature consensus). Enough to separate ~30-100 individuals before false-match probability exceeds 1%.
|
||||
|
||||
**Measurement difficulty:** R10's gait-band DSP (0.5-15 Hz) already extracts this. Stride frequency robust to multipath; stride asymmetry needs higher SNR (gait phase shape, not just rate).
|
||||
|
||||
**Cross-room invariance:** **HIGH.** The carrier of the gait signature is the Doppler shift induced by leg motion; the magnitude depends on environment (Fresnel envelope, R6) but the *frequency* doesn't.
|
||||
|
||||
### 2. Breathing rate baseline + envelope
|
||||
|
||||
**Physical basis:** resting respiration rate is a person-specific physiological setpoint (12-20 BPM normal range, individual ±2 BPM). The tidal-volume envelope (chest expansion amplitude) scales with lung capacity, which scales with body size and age. **Invariant to environment** at the rate level.
|
||||
|
||||
**Discriminability:** ~3-4 bits at the rate level alone. Combined with envelope amplitude it could reach 5-6 bits. The combined signal also has phase information (inhale/exhale ratio, breathing irregularity) that adds another 1-2 bits.
|
||||
|
||||
**Measurement difficulty:** `vital_signs` pipeline already extracts breathing rate. Envelope amplitude is noisier; needs ~10× more averaging.
|
||||
|
||||
**Cross-room invariance:** **HIGH.** Same reasoning as gait — temporal frequency is invariant, only amplitude is environment-dependent.
|
||||
|
||||
### 3. Heart rate variability (HRV) signature
|
||||
|
||||
**Physical basis:** HRV is a person-specific autonomic-nervous-system signature. Resting HRV varies ±15-30 ms between individuals; under stress it changes predictably per person.
|
||||
|
||||
**Discriminability:** ~4-5 bits per person (Hjortskov 2004, HRV literature). The full HRV time-series adds another 2-3 bits over the summary statistics.
|
||||
|
||||
**Measurement difficulty:** R13's NEGATIVE physics scrutiny showed that *waveform-shape* HR recovery from CSI is **5 dB short** of the floor. **Rate-level HRV** (R-R interval variability) is achievable; *contour-shape* HRV (which gives the autonomic signature) is not.
|
||||
|
||||
**Cross-room invariance:** **HIGH at rate level, LOW at contour level.** The achievable subset is rate-level HRV, which is real but lower discriminability than published claims that assume contour recovery.
|
||||
|
||||
### 4. Body-size RCS envelope
|
||||
|
||||
**Physical basis:** the radar cross-section (RCS) of a stationary human at WiFi frequencies is roughly proportional to body surface area (~0.6 m² for adult, ~0.2 m² for small child). The frequency-dependent RCS shape encodes body size + body composition (fat/muscle/water ratios affect dielectric properties).
|
||||
|
||||
**Discriminability:** ~3-5 bits per person. Lower than gait or HRV because it's gross-body-only.
|
||||
|
||||
**Measurement difficulty:** Needs calibration against a known reference target in the same environment. Cross-room calibration is a research problem.
|
||||
|
||||
**Cross-room invariance:** **MEDIUM.** Absolute RCS depends on environment (Fresnel envelope, R6); but the *ratio* of RCS at different subcarrier frequencies (the frequency response of the body) is environment-invariant by R6's forward model.
|
||||
|
||||
### 5. Walking dynamics (limb timing)
|
||||
|
||||
**Physical basis:** per-individual stride length, step-time asymmetry, hip-sway pattern. These are determined by skeletal proportions + neuromuscular control. **Highly invariant** to environment.
|
||||
|
||||
**Discriminability:** **6-9 bits per person** when full dynamics are recovered (Cunado 2003, biometric-gait literature). Among the highest-discriminability biometrics short of fingerprint.
|
||||
|
||||
**Measurement difficulty:** Requires recovering the *pose* (limb positions) from CSI, not just the gait *rate*. The full pose-from-CSI pipeline (ADR-079, ADR-101) gets within ~92.9% PCK@20 — good enough to extract limb timing in clean conditions.
|
||||
|
||||
**Cross-room invariance:** **HIGH** when pose is recovered correctly. The pose extractor itself uses MERIDIAN (R3) for cross-room transfer; if the pose pipeline works cross-room, so does the gait dynamics biometric.
|
||||
|
||||
## Composite biometric strength
|
||||
|
||||
Combining all five (assuming statistical independence, which is **not** true — gait correlates with body size, HRV correlates with age, etc. — so this is a soft upper bound):
|
||||
|
||||
| Primitive | Bits (cross-room achievable) |
|
||||
|---|---:|
|
||||
| Gait stride frequency | 5 |
|
||||
| Breathing rate + envelope | 5 |
|
||||
| HRV (rate-level only) | 4 |
|
||||
| Body-size RCS frequency response | 4 |
|
||||
| Walking dynamics (limb timing) | 7 |
|
||||
| **Composite (statistically independent upper bound)** | **25 bits** |
|
||||
| **Composite (realistic correlation correction)** | **~12-15 bits** |
|
||||
|
||||
12-15 bits of biometric is enough to uniquely identify a person within a population of ~4k-30k. For a household of 4 people, that's overwhelming discrimination. For a building of 1000 people, easily sufficient. For city-scale surveillance, it would need to combine with other modalities — but the primitive is already there.
|
||||
|
||||
## Privacy implications
|
||||
|
||||
This is the part R14 + R3 hinted at but didn't fully spell out:
|
||||
|
||||
**RF biometric is harder to remove than visual biometric.** A face can be obscured with a mask. A fingerprint can be left at home. A gait + breathing + RCS signature is **emitted continuously**, **without subject awareness**, **through walls**.
|
||||
|
||||
Specifically:
|
||||
|
||||
1. **No opt-out via behaviour.** Removing a face requires covering it. Removing a gait requires not walking. There is no behavioural countermeasure that doesn't impair the user.
|
||||
2. **No removable artefact.** Visual ID can be defeated with sunglasses + mask. RF ID requires actual physical change (different body shape — impossible) or jamming (illegal, plus jams everything around).
|
||||
3. **Cross-installation linkage is a transit-tracking primitive.** R3 already constrained per-installation embedding spaces; R15 says the constraint is **doubly important** because the biometric is intrinsically physical, not learned.
|
||||
|
||||
These constraints take the R3 + ADR-105 framework and push it harder:
|
||||
|
||||
| R3 / ADR-105 constraint | R15-strengthened version |
|
||||
|---|---|
|
||||
| No cross-installation linkage | **Hardware-isolated embedding spaces, cryptographically prove they're isolated** |
|
||||
| Embedding storage requires opt-in | **Storage of any RF-biometric-derivable signature requires opt-in, not just the final embedding** |
|
||||
| Cryptographically verifiable forgetting | **Forget the raw extracted biometric primitives (gait freq, breath rate, RCS curve) — not just the model output** |
|
||||
| No re-ID across legal entities | **No sharing of any RF biometric primitive across legal entities, including aggregate / derived versions** |
|
||||
|
||||
## Architectural implications
|
||||
|
||||
**The federation protocol (ADR-105) needs an additional constraint:**
|
||||
|
||||
> The federation aggregator MUST NOT receive any raw per-subject biometric primitive (gait frequency, breath rate, RCS curve, limb timing). It MAY receive *aggregated, MERIDIAN-normalised* embedding deltas. Per-subject primitives stay on-device.
|
||||
|
||||
This is **stronger** than ADR-105's existing "data stays on-device" because MERIDIAN deltas are not "data" in the conventional sense — they're learned model parameters. But the learned parameters *encode* biometric features. R15 says: encode them as you must, but the **measurement** of the underlying biometric must never leave the device.
|
||||
|
||||
**Concretely:** the Cognitum Seed runs `extract_gait_freq(csi_window)` locally, produces a 5-bit signature, uses it in inference, **does not** send the signature to the coordinator. The coordinator sees only the model delta that influenced inference outcomes.
|
||||
|
||||
This adds a constraint to the ADR-105 implementation. ADR-106 (next ADR after the deferred DP-SGD) should formalise the on-device-only primitive list.
|
||||
|
||||
## What R15 enables (positively framed)
|
||||
|
||||
1. **Per-installation natural identification.** A household of 4 with known members + no setup gives perfect within-installation re-ID using the 25-bit biometric. The same primitive lets a hospital ICU know which patient is in which bed.
|
||||
2. **Health monitoring at biometric resolution.** Long-term tracking of gait stride asymmetry detects early gait pathology (Parkinson's, stroke recovery). Breath-rate baseline drift detects respiratory decline. These are **medically actionable** signals that the existing rate-extraction pipelines almost ship.
|
||||
3. **Pose-data-association robust across occlusion.** The 7-bit limb-timing biometric resolves identity through brief visual occlusion or sensor blind-spots.
|
||||
|
||||
## What R15 makes worse (negatively framed)
|
||||
|
||||
1. **Cross-installation tracking is harder to prevent than visual cross-camera tracking** because the biometric is intrinsically physical.
|
||||
2. **The data-rights legal framework** doesn't yet treat "intrinsic biometric leaked passively through walls" as a category. GDPR Art 9 covers "biometric data for unique identification" but the consent flow assumes the user knows they're being measured (e.g. fingerprint scanner). RF biometric extraction can happen without subject awareness.
|
||||
3. **The federation threat surface** is larger than ADR-105 anticipated. ADR-106 will need to formalise the on-device-only primitive list.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- **A complete biometric primitive inventory** with explicit invariance and discriminability per primitive — lets the team make informed trade-offs.
|
||||
- **A stronger version of the R3 + R14 privacy framework** that accounts for the physical (not learned) nature of these biometrics.
|
||||
- **A clear next ADR**: ADR-106 (already mentioned in ADR-105's deferred list) gets a sharper requirements section: on-device-only primitive measurement, not just on-device-only training data.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- **Cross-installation re-ID** — explicitly prohibited and prevented by hardware-isolated embedding spaces.
|
||||
- **Adversarial-resistance to a building-level attacker** with control over multiple Cognitum Seeds — that requires a different defence layer (R7 mincut multi-link extends to multi-installation only with crypto, see ADR-105's deferred cross-installation work).
|
||||
- **Forensic post-hoc identification** — even within an installation, the 12-15 bit biometric resolution is too low for forensic use (would require ~30+ bits, which CSI alone cannot provide).
|
||||
|
||||
## Honest scope
|
||||
|
||||
- The bit counts are upper bounds. Real-world deployments lose 30-50% to noise + multipath + sensor variance. Realistic composite biometric strength is closer to **6-10 bits**, useful for household-scale ID but not for global identification.
|
||||
- The "5 dB short" finding from R13 means the *contour-level* HRV biometric is **not achievable** on a typical ESP32 deployment. Rate-level HRV (the 4-bit subset of #3) is the realistic upper bound.
|
||||
- The walking dynamics number (7 bits) depends on the pose-from-CSI pipeline achieving its ADR-079 92.9% PCK target in cross-room conditions. Current numbers are within-room; cross-room degradation is unmeasured.
|
||||
- Body-size RCS frequency response (#4) needs a calibration target in the new room. Without it, the cross-room invariance is the *ratio* not the absolute value — and ratios across 56 subcarriers give ~3-4 bits, not 5.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5 (saliency)** — saliency maps for biometric extraction are task-specific; gait-saliency, breath-saliency, RCS-saliency are different. The band-spread observation from R5 supports gait + breath extraction; high-precision RCS recovery may need a tighter sub-band.
|
||||
- **R6 (Fresnel forward model)** — gives the physics of *why* RCS frequency-response is environment-invariant (the per-subcarrier amplitude scales with body geometry, not with the environment, after env subtraction).
|
||||
- **R7 (mincut adversarial)** — biometric primitives can be poisoned by crafted CSI on a single link; multi-link consistency catches this.
|
||||
- **R10 (foliage / per-species gait)** — gait stride-frequency taxonomy from R10 transfers directly to per-individual gait biometric (different physiologic source, same DSP).
|
||||
- **R13 (contactless BP, NEGATIVE)** — the same physics argument that ruled out contactless BP also rules out contour-level HRV recovery. Both fail at the "5 dB short" wall.
|
||||
- **R3 (cross-room re-ID)** — provides the embedding-space machinery that combines the 5 primitives into a unified per-subject signature.
|
||||
- **R14 (empathic appliances)** — V1 lighting needs only breathing rate (already shipped); V2 HVAC needs breath rate + body-size RCS; V3 attention state needs breath envelope + maybe HRV rate. R15 says all of these are achievable with the rate-level subset, no contour recovery needed.
|
||||
- **ADR-105 (federated training)** — needs ADR-106 to formalise on-device-only primitive measurement.
|
||||
|
||||
## What R15 closes / what it opens
|
||||
|
||||
This is the loop's **final research thread** before the deferred follow-up items begin. After R15:
|
||||
|
||||
**Closed:** the question "what RF biometrics exist and how do they invariantise" has a worked answer.
|
||||
|
||||
**Open:** ADR-106 (on-device DP-SGD + primitive isolation), R6.1 (multi-scatterer extension), R3 follow-up (physics-informed env_sig prediction), R6.2 (Fresnel-aware antenna placement).
|
||||
|
||||
Together with the 12 prior threads, R15 makes the per-occupant feature surface (R14 V1/V2/V3) **fully grounded in physics and constraints**, with no remaining unspecified primitives. The remaining work is implementation + measurement, not research.
|
||||
@@ -0,0 +1,108 @@
|
||||
# R3 — Cross-room CSI re-identification: AETHER + MERIDIAN synthesis
|
||||
|
||||
**Status:** simulation + ADR-024/027 synthesis + privacy framing · **2026-05-22**
|
||||
|
||||
## The question
|
||||
|
||||
AETHER (ADR-024) gives us contrastive CSI embeddings that achieve **~95% within-room 1-shot re-identification** on MM-Fi. Can the same embeddings identify the same person across a different room?
|
||||
|
||||
This question has two answers — a technical one and an ethical one. R3 takes both seriously.
|
||||
|
||||
## Decomposition
|
||||
|
||||
A CSI embedding from any frame is approximately:
|
||||
|
||||
```
|
||||
embedding = person_signature + environment_signature + noise
|
||||
```
|
||||
|
||||
The environment signature includes multipath geometry, AP placement, furniture, walls. It is **constant per (room, antenna placement)**, and **changes by O(1)** between rooms — empirically larger than the per-person signature variation. This is exactly the structure that ADR-027 (MERIDIAN) targets.
|
||||
|
||||
`examples/research-sota/r3_crossroom_reid.py` simulates the problem with physics-realistic parameters: 10 subjects, 3 rooms, 128-dim embeddings, person-signature scale 0.35, environment scale 1.5 (env ≈ 4.7× person), noise 0.3.
|
||||
|
||||
## Results
|
||||
|
||||
| Configuration | 1-shot accuracy | Δ from baseline |
|
||||
|---|---:|---|
|
||||
| Within-room baseline | 100.0% | (matches AETHER ~95% target) |
|
||||
| Cross-room, **raw cosine** K-NN | **70.0%** | -30 pp |
|
||||
| Cross-room, MERIDIAN 100% env subtraction | 100.0% | recovered |
|
||||
| Cross-room, MERIDIAN 70% env subtraction (realistic) | 100.0% | recovered |
|
||||
| Chance | 10.0% | floor |
|
||||
|
||||
Three observations:
|
||||
|
||||
1. **Cosine K-NN partially mitigates** the environment-shift problem (70% >> 10% chance) because magnitude normalisation removes the additive env component as a *direction*. The remaining 30 pp gap comes from how the env shift rotates the cluster in the high-dim space.
|
||||
2. **Explicit MERIDIAN-style env subtraction** (per-room centroid removal) closes the remaining gap. The simulation suggests even **70%-effective** subtraction (realistic for finite labelled examples) is enough.
|
||||
3. **The within-room baseline is what an attacker has**, not what the system needs. The same primitive that gives the user "let RuView greet you by name in this room" also gives an attacker "this person walked through 5 different rooms and we tracked them."
|
||||
|
||||
## Why the env-removal approach works
|
||||
|
||||
MERIDIAN's core idea (ADR-027) is to estimate `environment_signature` from labelled samples *in the new room* and subtract it. The estimator works because:
|
||||
|
||||
- All people contribute equally to the per-room mean (assuming reasonably balanced training data)
|
||||
- The person signatures are zero-mean across the population (an embedding is meaningful only relative to others)
|
||||
- Therefore `mean(embeddings in room R) ≈ environment_signature[R]`
|
||||
|
||||
Subtracting the per-room centroid gives `embedding_clean ≈ person_signature + noise`, which is the room-invariant signature.
|
||||
|
||||
**Trade-off:** MERIDIAN needs labelled (or at least clustered) examples *in the new room* to estimate its centroid. Pure zero-shot transfer to an unobserved room is much harder — without any anchor, you can't distinguish "person A in new room" from "person B in old room" robustly.
|
||||
|
||||
## Physics gives us another lever
|
||||
|
||||
R6's Fresnel forward model tells us where the env_sig **lives** in the embedding: it's the contribution from the multipath / reflector geometry. A 5 m bedroom has 4-6 dominant reflector positions; the env_sig is a function of those.
|
||||
|
||||
If we could **predict** the env_sig from the forward model + a room geometry (R6's A matrix + a coarse map of the room), we wouldn't need labelled examples. This is the next-tier sophistication: **physics-informed domain invariance** rather than statistically estimated.
|
||||
|
||||
This isn't built. It's the right next step in the AETHER + MERIDIAN line.
|
||||
|
||||
## Privacy framing (the ethical answer)
|
||||
|
||||
The same primitive that enables "RuView greets you by name in your bedroom" enables a building-level adversary to **track every individual's movement through every WiFi-CSI-sensing surface**. This is a stronger surveillance primitive than face recognition because:
|
||||
|
||||
- WiFi penetrates walls (no line-of-sight needed)
|
||||
- Re-ID works without subject cooperation (no "look at the camera")
|
||||
- The signal is invisible (no light, no observable signal)
|
||||
- The biometric is the body's RF signature, not a removable accessory
|
||||
|
||||
The R14 ethical framework (opt-in by default, data stays on-device, override is one tap) applies, but with **additional** constraints specific to re-ID:
|
||||
|
||||
1. **No cross-installation linkage.** Per-installation embedding spaces only. Two RuView installs in two different buildings must NOT share embedding spaces.
|
||||
2. **Embedding storage requires explicit opt-in.** Storing person embeddings persists biometrics; many regulatory regimes treat this as biometric data with stronger consent requirements (GDPR Art 9, BIPA).
|
||||
3. **Forgetting must be cryptographically verifiable.** When a user requests deletion, the embedding must be cryptographically destroyed, not just unlabelled. Storing "unlabelled embeddings" still enables future linkage.
|
||||
4. **No re-ID across legal entities.** Building A and Building B owned by different entities must NOT exchange embeddings. The data-flow boundaries should be hard-walled.
|
||||
|
||||
These constraints make some use cases impossible (e.g. "automatic global biometric ID" — yes, that's the point) and some clearly aligned with the user (e.g. "remember which family member is in which room").
|
||||
|
||||
## What this enables
|
||||
|
||||
1. **Per-installation personalisation** — empathic appliances (R14) get per-person calibration after MERIDIAN-style env subtraction.
|
||||
2. **Anomaly detection** — "someone walked into this room who isn't in the household's embedding set" → home-security primitive without face recognition.
|
||||
3. **Pose-data-association** — multi-person pose tracking in the same room can use the embedding to maintain consistent identity through occlusion.
|
||||
|
||||
## What this DOES NOT enable (correctly, by design)
|
||||
|
||||
1. Cross-building tracking
|
||||
2. Re-ID across legal entities
|
||||
3. Long-term unlabelled biometric storage
|
||||
4. Zero-shot transfer to unobserved rooms (without physics-informed extension)
|
||||
|
||||
## Honest scope
|
||||
|
||||
- The simulation uses additive `person + env + noise` decomposition. Real CSI has **multiplicative** environment effects in the multipath domain — env modulates person signature amplitude in subcarrier-specific ways. A more realistic forward model would multiply the per-subcarrier slot transfer function with the person signature, which makes env-removal harder (not just subtraction).
|
||||
- The 70% cross-room raw cosine K-NN number depends heavily on env / person scale ratio. With a 10× larger env (e.g. crossing from a bedroom to a kitchen with very different multipath), the raw cosine K-NN drops further. With a 2× smaller env (very similar rooms), it barely drops. The MERIDIAN closing of the gap appears robust.
|
||||
- We did **not** simulate adversarial scenarios where an attacker actively manipulates the env signal to break tracking. R7's mincut would have to weigh in on this.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — within-room saliency profiles include both the person- and environment-saliency. Cross-room transfer would need to find the *person-only* saliency, which is a research problem AETHER (ADR-024) partially addresses through contrastive learning.
|
||||
- **R6** (Fresnel) — the missing piece: physics-informed env_sig prediction from a room model. Not yet built.
|
||||
- **R7** (mincut adversarial) — cross-room re-ID is the highest-risk surface for adversarial spoofing. If the system can be fooled into thinking "person B is in room A", that's a security incident; multi-link consistency from R7 is the defence.
|
||||
- **R9** (RSSI K-NN) — already showed that even RSSI alone preserves a weak locality signature within room; the cross-room transfer for RSSI is *worse* than for full CSI, but the env / person decomposition still applies.
|
||||
- **R14** (empathic appliances) — re-ID enables per-occupant V1 lighting / V2 HVAC / V3 attention-respecting. The privacy constraints from R14 + the four cross-installation constraints from R3 together are the binding spec.
|
||||
|
||||
## Next ticks (R3 follow-ups)
|
||||
|
||||
- Physics-informed env_sig prediction from R6's forward operator + a coarse room map → zero-shot cross-room transfer.
|
||||
- Multi-occupant re-ID under occlusion: two people in the same room, intermittent visibility of each; can a Kalman + AETHER pipeline maintain identity continuously?
|
||||
- Cryptographic forgetting protocol: how do you prove an embedding has been deleted to a regulator who can't see your hard drive? (Out of scope for this loop, but a real research question.)
|
||||
@@ -0,0 +1,125 @@
|
||||
# R6 — Fresnel-zone forward model: making CSI sensitivity predictable
|
||||
|
||||
**Status:** working forward model + numpy demo · **2026-05-22**
|
||||
|
||||
## The gap this fills
|
||||
|
||||
The entire `wifi-densepose-signal` DSP pipeline — `vital_signs`, `multistatic`, `pose_tracker` — operates on CSI windows whose **physical meaning** is taken for granted. We measure complex per-subcarrier amplitudes, treat them as input features, and learn classifiers. Nobody in the repo has written down the **forward model**: given a known scatterer position + size + reflectivity, what does the CSI look like?
|
||||
|
||||
Without a forward model:
|
||||
|
||||
- **R12** (eigenshift) was forced to invent its own subspace basis from data — and discovered it was indistinguishable from natural drift.
|
||||
- **R7** (multi-link consistency) had to bootstrap an adversarial detector from scratch instead of comparing against a physics-grounded expectation.
|
||||
- **R10** (foliage range) had to use ITU-R + FSPL alone, ignoring the fact that an obstacle larger than the **first Fresnel zone** causes diffraction loss that no FSPL model captures.
|
||||
|
||||
This tick makes the forward model explicit. Self-contained numpy; no dependencies on the workspace.
|
||||
|
||||
## The model
|
||||
|
||||
For a Tx-Rx link of length `L`, the **first Fresnel zone** is the prolate ellipsoid where most of the diffracted RF energy travels. Its radius at fractional position `p ∈ [0, 1]` along the LOS is:
|
||||
|
||||
```
|
||||
r_1(p) = sqrt(λ · L · p · (1 − p)) [metres]
|
||||
```
|
||||
|
||||
A **point scatterer** at perpendicular offset `x` from the LOS, at link position `d_1` from Tx (so `d_2 = L − d_1` from Rx), introduces a path-length delta:
|
||||
|
||||
```
|
||||
Δℓ(x) = sqrt(d_1² + x²) + sqrt(d_2² + x²) − (d_1 + d_2)
|
||||
```
|
||||
|
||||
Phase shift on subcarrier `k` with centre frequency `f_k`:
|
||||
|
||||
```
|
||||
φ_k = 2π · f_k · Δℓ / c
|
||||
```
|
||||
|
||||
That's it. Six lines that the entire workspace's DSP secretly assumes.
|
||||
|
||||
## What the demo computes
|
||||
|
||||
`examples/research-sota/r6_fresnel_zone.py` runs four canonical scenarios and emits per-subcarrier phase predictions for 802.11n/ac 20 MHz channels (52 used subcarriers, 312.5 kHz spacing):
|
||||
|
||||
### First Fresnel radii (the basic envelope)
|
||||
|
||||
| Link length | 2.4 GHz @ midpoint | 5 GHz @ midpoint |
|
||||
|---|---:|---:|
|
||||
| 2 m | 25.0 cm | 17.3 cm |
|
||||
| 5 m | **39.5 cm** | 27.4 cm |
|
||||
| 10 m | 55.9 cm | 38.7 cm |
|
||||
|
||||
These are **measurable, physical envelopes**: a 5 m WiFi link in a typical bedroom has a roughly 40 cm wide "channel of maximum sensitivity" centered on the LOS, narrowing toward each antenna. A human standing inside that ellipsoid moves the entire CSI vector; a human standing outside it perturbs only edge subcarriers.
|
||||
|
||||
### Single-scatterer predictions
|
||||
|
||||
| Scenario | Offset | Position | Zone @ 2.4 GHz | Phase spread |
|
||||
|---|---:|---:|:---|---:|
|
||||
| Human standing at midpoint | 10 cm | 2.5 m | zone-1 | 0.077° |
|
||||
| Human walking into Fresnel | 25 cm | 2.5 m | zone-1 | 0.477° |
|
||||
| Scatterer outside Fresnel | 1.5 m | 2.5 m | far-field | 15.9° |
|
||||
| Scatterer near Tx | 5 cm | 0.5 m | zone-1 | 0.053° |
|
||||
|
||||
**Key insight (concrete now):** the phase spread across subcarriers grows monotonically with `Δℓ`, which grows quadratically with offset `x`. A scatterer in the **far field** (15.9° spread across 52 subcarriers) is the regime where multi-tap channel estimation works well. A scatterer **inside the first Fresnel zone** (<0.5° spread) is essentially uniform across subcarriers — which is why R5's saliency revealed band-spread top subcarriers (the scatterer effectively excites the whole band) rather than tight clusters.
|
||||
|
||||
This unifies R5 and R6: the saliency band-spread we measured experimentally is exactly what the Fresnel forward model predicts for inside-zone-1 occupancy.
|
||||
|
||||
## Why this matters for the workspace
|
||||
|
||||
| Existing module | What R6 gives it |
|
||||
|---|---|
|
||||
| `vital_signs` (breathing/HR) | Predicts that chest-wall motion at ~1 cm amplitude inside zone-1 produces 0.01–0.05° phase change per breath — sets the floor SNR for HR detection |
|
||||
| `multistatic.rs` (attention-weighted fusion) | Provides ground-truth weights: scatterers in different Fresnel zones contribute different per-subcarrier phase signatures, so the attention weights have a closed-form prior |
|
||||
| `tomography.rs` (RF tomography) | Forward operator A in `Ax = y` was a black box; R6 makes A explicit (per-voxel position → per-subcarrier phase contribution) so the L1-ISTA inverse problem becomes properly conditioned |
|
||||
| `pose_tracker.rs` (17-keypoint Kalman) | The "sensitivity to limb position" prior is now derivable from the Fresnel geometry — distal limbs (hands, feet) often sit *outside* the first Fresnel zone for indoor links, explaining why they're harder to track than torso/head |
|
||||
|
||||
## Connection to R12
|
||||
|
||||
R12 (eigenshift) failed because the SVD spectrum is a 1-D summary that loses the spatial structure the Fresnel forward model preserves. The right revision is:
|
||||
|
||||
```
|
||||
y_predicted = sum_voxels A(voxel) · reflectivity(voxel)
|
||||
residual = y_observed − y_predicted
|
||||
PABS = norm(residual) # the structure-detection signal
|
||||
```
|
||||
|
||||
where `A(voxel)` is exactly the per-subcarrier phase prediction from R6. This is essentially RF tomography, but used as a **structure-detection prior** rather than as inverse reconstruction. **PABS-over-Fresnel-grounded-basis** is the right next step that R12 explicitly identified — R6 supplies the basis.
|
||||
|
||||
## Connection to R10 (the wildlife angle)
|
||||
|
||||
R10's range estimates used FSPL + ITU foliage attenuation. But foliage **also blocks the first Fresnel zone**, and an obstacle filling >60% of the zone produces diffraction loss that FSPL alone misses. For the 2.4 GHz / 100 m sparse case, the first Fresnel zone at midpoint is `sqrt(0.125 · 100 · 0.5 · 0.5) = 1.77 m` wide — large enough that a tree trunk in the middle of the link cuts deeply into it.
|
||||
|
||||
A more honest sparse-foliage range, accounting for partial zone obstruction: probably **closer to 70 m than 100 m** for canopies with ~1.5 m vertical clearance. Documented here as a known under-estimate of the range we should retract toward in any field deployment.
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **Point scatterer.** Real bodies are distributed scatterers (limbs, chest, head — all at different positions in the zone). The full forward model is a volume integral over body-mounted RCS, not the scalar `Δℓ` here. The scalar version is the correct first-order approximation.
|
||||
- **First Fresnel only.** Real diffraction includes contributions from zones 2..N (the Cornu spiral). For obstacle classification (presence/absence/size) zone-1 dominates and the model is enough. For phase-precise reconstruction (millimeter-wave-style imaging) we'd need to sum over more zones.
|
||||
- **Frequency-flat scatterers.** We assume the scatterer's reflectivity is constant across the 20 MHz channel. Real biological tissue has frequency-dependent permittivity; the error is small at WiFi bands but non-zero.
|
||||
- **LOS-only.** Multipath (floor / ceiling / wall reflections) is not modeled. In a real bedroom there are typically 4-6 dominant reflectors, each contributing its own Δℓ. The full multipath model is just a sum of single-scatterer terms with their own A matrices — additive in the forward direction, harder to invert.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
- **Closed-form sensitivity bounds.** For any specified `(link length, frequency, scatterer position+size)` we can predict the per-subcarrier signature analytically. Removes mystery from "why does this signal look like this?"
|
||||
- **R12 revision path with a basis.** PABS computed against a Fresnel-grounded forward operator is the right structure-detection signal.
|
||||
- **Antenna-placement heuristics.** For a given room, R6 immediately predicts where the Fresnel envelope sits and which sensor positions maximise coverage. The current installation-guide is "guess and measure"; R6 enables "compute and validate."
|
||||
- **R10 range correction.** Foliage range estimates should be discounted for partial Fresnel-zone obstruction. ~30% conservative correction in the sparse case.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- **Without antenna calibration**, the absolute phase predictions are off by a constant per-subcarrier offset (the LO phase, per-antenna delay, etc.). The relative predictions (phase **spread** across subcarriers; phase **change** between consecutive windows) survive. The existing `phase_align.rs` handles the calibration step.
|
||||
- **Multipath-rich environments** need the multi-scatterer extension before R6 is quantitatively useful.
|
||||
|
||||
## Next ticks (R6 follow-ups)
|
||||
|
||||
- **PABS over Fresnel basis:** implement R12's revision — observed CSI minus forward-model prediction, structure detection on the residual. Should improve R12's 0.69× signal/drift ratio.
|
||||
- **R6.1 — multi-scatterer additive forward model:** sum over a coarse voxel grid, see whether breathing-rate estimation accuracy improves vs the current `vital_signs` heuristic.
|
||||
- **R6.2 — Fresnel-aware antenna placement:** given a room geometry + target occupancy zones, solve for the antenna positions that maximise Fresnel-envelope coverage. Could ship as a CLI tool in `wifi-densepose-cli`.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — band-spread top subcarriers are exactly what zone-1 occupancy predicts. R5 measured it; R6 explains it.
|
||||
- **R7** (mincut adversarial) — physically inconsistent CSI is now well-defined: residual from R6's forward model exceeds noise floor across all links simultaneously. Stoer-Wagner mincut detects the violation.
|
||||
- **R10** (foliage range) — Fresnel-zone obstruction adds ~30% range discount in sparse-foliage scenarios; the 100 m number should be retracted to ~70 m.
|
||||
- **R12** (eigenshift) — the failed SVD-spectrum approach has a clear successor: PABS over Fresnel-grounded basis.
|
||||
- **R14** (empathic appliances) — Fresnel-envelope sensitivity bound sets the per-room calibration floor for the V1 stress-responsive lighting use case.
|
||||
- **ADR-029** (multistatic) — provides the closed-form attention-weight prior the current learned-weights system lacks.
|
||||
@@ -0,0 +1,141 @@
|
||||
# R6.2 — Fresnel-aware antenna placement: a 93× sensing-coverage lift from physics
|
||||
|
||||
**Status:** working CLI tool + demo + 5×5 m bedroom benchmark · **2026-05-22**
|
||||
|
||||
## Premise
|
||||
|
||||
R6 (Fresnel forward model) said: there is a ~40 cm wide ellipsoid around a 5 m WiFi link where occupancy dominates the CSI signal. Outside that envelope, CSI is mostly multipath edge noise. The current RuView installation guide is essentially "stick the seed wherever the AP is and hope for the best."
|
||||
|
||||
This thread quantifies how much coverage you give up by ignoring the Fresnel geometry — and provides a CLI-shaped tool that solves the placement problem given a room layout + target occupancy zones (bed, chair, where the user actually spends time).
|
||||
|
||||
## Method
|
||||
|
||||
In 2D the first Fresnel zone is an ellipse with:
|
||||
|
||||
- foci at Tx and Rx
|
||||
- semi-major axis `a = (d + λ/2) / 2`
|
||||
- semi-minor axis `b = √(a² − (d/2)²) ≈ √(d·λ)/2` for d ≫ λ
|
||||
|
||||
A point `x` is inside the first Fresnel zone iff `|Tx-x| + |x-Rx| ≤ d + λ/2`. This is the natural 2D extension of R6's midpoint radius formula.
|
||||
|
||||
`examples/research-sota/r6_2_antenna_placement.py` rasterises target zones at 5 cm resolution, evaluates every candidate (Tx, Rx) pair on the room perimeter (25 cm step), and picks the pair that maximises total target-zone area inside the first Fresnel ellipse.
|
||||
|
||||
## Benchmark: 5×5 m bedroom
|
||||
|
||||
Two target zones:
|
||||
|
||||
| Zone | Position | Area |
|
||||
|---|---|---:|
|
||||
| Bed | (1.5, 0.5)-(3.5, 2.0) | 3.00 m² |
|
||||
| Chair | (3.5, 3.5)-(4.3, 4.3) | 0.64 m² |
|
||||
|
||||
2,900 antenna pairs evaluated at 2.4 GHz (λ = 12.5 cm):
|
||||
|
||||
| Placement | Tx | Rx | Link | Bed cov | Chair cov | **Total** |
|
||||
|---|:---:|:---:|---:|---:|---:|---:|
|
||||
| **Optimal** | (1.25, 0.00) | (4.75, 5.00) | 6.10 m | 43.5% | 86.7% | **51.1%** |
|
||||
| Median (rand-place baseline) | varies | varies | varies | varies | varies | 0.5% |
|
||||
| Worst | varies | varies | 5.00 m | varies | varies | **0.0%** |
|
||||
|
||||
**Best/median improvement: 93×.** The current "stick it anywhere" deployment recipe is ~50-100× below optimal in this geometry. Most placements give effectively no sensing of the actual target zones, because the Fresnel ellipse threads space that nobody occupies.
|
||||
|
||||
## Why diagonal-across-the-room wins
|
||||
|
||||
The optimal placement runs **diagonally across the long axis**, threading both the bed and the chair. The 6.10 m link length is **longer** than any wall-parallel link (≤5 m), which gives a **wider** Fresnel ellipse at the midpoint:
|
||||
|
||||
```
|
||||
b(d=5.0, λ=0.125) = √(5.0 × 0.125)/2 = 39.5 cm
|
||||
b(d=6.1, λ=0.125) = √(6.1 × 0.125)/2 = 43.7 cm (+10%)
|
||||
```
|
||||
|
||||
The Fresnel envelope **gets wider as the link gets longer** (up to the link-budget limit, which we ignore here — R10 sets that). Counter to the intuition "shorter link = stronger signal", *longer* links cover *more space*. Up to a budget-limited point.
|
||||
|
||||
## Per-cog deployment recommendations
|
||||
|
||||
Plugging this into each existing cog's installation flow:
|
||||
|
||||
| Cog | Target zones | Recommended placement |
|
||||
|---|---|---|
|
||||
| `cog-person-count` (R8/R5/ADR-103) | Any room occupancy | Diagonal across longest axis |
|
||||
| `cog-pose-estimation` (ADR-079, ADR-101) | Where pose matters (gym corner, kitchen workspace) | Place link so the zone is within ~50% of the midpoint envelope width |
|
||||
| AETHER re-ID (ADR-024) | Doorway + main occupancy zone | Tx near doorway, Rx diagonal across; doorway transit triggers ID, main zone confirms |
|
||||
| `cog-maritime-watch` (R11) | Cabin floor space | Tx ceiling-mount, Rx floor-mount, vertical diagonal through cabin |
|
||||
| `cog-wildlife` (R10 follow-up, not yet built) | Forest clearing perimeter | Tx and Rx on opposite trees, link threads the clearing midline |
|
||||
|
||||
These recommendations make the existing installation guides ~50-100× more effective without any hardware change.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
1. **A shippable CLI tool** that gives end users immediate placement guidance. Same input shape as `wifi-densepose plan-antennas --room 5x5 --target bed,1,1,2x1`. The output is a concrete placement that an installer can mount to.
|
||||
2. **Reproducible benchmarks** for the "is the placement good enough?" question. Existing RuView installs have no objective placement metric; this tool gives one.
|
||||
3. **A natural cog feature**: when a new cog is added (e.g. `cog-wildlife`), the placement guide is generated from the cog's target-zone schema, not hand-written per-cog.
|
||||
4. **Adaptive 4-anchor multistatic generalisation.** The current 2D single-pair search extends naturally to N anchors — pick the 4-anchor set that maximises union-of-Fresnel-envelopes coverage. Each additional anchor saturates coverage (diminishing returns), giving a quantitative answer to "is 4 anchors enough?" (in a 5×5 m bedroom: yes; in a 10 m living room: no, need 6).
|
||||
|
||||
## Composes with prior threads
|
||||
|
||||
- **R6** (Fresnel forward model) — provides the 2D extension; R6.2 is the natural application.
|
||||
- **R1** (CRLB) — combining R1's localisation precision with R6.2's coverage gives a full **sensing geometry budget**: how many anchors × where × precision.
|
||||
- **R10** (foliage range) — the link-budget cap on link length is set by R10's path-loss model. For sparse foliage at 2.4 GHz, R10 said 100 m is the maximum link; R6.2 says use most of that budget for wider Fresnel envelopes.
|
||||
- **R11** (maritime) — ship cabins are small + steel-walled (Fresnel envelope narrowed by reflection geometry); R6.2's recipe still applies but coverage saturates faster.
|
||||
- **R14** (empathic appliances) — V1 lighting / V2 HVAC / V3 attention-respecting need to sense the *occupant*, who lives in known target zones (bed, sofa, desk). R6.2 is the installation-time tool that ensures the empathic-appliance system actually sees the user.
|
||||
- **ADR-105** (federated learning) — placement plays no role in federation per se, but better placement → better local training data → faster convergence with smaller (ε, δ) budget (ADR-106).
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **2D approximation.** Real Fresnel envelopes are 3D ellipsoids; the 2D model is correct for floor-level scattering (most occupancy) but underestimates ceiling-mounted antennas' coverage of standing occupants. A 3D version is a half-day's work.
|
||||
- **Free-space assumption.** Real rooms have furniture, walls, and floor reflections. Multipath sometimes *helps* coverage outside Fresnel (multi-bounce paths add signal paths). The 2D Fresnel-only model is a lower bound on coverage; real rooms typically have +5-15% coverage from multipath.
|
||||
- **Rectangular target zones.** People don't occupy rectangles. A more realistic version uses pose-trajectory distributions (where do users *actually* spend time) — derived from R3 + AETHER + a few weeks of data.
|
||||
- **Single-pair only.** Multistatic with N > 2 anchors is a strict superset; the current code only searches over single-pair placements. Multi-anchor extension is the next R6.2.1.
|
||||
- **Perimeter-only candidates.** The 25 cm step on walls assumes wall-mounted antennas. Ceiling mounts, free-standing tripods, and furniture-attached placements are all valid but harder to evaluate (more design freedom = larger search space).
|
||||
- **No link-budget gate.** A diagonal-across-30-m-warehouse placement may have wider Fresnel envelope but exceed the link budget (R10). The current code doesn't gate by link budget; for large rooms this is critical.
|
||||
|
||||
## Practical CLI shape
|
||||
|
||||
```bash
|
||||
wifi-densepose plan-antennas \
|
||||
--room 5.0 5.0 \
|
||||
--target bed 1.5 0.5 2.0 1.5 \
|
||||
--target chair 3.5 3.5 0.8 0.8 \
|
||||
--freq-ghz 2.4 \
|
||||
--step 0.25
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
BEST placement:
|
||||
Tx: 1.25, 0.00
|
||||
Rx: 4.75, 5.00
|
||||
Coverage fraction: 51.1%
|
||||
Per-zone:
|
||||
bed: 43.5%
|
||||
chair: 86.7%
|
||||
```
|
||||
|
||||
This is the deliverable a customer would run before mounting hardware. Two minutes of computation saves an installer from making the "stick it on the AP" mistake that loses 50-100× of the sensing potential.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- **3D placement** for ceiling-mount antennas.
|
||||
- **Link-budget gating** for long-distance deployments.
|
||||
- **Multi-anchor optimisation** for the eventual ADR-029 multistatic shipping.
|
||||
- **Pose-trajectory-aware target zones** — these need empirical data, not just static room layouts.
|
||||
- **Furniture / wall reflection modelling** — bigger model, slower search, marginal improvement.
|
||||
|
||||
## Next ticks (R6.2 follow-ups)
|
||||
|
||||
- **R6.2.1**: 3D extension. Replace 2D ellipse with prolate ellipsoid; allow ceiling/floor antenna mounts.
|
||||
- **R6.2.2**: N-anchor multistatic placement (maximises *union* of N pairwise Fresnel envelopes). Quantitative answer to "is 4 anchors enough?"
|
||||
- **R6.2.3**: Pose-trajectory-aware target zones, fed from AETHER's per-installation occupancy data (R3 + ADR-105 federation enables this without raw data leaving the install).
|
||||
- **Productise**: add as `wifi-densepose plan-antennas` subcommand; mention in ADR-104's CLI surface as a deferred MCP tool `ruview_placement_recommend`.
|
||||
|
||||
## What this DOES close
|
||||
|
||||
The "we don't have a placement recommendation tool" gap that every RuView installer hits is now closed with a working CLI-shaped prototype. The 93× median-vs-best improvement is large enough that productising this is high-leverage with no new physics.
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R5** (saliency) — placement that gets a target zone *in* the first Fresnel zone yields the band-spread saliency profile R5 measured. Bad placement (target outside the zone) gives band-edge-only saliency, which is what R5 explicitly didn't measure (no occupant outside the envelope = no saliency to measure).
|
||||
- **R6** (Fresnel forward model) — direct extension. R6 gave the math; R6.2 productises it.
|
||||
- **R7** (mincut adversarial) — multi-pair placement that R6.2.2 will solve enables the multi-link consistency check R7 needs. Single-pair installations can't run R7's adversarial defence.
|
||||
- **R9** (RSSI fingerprint K-NN) — RSSI doesn't have the spatial precision Fresnel gives; placement matters less for RSSI-only deployments (R8 + R9 showed 95% retained even with coarse spatial info).
|
||||
- **R14** (empathic appliances) — the V1/V2/V3 verticals all need *the right user* sensed, which means the user's bed/sofa/desk must be inside the Fresnel envelope. R6.2 makes this an installation-time check, not a deploy-and-pray.
|
||||
@@ -0,0 +1,106 @@
|
||||
# R6.2.2 — N-anchor multistatic Fresnel placement: how many seeds do I need?
|
||||
|
||||
**Status:** working multi-anchor greedy + saturation curve · **2026-05-22**
|
||||
|
||||
## Premise
|
||||
|
||||
R6.2 answered the single-pair placement question. R6.2.2 answers the **multi-anchor saturation** question: given a room + target zones, how does coverage scale with the number of anchors? The practical answer — "how many Cognitum Seeds do I need to deploy?" — falls out of the saturation curve.
|
||||
|
||||
## Method
|
||||
|
||||
Same Fresnel-ellipse machinery as R6.2, but instead of a single pair, evaluate **all C(N, 2) pairwise Fresnel ellipses** and compute their **union coverage** of the target zones.
|
||||
|
||||
Full combinatorial search is O(M^N) which blows up past N=4 with M=40 candidates. We use **greedy with K random restarts** instead: starting from a random initial pair, at each step add the candidate that maximises marginal coverage. K=8 restarts gives reliable convergence at this problem size; each restart is O(N·M·grid_size) which is tractable.
|
||||
|
||||
## 5×5 m bedroom benchmark
|
||||
|
||||
Three target zones (bed 3.00 m² + chair 0.64 m² + desk 0.60 m²); 40 wall-perimeter candidates at 0.5 m step; 434 target grid points.
|
||||
|
||||
| N anchors | Pairwise links | Coverage | Marginal gain |
|
||||
|---:|---:|---:|---:|
|
||||
| 2 | 1 | 35.7% | +35.7 pp |
|
||||
| 3 | 3 | 63.4% | +27.6 pp |
|
||||
| 4 | 6 | 86.2% | +22.8 pp |
|
||||
| **5** | **10** | **96.8%** | **+10.6 pp** |
|
||||
| 6 | 15 | 100.0% | +3.2 pp |
|
||||
| 7+ | 21+ | 100.0% | +0.0 pp |
|
||||
|
||||
**Knee at N=5** — going from 4 to 5 adds 10.6 pp; from 5 to 6 adds only 3.2 pp. Past 5 anchors, the gain per additional seed drops below the practical-cost threshold.
|
||||
|
||||
## Three regimes
|
||||
|
||||
### Sparse (N=2–3)
|
||||
|
||||
A single-link or 3-anchor install hits 36-63% coverage. Acceptable for **occupancy-only** features (R8 person-count, room-presence triggers). Insufficient for per-occupant features (R14 V1/V2/V3) that need the specific occupant zone sensed.
|
||||
|
||||
### Practical (N=4–5)
|
||||
|
||||
The ADR-029 default of 4 anchors hits 86% in this geometry — close to but not at the "all zones reliably sensed" line. **5 anchors closes the gap to ~97%**, which is the right product target for empathic-appliance features (R14 V1 lighting, V2 HVAC, V3 attention-respecting).
|
||||
|
||||
### Saturated (N=6+)
|
||||
|
||||
100% is reachable with 6 anchors and stays there. Diminishing returns past 5 are real — additional anchors mostly redundant.
|
||||
|
||||
## Bridging back to ADR-029
|
||||
|
||||
ADR-029 specifies multistatic sensing without specifying the anchor count. This thread gives a concrete answer for a bedroom: **5 anchors hits the practical knee**, 4 is acceptable for occupancy-only, 6+ is over-provisioned. Different room geometries (larger living rooms, open-plan kitchens, narrow hallways) will have different knees — but the methodology transfers without modification.
|
||||
|
||||
Updating ADR-029's recommended configuration:
|
||||
|
||||
| Use case | Anchor count | Expected coverage |
|
||||
|---|---:|---:|
|
||||
| Single-feature (presence / occupancy) | 2-3 | 36-63% |
|
||||
| Multi-feature (pose, vitals, count) | **4-5** | 86-97% |
|
||||
| Mission-critical (medical, security) | 6 | 100% |
|
||||
| Beyond 6 | wasted | 100% (no gain) |
|
||||
|
||||
## Why this matters for cost / installation
|
||||
|
||||
A typical Cognitum Seed costs $9-15 BOM. 4 → 5 anchors is +$9-15 + ~10 min installer time. 5 → 6 is the same cost for +3.2 pp coverage. The economic story for **most consumer deployments** is **5 anchors, hit the knee**. Commercial / medical deployments can justify the 6-anchor configuration; consumers shouldn't.
|
||||
|
||||
This is a **shipping-ready cost-optimisation conclusion** with explicit numbers.
|
||||
|
||||
## Composes with prior threads
|
||||
|
||||
- **R6** (Fresnel forward model) — provides the 2D ellipse machinery R6.2.2 unions over.
|
||||
- **R6.2** (single-pair placement) — direct generalisation; greedy expansion to N anchors.
|
||||
- **R7** (mincut adversarial) — **requires** N ≥ 3 to detect single-link adversarial spoofing; N ≥ 4 to detect single-anchor compromise. R6.2.2's knee at N=5 happens to also satisfy R7's defensive requirement.
|
||||
- **R1** (CRLB) — combined with R6.2.2, gives the full sensing geometry budget: 5 anchors × R1's 25 cm ToA precision per anchor = full room-scale geometric coverage at room-pose quality.
|
||||
- **ADR-029** (multistatic) — direct architectural recommendation update.
|
||||
- **ADR-105** (federated learning) — N=5 is also "enough" for inter-node Krum aggregation (f=1 byzantine tolerance with K=5).
|
||||
|
||||
## Honest scope
|
||||
|
||||
- **Single geometry tested.** Only 5×5 m bedroom with these 3 zones. Living rooms, hallways, kitchens will have different knees. A repository of "knee-per-room-shape" benchmarks would be valuable; not built here.
|
||||
- **2D still.** R6.2.1 (3D ellipsoid + ceiling/floor anchors) hasn't been built. In 3D, the same anchor count may give either more or less coverage depending on geometry.
|
||||
- **Free-space.** Multipath probably adds +5-15% coverage beyond the Fresnel-only model. The N=5 knee in practice may be N=4-5 with multipath.
|
||||
- **No link-budget gate.** Long-distance large-room placements may exceed R10's path-loss cap.
|
||||
- **Greedy + restarts.** Approximation to global optimum; restarts=8 typically lands within 1-2 pp of the global optimum for N ≤ 8 on this problem size.
|
||||
- **No furniture occlusion.** A real bedroom has the wardrobe blocking some Fresnel ellipses.
|
||||
|
||||
## What this DOES enable
|
||||
|
||||
1. **Concrete cost-optimisation answer**: 5 anchors is the practical recommendation for most consumer rooms.
|
||||
2. **Saturation curve methodology**: customer / installer can run their own room layout and see where their knee is.
|
||||
3. **ADR-029 update**: anchor-count recommendation backed by physics + benchmark.
|
||||
4. **Forward-projection**: combined with R1 (precision) and R6.2 (single-pair lift), we now have a full **sensing geometry budget** for any RuView room install.
|
||||
|
||||
## What this DOES NOT enable
|
||||
|
||||
- 3D ceiling/floor placement (R6.2.1 needed)
|
||||
- Pose-trajectory-aware zones (R6.2.3, depends on AETHER + R3 data)
|
||||
- Cross-room multistatic (single-room only; R3 handles cross-room re-ID via embeddings)
|
||||
- Furniture occlusion modelling
|
||||
|
||||
## Next ticks (R6.2 family)
|
||||
|
||||
- **R6.2.1**: 3D extension with ceiling/floor anchors
|
||||
- **R6.2.3**: pose-trajectory-aware target zones (need AETHER + R3 data)
|
||||
- **R6.2 productisation**: ship as `wifi-densepose plan-antennas` CLI subcommand + MCP tool `ruview_placement_recommend`
|
||||
|
||||
## Connection back
|
||||
|
||||
- **R14** (empathic appliances) — V1 stress-responsive lighting needs ≥86% coverage to actually sense the occupant; R6.2.2 says N=4-5 is the right anchor count.
|
||||
- **R11** (maritime) — through-seam sensing in cabins is small + cluttered; saturation likely hits earlier (N=3-4). Worth benchmarking on cabin geometry.
|
||||
- **R10** (foliage / wildlife) — outdoor wildlife corridors are long + thin; saturation curve will be different (more anchors needed for length, fewer for width).
|
||||
- **ADR-029 / ADR-105 / ADR-106** — N=5 is also the Krum byzantine-fault-tolerance threshold for f=1 attacker, which means **the same 5-anchor count satisfies coverage, R7 adversarial defence, and ADR-105 federation byzantine bound simultaneously**. The numerology is convenient and probably not coincidental — these constraints are all bounded by similar inverse-square-of-geometry scaling.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Tick 10 — 2026-05-22 05:46 UTC
|
||||
|
||||
**Thread:** R11 (maritime / through-bulkhead sensing)
|
||||
**Verdict:** Physics scrutiny re-frames "through-bulkhead" to "through-seam" — the romantic submarine-radar vision is impossible at WiFi bands; the actual product category is **gasket-leakage sensing**.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r11_maritime_propagation.py` — pure-numpy skin-depth + lossy-dielectric saltwater + slot-diffraction physics for 7 maritime scenarios.
|
||||
- `examples/research-sota/r11_maritime_results.json` — machine-readable predictions.
|
||||
- `docs/research/sota-2026-05-22/R11-maritime-sensing.md` — research note with the physics, verdicts table, feasible/infeasible verticals, honest scope, composition with prior threads.
|
||||
|
||||
## Headline (verdict table)
|
||||
|
||||
| Scenario | Verdict | Margin |
|
||||
|---|---:|---:|
|
||||
| Man-overboard surface @ 200 m | ✅ | +25 dB |
|
||||
| Through 10 mm closed steel door | ❌ | -938 dB |
|
||||
| Through cabin door **2 mm seam** | ✅ | **+31 dB** |
|
||||
| Through cabin door **5 mm seam** | ✅ | +39 dB |
|
||||
| Container w/ 30 mm vent slot | ✅ | +45 dB |
|
||||
| Submarine 30 mm pressure hull | ❌ | -929 dB |
|
||||
| Head 30 cm underwater | ❌ | -231 dB |
|
||||
|
||||
Key physics: steel skin depth = **3.25 µm at 2.4 GHz** (impassable). Saltwater = **853 dB/m**. The loophole is **slot diffraction** through gasket seams.
|
||||
|
||||
## Feasible verticals catalogued
|
||||
|
||||
1. Man-overboard surface detection (200 m range)
|
||||
2. Through-seam crew vitals (lone-watch monitoring without compromise)
|
||||
3. Container tamper detection (cargo security)
|
||||
4. Hatch-seal integrity audit (predictive maintenance)
|
||||
5. Engine room thermal-anomaly detection (via condensation envelope)
|
||||
|
||||
## What this matters for the loop
|
||||
|
||||
R11 is the first thread that **explicitly debunks** a romantic 10-20y framing. The "through-bulkhead" terminology used in the original PROGRESS.md is physically wrong; the actual category is "through-seam". Replacing one vision with a more honest one is the kind of progress this loop is meant to surface.
|
||||
|
||||
Composes cleanly:
|
||||
- R6 Fresnel envelope + slot diffraction = narrower composite envelope
|
||||
- R10 link-budget primitives reused unmodified for air-side maritime
|
||||
- R7 multi-link consistency essential for adversarial-resistant maritime
|
||||
- R14 privacy framework transfers directly to crew-cabin monitoring
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- Best-case ignores vessel vibration, engine ignition noise, salt-spray, multipath
|
||||
- Vibration (5-30 Hz) is **in-band** with R10's gait frequencies — maritime gait-classification harder than land
|
||||
- No GPS in steel compartments — alternative positioning needed
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-10.md`. No PROGRESS.md edit. Branch `research/sota-r11-maritime`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R3 (cross-room re-ID), R4 (federated), R13 (contactless BP — likely negative-result candidate), R15 (RF biometric).
|
||||
|
||||
~6.3h to cron stop. 10 threads landed.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Tick 11 — 2026-05-22 06:01 UTC
|
||||
|
||||
**Thread:** R13 (contactless BP) — **NEGATIVE RESULT**
|
||||
**Verdict:** Don't pursue contactless BP from CSI as a primary product feature. The physics floors make it provably worse than a $20 arm cuff at every dimension.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r13_bp_physics_floor.py` — pure-numpy quantification of four physics floors that defeat the published CSI-BP approach.
|
||||
- `examples/research-sota/r13_bp_results.json` — machine-readable predictions.
|
||||
- `docs/research/sota-2026-05-22/R13-contactless-bp-negative.md` — explicit negative-result scrutiny note.
|
||||
|
||||
## Four floors quantified
|
||||
|
||||
| Floor | Need | Have | Gap |
|
||||
|---|---|---|---|
|
||||
| PTT temporal resolution | 0.5 ms (for 1 mmHg) | 10 ms typical, 1 ms max | typical ESP32 deployment cannot do <20 mmHg |
|
||||
| Spatial separation of two body sites | 55 cm | 40 cm Fresnel at 5 m link | sites CANNOT be resolved by single link |
|
||||
| Pulse-contour SNR | +25 dB | +20 dB after bandpass | **5 dB short** |
|
||||
| Vs $20 arm cuff | ±2 mmHg | best published ±10 mmHg | **5× worse** |
|
||||
|
||||
The cleanest result: pulse signal motion at the chest is **0.3 mm**, breathing is **8 mm** — 27× larger. After bandpass we recover rate (we already ship this) but cannot recover waveform shape, which is what BP estimation needs.
|
||||
|
||||
## Why this is the most valuable kind of tick
|
||||
|
||||
A research loop that only publishes successes biases toward overclaiming. Two negative results this loop:
|
||||
|
||||
1. **R12 eigenshift** — naive SVD-spectrum approach fails because signal doesn't dominate drift floor
|
||||
2. **R13 contactless BP** — published approaches require unrealistic SNR and spatial resolution
|
||||
|
||||
Both follow the same pattern: a plausible-sounding ML approach fails because the underlying signal doesn't dominate the noise. Both have explicit follow-up paths if anyone wants to revisit (R12 → PABS over Fresnel basis from R6; R13 → bed-instrumented `cog-bedside` niche, multistatic PWV with 6+ anchors).
|
||||
|
||||
## Confirms R14's design choice
|
||||
|
||||
R14 (empathic appliances) explicitly assumed BP would *not* be available — its V1/V2/V3 sketches depend only on breathing + HR rate + motion intensity. R13 confirms that assumption is right.
|
||||
|
||||
## What's still open in the negative space
|
||||
|
||||
Three niche scenarios where BP-from-CSI *might* close some day:
|
||||
1. Single-subject **trend** monitoring (relative not absolute)
|
||||
2. Bed-instrumented controlled-still subject (25+ dB SNR achievable)
|
||||
3. Multistatic PWV with 6+ anchors + per-installation calibration
|
||||
|
||||
The general "BP from a $9 ESP32 in the corner" claim does not close.
|
||||
|
||||
## Composes with prior threads
|
||||
|
||||
- **R1** (CRLB) — confirms temporal-resolution floor for PTT
|
||||
- **R6** (Fresnel) — provides the spatial floor that defeats two-site PTT
|
||||
- **R5** (saliency) — band-spread occupancy explains why the whole chest is observed but the 0.3 mm pulse isn't
|
||||
- **R12** — loop's other negative result; same failure pattern
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-11.md`. No PROGRESS.md edit. Branch `research/sota-r13-contactless-bp-negative`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R3 (cross-room re-ID), R4 (federated learning), R15 (RF biometric across rooms).
|
||||
|
||||
~6.0h to cron stop. 11 threads landed (2 explicit negative results).
|
||||
@@ -0,0 +1,62 @@
|
||||
# Tick 12 — 2026-05-22 06:08 UTC
|
||||
|
||||
**Thread:** R3 (cross-room re-ID)
|
||||
**Verdict:** Cross-room re-ID is **technically feasible** (MERIDIAN closes the env-shift gap) and **ethically constrained** (4 additional privacy constraints beyond R14 baseline).
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r3_crossroom_reid.py` — pure-numpy simulation of person + environment + noise decomposition with 4 K-NN configurations.
|
||||
- `examples/research-sota/r3_reid_results.json` — machine-readable predictions.
|
||||
- `docs/research/sota-2026-05-22/R3-crossroom-reid.md` — synthesis of AETHER (ADR-024) + MERIDIAN (ADR-027) + privacy framing + physics-informed extension path.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| Configuration | 1-shot accuracy |
|
||||
|---|---:|
|
||||
| Within-room (matches AETHER ~95%) | **100%** |
|
||||
| Cross-room, raw cosine K-NN | 70% |
|
||||
| Cross-room, MERIDIAN 100% env removal | 100% |
|
||||
| Cross-room, MERIDIAN 70% env removal (realistic) | 100% |
|
||||
| Chance | 10% |
|
||||
|
||||
The 30 pp gap from within-room to raw cross-room is exactly the angular contribution of the env-shift that cosine similarity can't normalise away. MERIDIAN-style per-room centroid subtraction recovers it — even at 70% effectiveness (realistic for limited labelled examples).
|
||||
|
||||
## Privacy constraints surfaced
|
||||
|
||||
R14 baseline (opt-in default, on-device data, one-tap override) + **4 new constraints specific to re-ID**:
|
||||
|
||||
1. No cross-installation linkage (each install = isolated embedding space)
|
||||
2. Embedding storage requires explicit opt-in (biometric-class consent)
|
||||
3. Cryptographically verifiable forgetting (not just unlabelled storage)
|
||||
4. No re-ID across legal entities (hard-walled inter-org boundaries)
|
||||
|
||||
These rule out: cross-building tracking, mass surveillance, long-term unlabelled storage, third-party data sharing. They allow: per-installation personalisation, household anomaly detection, multi-person pose association in the same room.
|
||||
|
||||
## Why R3 matters as a synthesis
|
||||
|
||||
R3 closes the loop on the empathic-appliance vision from R14: re-ID is **the** primitive that makes per-occupant features possible (V1 stress-responsive lighting needs to know it's "this person", not "any person"). Without R3, R14's verticals can't ship; with R3 + its privacy constraints, they can.
|
||||
|
||||
It also identifies the **next research lever**: physics-informed env_sig prediction from R6's forward operator + a room map → zero-shot transfer without labelled examples in the new room.
|
||||
|
||||
## Composes cleanly
|
||||
|
||||
- **R5/R6**: person + env decomposition lives in the embedding space; physics-informed env prediction is the unbuilt sophistication.
|
||||
- **R7**: mincut multi-link consistency = defence against re-ID spoofing.
|
||||
- **R9**: RSSI K-NN showed env-locality dominance for the K-NN primitive; CSI is harder but the same decomposition works.
|
||||
- **R14**: the four R3 privacy constraints extend R14's framework to biometric-class data.
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- Additive decomposition is a first-order model; real CSI env effects are multiplicative in subcarrier domain
|
||||
- The 70% raw-cosine K-NN number depends on env / person scale ratio (here ~4.7×)
|
||||
- Adversarial scenarios not simulated; R7 mincut would weigh in
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-12.md`. No PROGRESS.md edit. Branch `research/sota-r3-crossroom-reid`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R4 (federated learning), R15 (RF biometric across rooms — now partly subsumed by R3).
|
||||
|
||||
~5.8h to cron stop. 12 threads landed (2 negative results, 1 synthesis).
|
||||
@@ -0,0 +1,51 @@
|
||||
# Tick 13 — 2026-05-22 06:13 UTC
|
||||
|
||||
**Thread:** R4 (federated learning)
|
||||
**Verdict:** ADR-105 drafted. Federated CSI training is the unique design that satisfies R14 (data-stays-on-device) + R3 (no cross-installation linkage) + R7 (multi-node adversarial defence) simultaneously.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `docs/adr/ADR-105-federated-csi-training.md` — full ADR draft covering protocol, threat model, bandwidth analysis, alternatives, implementation plan.
|
||||
|
||||
This tick chose the "one ADR" unit option from the cron prompt rather than another numpy demo — federation is fundamentally a protocol-design problem, not a numerical-experiment problem. Architectural decisions are the right unit when the question is "what's the right shape of the thing" not "what number does it give".
|
||||
|
||||
## Headline protocol
|
||||
|
||||
**MERIDIAN-FedAvg with Byzantine-robust (Krum) aggregation + R7 mincut update-level consistency.**
|
||||
|
||||
Per-round bandwidth (4-seed installation):
|
||||
- Coordinator → nodes (multicast): 8 MB checkpoint
|
||||
- Each node → coordinator: 1 MB delta (LoRA-rank-8 + int8 quantisation)
|
||||
- Total per round: ~12 MB
|
||||
- Weekly × monthly = ~50-180 MB/month/installation (0.06% of typical broadband cap)
|
||||
|
||||
## Why ADR-105 not another numpy demo
|
||||
|
||||
R3 (last tick) said: "re-ID is the primitive that makes empathic appliances ship". R4 says: "federation is the protocol that makes re-ID training privacy-compliant." Together they trace the full pipeline from physics (R6) → embeddings (R3) → personalised features (R14) → trained how (R4) → defended how (R7).
|
||||
|
||||
The protocol is the deliverable. ADR-105 specifies it; ruview-fed crate implementation (~500 LOC) is the next-quarter work.
|
||||
|
||||
## Composes with every prior thread
|
||||
|
||||
- **R3** — MERIDIAN env centroid subtraction is **mandatory** pre-aggregation step.
|
||||
- **R7** — Stoer-Wagner mincut extended from multi-link CSI to multi-node update consistency.
|
||||
- **R12 / R13** — two negative results informed the byzantine-robust + SNR-threshold-on-updates choices.
|
||||
- **R14** — privacy framework's "data stays on-device" baseline is now operational.
|
||||
- **ADR-024 (AETHER), ADR-027 (MERIDIAN), ADR-029 (multistatic), ADR-100 (cog packaging), ADR-103 (cog-person-count), ADR-104 (MCP+CLI)** — all referenced in the ADR's "bridge to existing ADRs" section.
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- Cross-installation federation explicitly **deferred** to a future ADR (legal + DP work needed)
|
||||
- Member inference defence → ADR-106 with formal DP-SGD
|
||||
- The 500 LOC + 2-week-effort estimates assume AgentDB / microlora / mincut crates are stable
|
||||
- Krum byzantine bound: f < (K-2)/2 — practical f ≤ 4 for typical RuView installs
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-13.md`. No PROGRESS.md edit. Branch `research/sota-r4-federated-adr105`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R15 (RF biometric across rooms) — now largely subsumed by R3 + ADR-105 cross-installation deferral. Could write a short "scoping note" for R15 in next tick to close the loop, or pick up the deferred items: physics-informed env_sig prediction (next R3 follow-up), or ADR-106 (DP-SGD on local training).
|
||||
|
||||
~5.7h to cron stop. 13 threads landed (2 negative results, 1 ADR, 10 research notes with demos).
|
||||
@@ -0,0 +1,87 @@
|
||||
# Tick 14 — 2026-05-22 06:32 UTC
|
||||
|
||||
**Thread:** R15 (RF biometric across rooms)
|
||||
**Verdict:** Catalogues 5 environment-invariant biometric primitives in CSI with quantified discriminability + strengthens R14/R3/ADR-105 privacy framework. Closes the last unaddressed research-loop thread.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `docs/research/sota-2026-05-22/R15-rf-biometric-primitives.md` — synthesis pulling from R5, R6, R8, R10, R13, R3, R14, ADR-105.
|
||||
|
||||
## Five biometric primitives inventoried
|
||||
|
||||
| Primitive | Bits/person | Cross-room invariance | Status |
|
||||
|---|---:|:---:|---|
|
||||
| Gait stride frequency | 5 | HIGH | shipped (R10 DSP) |
|
||||
| Breathing rate + envelope | 5 | HIGH | shipped (vital_signs) |
|
||||
| HRV (rate-level only) | 4 | HIGH at rate, LOW at contour | partial (R13 negative on contour) |
|
||||
| Body-size RCS frequency response | 4 | MEDIUM (needs calibration target) | not built |
|
||||
| Walking dynamics (limb timing) | 7 | HIGH (if pose works cross-room) | pose pipeline shipped, cross-room unmeasured |
|
||||
|
||||
**Composite biometric strength**: ~12-15 bits realistic (vs 25-bit independence upper bound). Enough for household + building-scale ID; insufficient for forensic / city-scale.
|
||||
|
||||
## Privacy framework strengthened
|
||||
|
||||
R15 makes a sharper point than R14/R3: **RF biometric is physical, not learned, so the same identification primitive that enables empathic appliances is also a surveillance primitive that's harder to opt out of than visual ID.**
|
||||
|
||||
| R3/ADR-105 baseline | R15-strengthened |
|
||||
|---|---|
|
||||
| No cross-installation linkage | Hardware-isolated, cryptographically proven |
|
||||
| Embedding storage opt-in | Storage of any biometric primitive opt-in (not just embeddings) |
|
||||
| Cryptographically verifiable forgetting | Forget raw primitives, not just outputs |
|
||||
| No re-ID across legal entities | No sharing of any RF biometric primitive (including aggregate / derived) |
|
||||
|
||||
## ADR-105 amendment surfaced
|
||||
|
||||
Adds a constraint to ADR-105 federation:
|
||||
|
||||
> The federation aggregator MUST NOT receive any raw per-subject biometric primitive (gait frequency, breath rate, RCS curve, limb timing). It MAY receive aggregated, MERIDIAN-normalised model deltas. Per-subject primitives stay on-device.
|
||||
|
||||
This becomes the requirements basis for **ADR-106 (deferred DP-SGD ADR from ADR-105)**.
|
||||
|
||||
## Why R15 closes the loop
|
||||
|
||||
R15 is the last unaddressed PROGRESS.md thread. After R15:
|
||||
- **Closed**: "what RF biometrics exist and how do they invariantise" has a worked answer
|
||||
- **Open**: ADR-106, R6.1 multi-scatterer, R3 follow-up (physics-informed env_sig prediction), R6.2 antenna placement
|
||||
|
||||
The per-occupant feature surface (R14 V1/V2/V3) is now fully grounded in physics + constraints; remaining work is implementation, not research.
|
||||
|
||||
## Composes with every prior thread
|
||||
|
||||
- R5 saliency → primitive-specific saliency maps
|
||||
- R6 Fresnel → physical basis for RCS frequency-response invariance
|
||||
- R7 mincut → defends primitive-level poisoning
|
||||
- R10 per-species gait taxonomy → transfers to per-individual gait biometric
|
||||
- R13 NEGATIVE → 5-dB-short wall also rules out contour-level HRV
|
||||
- R3 → embedding space combines the 5 primitives
|
||||
- R14 → all 3 verticals (V1/V2/V3) work with the rate-level subset, no contour recovery
|
||||
- ADR-105 → needs ADR-106 to formalise on-device-only primitive measurement
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- Bit counts are upper bounds; realistic 30-50% loss to noise/multipath/sensor variance
|
||||
- Contour-level HRV not achievable (R13 wall)
|
||||
- Walking-dynamics 7-bit assumes pose-from-CSI works cross-room (unmeasured)
|
||||
- Body-size RCS needs calibration target in new room → ratio-only gives 3-4 bits not 5
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-14.md`. No PROGRESS.md edit. Branch `research/sota-r15-rf-biometric`.
|
||||
|
||||
## Remaining work (deferred to post-loop)
|
||||
|
||||
- **ADR-106**: on-device DP-SGD + primitive isolation requirements from R15
|
||||
- **R6.1**: multi-scatterer additive Fresnel forward model
|
||||
- **R3 follow-up**: physics-informed env_sig prediction (zero-shot cross-room)
|
||||
- **R6.2**: Fresnel-aware antenna placement CLI tool
|
||||
|
||||
~5.4h to cron stop. **14 threads landed. PROGRESS.md research agenda exhausted.**
|
||||
|
||||
## Next-tick plan
|
||||
|
||||
Could either:
|
||||
1. Pick up one of the deferred follow-ups (ADR-106 or R6.1 are the strongest)
|
||||
2. Start consolidating into 00-summary.md (premature; loop has ~5h left)
|
||||
3. Add a meta-analysis / loop retrospective tick
|
||||
|
||||
Recommend (1) on next tick — ADR-106 has clear requirements from R15 + ADR-105.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Tick 15 — 2026-05-22 06:40 UTC
|
||||
|
||||
**Thread:** ADR-106 (DP-SGD + biometric primitive isolation)
|
||||
**Verdict:** Closes the two items deferred from ADR-105 (member-inference defence + primitive isolation enforcement). The federation protocol now has formally-bounded privacy.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `docs/adr/ADR-106-dp-sgd-and-primitive-isolation.md` — full ADR draft. Direct extension of ADR-105.
|
||||
|
||||
## Three-layer defence
|
||||
|
||||
| Layer | Mechanism | Defends against |
|
||||
|---|---|---|
|
||||
| 1 — Primitive Isolation | API-level tagging of on-device-only tensors (R15 binding list) | Exfiltration of biometric primitives via federation channel |
|
||||
| 2 — Gradient clipping | Per-sample L2 norm bound (Abadi 2016) | Bounds sensitivity of any single training sample |
|
||||
| 3 — Gaussian noise | Per-round N(0, σ²C²I) on aggregated delta | Formal (ε, δ)-DP via Moments Accountant |
|
||||
|
||||
## Privacy budget
|
||||
|
||||
Recommended (per Moments Accountant, δ=1e-5):
|
||||
|
||||
| Profile | σ | Rounds | Total ε | Use |
|
||||
|---|---:|---:|---:|---|
|
||||
| Conservative (medical-grade) | 1.5 | 50 | **2.0** | HIPAA-aligned |
|
||||
| Standard (typical RuView) | 1.0 | 100 | **5.0** | Most cogs |
|
||||
| Lenient | 0.5 | 100 | 8.0 | Below ε=10 community soft-bound |
|
||||
|
||||
## On-device-only primitive list (R15-binding)
|
||||
|
||||
7 ✅ "never transmit" primitives:
|
||||
- Raw CSI window
|
||||
- Gait stride frequency
|
||||
- Breathing rate (per-subject)
|
||||
- HRV rate signature
|
||||
- RCS frequency response curve
|
||||
- Limb timing vector
|
||||
- Per-subject embedding centroid
|
||||
|
||||
3 ⚠️ "transmit with mitigation":
|
||||
- MERIDIAN per-room centroid (aggregate, OK)
|
||||
- LoRA weight delta (DP-SGD applied)
|
||||
- Model logits during inference (never aggregated)
|
||||
|
||||
API surface enforces ✅ as compile-time error where possible.
|
||||
|
||||
## Implementation budget
|
||||
|
||||
Extends ADR-105's 500 LOC by **+300 LOC**: PrimitiveTag (60) + clipping (30) + DP noise (40) + Moments Accountant (120) + per-cog config schema (50). Total federation budget: **~800 LOC, 3-week effort**.
|
||||
|
||||
## Why this closes the privacy story
|
||||
|
||||
R3 + R14 + R15 + ADR-105 + ADR-106 = complete chain from physics (R6 forward model) → embeddings (R3) → personalised features (R14) → trained how (ADR-105) → defended how (R7) → privacy-bounded how (ADR-106).
|
||||
|
||||
The chain has:
|
||||
- A physics floor (R6/R1)
|
||||
- A spatial intelligence layer (R5/R7/R3)
|
||||
- A vertical roadmap (R10 wildlife + R11 maritime + R14 home)
|
||||
- Two negative results (R12 eigenshift, R13 contactless BP)
|
||||
- Two architectural decisions (ADR-105 + ADR-106)
|
||||
|
||||
The per-occupant feature surface (R14 V1/V2/V3) now has **formal (ε, δ) privacy backing**, not just policy.
|
||||
|
||||
## Composes with every prior thread
|
||||
|
||||
- R3: Layer 1 blocks per-subject embedding centroid transmission
|
||||
- R7 mincut: compatible with DP-noised deltas; operates on noised graph
|
||||
- R12/R13 negative results: informed the noise-vs-structure-detection design choice
|
||||
- R14: privacy framework now has formal (ε, δ) backing
|
||||
- R15: requirements basis = on-device-only primitive list made executable
|
||||
- ADR-105: 800 LOC budget, DP slots into step 4 of protocol
|
||||
|
||||
## Honest scope
|
||||
|
||||
- σ values are recommendations, not measurements (per-cog tuning needed)
|
||||
- (ε, δ)-DP is worst-case bound; auxiliary info changes the practical leakage
|
||||
- Moments Accountant is conservative (slightly over-estimates budget consumed)
|
||||
- Subject-level DP not formalised (household of 4 has K=4 subjects → sample-level DP doesn't fully capture)
|
||||
- Side-channel timing leaks out of scope (future ADR)
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-15.md`. No PROGRESS.md edit. Branch `research/sota-adr106-dp-sgd-primitive-isolation`.
|
||||
|
||||
## Remaining loop work (post ADR-106)
|
||||
|
||||
- R6.1 multi-scatterer Fresnel extension
|
||||
- R3 follow-up: physics-informed env_sig prediction (zero-shot cross-room)
|
||||
- R6.2 Fresnel-aware antenna placement CLI tool
|
||||
- ADR-107: cross-installation federation w/ secure aggregation (explicitly deferred from ADR-106)
|
||||
- Loop retrospective / 00-summary.md (premature — ~5h still on clock)
|
||||
|
||||
~5.3h to cron stop. **15 ticks landed. PROGRESS.md research agenda + 1 follow-up ADR closed.**
|
||||
@@ -0,0 +1,86 @@
|
||||
# Tick 16 — 2026-05-22 06:55 UTC
|
||||
|
||||
**Thread:** R6.2 (Fresnel-aware antenna placement) — first deferred follow-up
|
||||
**Verdict:** Working 2D placement search + CLI-shaped demo. Optimal placement is **93× better** than median random placement and infinite-× better than worst (which is 0% coverage). The current "stick it anywhere" deployment recipe leaves 50-100× of sensing on the table.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r6_2_antenna_placement.py` — pure-numpy 2D Fresnel-ellipse placement search.
|
||||
- `examples/research-sota/r6_2_placement_results.json` — best/median/worst on a 5×5 m bedroom benchmark.
|
||||
- `docs/research/sota-2026-05-22/R6_2-fresnel-antenna-placement.md` — research note with the method, benchmark, per-cog deployment recommendations, honest scope.
|
||||
|
||||
## Headline benchmark: 5×5 m bedroom
|
||||
|
||||
Target zones: bed (3 m²) + chair (0.64 m²). 2,900 antenna pairs evaluated at 2.4 GHz.
|
||||
|
||||
| Placement | Bed cov | Chair cov | **Total** |
|
||||
|---|---:|---:|---:|
|
||||
| Optimal (1.25, 0)→(4.75, 5) | 43.5% | 86.7% | **51.1%** |
|
||||
| Median | varies | varies | 0.5% |
|
||||
| Worst | varies | varies | **0.0%** |
|
||||
|
||||
**93× improvement** from median to optimal. The "diagonal across longest axis" recipe is the right shape for a bedroom-class room.
|
||||
|
||||
## Counter-intuitive insight: longer links cover more space
|
||||
|
||||
Fresnel envelope width = √(d·λ)/2 — **grows with link length**. So the optimal placement at 6.10 m (diagonal) has a 43.7 cm midpoint envelope vs 39.5 cm for a 5 m wall-parallel link. Counter to "shorter link = stronger signal", *longer* links cover *more space*, up to the link-budget gate (R10).
|
||||
|
||||
## Per-cog deployment recommendations surfaced
|
||||
|
||||
| Cog | Recommended placement |
|
||||
|---|---|
|
||||
| `cog-person-count` | Diagonal across longest axis |
|
||||
| `cog-pose-estimation` | Zone inside ~50% of midpoint envelope |
|
||||
| AETHER re-ID | Tx near doorway, Rx diagonal |
|
||||
| `cog-maritime-watch` | Vertical diagonal through cabin |
|
||||
| `cog-wildlife` (future) | Tx/Rx on opposite trees, threading clearing midline |
|
||||
|
||||
These improvements come from **physics, not algorithms** — no model retraining required.
|
||||
|
||||
## Why this is high-leverage
|
||||
|
||||
- Existing customers can re-mount their seeds today and get 10-100× better sensing without firmware/model changes.
|
||||
- Future cog installations get the placement guide for free (generated from cog target-zone schema).
|
||||
- Adds a **ship-ready CLI tool** (`wifi-densepose plan-antennas`) that any installer can use in 2 minutes.
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- 2D approximation (3D Fresnel ellipsoid is a half-day extension)
|
||||
- Free-space (real multipath adds +5-15% coverage outside envelope)
|
||||
- Rectangular target zones (real occupants don't occupy rectangles)
|
||||
- Single-pair only (multistatic N-anchor union is next, R6.2.2)
|
||||
- Perimeter-only candidates (no ceiling/tripod mounts)
|
||||
- No link-budget gate (R10 sets it; needed for large rooms)
|
||||
|
||||
## Composes with prior threads
|
||||
|
||||
- **R6** (Fresnel forward model) — direct 2D extension
|
||||
- **R1** (CRLB) — combined: placement × precision = full geometry budget
|
||||
- **R10** (foliage range) — sets the link-budget gate that R6.2 ignores
|
||||
- **R11** (maritime) — same recipe in steel-walled cabins
|
||||
- **R14** (empathic appliances) — placement determines whether the V1/V2/V3 verticals see the right occupant
|
||||
- **ADR-105 federation** — better placement → better local training → faster (ε, δ) convergence per ADR-106
|
||||
|
||||
## CLI shape (ship-ready)
|
||||
|
||||
```
|
||||
wifi-densepose plan-antennas \
|
||||
--room 5.0 5.0 \
|
||||
--target bed 1.5 0.5 2.0 1.5 \
|
||||
--target chair 3.5 3.5 0.8 0.8 \
|
||||
--freq-ghz 2.4
|
||||
```
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-16.md`. No PROGRESS.md edit. Branch `research/sota-r6.2-fresnel-antenna-placement`.
|
||||
|
||||
## Remaining loop work
|
||||
|
||||
- **R3 follow-up**: physics-informed env_sig prediction (uses R6 forward operator + room map → zero-shot cross-room transfer without labelled examples)
|
||||
- **R6.1**: multi-scatterer Fresnel forward model (volume integral over voxel grid)
|
||||
- **R6.2.1/.2/.3**: 3D placement, N-anchor multistatic, pose-trajectory target zones
|
||||
- **ADR-107**: cross-installation federation w/ secure aggregation
|
||||
- Loop retrospective / 00-summary.md (premature — ~5h still on clock)
|
||||
|
||||
~5.1h to cron stop. **16 ticks landed. PROGRESS.md research agenda + 2 ADRs + 1 deferred follow-up closed.**
|
||||
@@ -0,0 +1,84 @@
|
||||
# Tick 17 — 2026-05-22 07:09 UTC
|
||||
|
||||
**Thread:** R6.2.2 (N-anchor multistatic placement)
|
||||
**Verdict:** Practical knee at **N=5 anchors** for typical 5×5 m bedroom. Direct cost-optimisation conclusion + ADR-029 architectural update.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r6_2_2_multistatic_placement.py` — pure-numpy greedy multi-anchor placement search with random restarts.
|
||||
- `examples/research-sota/r6_2_2_multistatic_results.json` — full saturation curve for 5×5 m bedroom benchmark.
|
||||
- `docs/research/sota-2026-05-22/R6_2_2-multistatic-placement.md` — research note.
|
||||
|
||||
## Saturation curve (5×5 m bedroom, 3 target zones, 2.4 GHz)
|
||||
|
||||
| N anchors | Pairs | Coverage | Marginal |
|
||||
|---:|---:|---:|---:|
|
||||
| 2 | 1 | 35.7% | +35.7 pp |
|
||||
| 3 | 3 | 63.4% | +27.6 pp |
|
||||
| 4 | 6 | 86.2% | +22.8 pp |
|
||||
| **5** | **10** | **96.8%** | **+10.6 pp** ← knee |
|
||||
| 6 | 15 | 100% | +3.2 pp |
|
||||
| 7+ | 21+ | 100% | +0.0 pp |
|
||||
|
||||
**Knee at N=5** — past this, diminishing returns.
|
||||
|
||||
## Three regimes surfaced
|
||||
|
||||
| Use case | Anchors | Coverage |
|
||||
|---|---:|---:|
|
||||
| Single-feature (presence only) | 2-3 | 36-63% |
|
||||
| Multi-feature (pose, vitals, count) | **4-5** | 86-97% |
|
||||
| Mission-critical (medical, security) | 6 | 100% |
|
||||
| Beyond 6 | wasted | 100% (no gain) |
|
||||
|
||||
## Cost-optimisation conclusion
|
||||
|
||||
Cognitum Seed BOM is $9-15. The +$9-15 from 4→5 anchors buys +10.6 pp coverage. The same cost from 5→6 buys only +3.2 pp. **Consumer recommendation: 5 anchors hits the knee.** Commercial / medical: 6.
|
||||
|
||||
## Convenient numerology
|
||||
|
||||
**N=5 happens to also satisfy three other constraints simultaneously:**
|
||||
|
||||
1. **R7 multi-link mincut**: needs N ≥ 4 to detect single-anchor compromise
|
||||
2. **ADR-105 federation Krum**: f=1 byzantine tolerance requires K ≥ 5
|
||||
3. **R6.2.2 coverage knee**: 5 anchors hits practical saturation
|
||||
|
||||
These three constraints all bound by similar inverse-square-of-geometry scaling, so the alignment is probably not coincidental — but it's a useful fact for the architectural roadmap.
|
||||
|
||||
## ADR-029 recommendation update
|
||||
|
||||
ADR-029 (multistatic sensing) didn't specify anchor counts. R6.2.2 fills the gap:
|
||||
|
||||
> **Recommended anchor count: 5 for typical 5×5 m room.** 4 anchors gives 86% coverage (good for many use cases); 6 anchors gives 100% but is over-provisioned past the knee.
|
||||
|
||||
## Composes with prior threads
|
||||
|
||||
- **R6 / R6.2**: direct generalisation; greedy expansion to N anchors
|
||||
- **R7**: needs N ≥ 4 for multi-link adversarial detection; N=5 satisfies
|
||||
- **R1**: combined with R6.2.2 = full sensing geometry budget
|
||||
- **ADR-029**: architectural recommendation now has a number
|
||||
- **ADR-105**: Krum byzantine bound f < (K-2)/2 → K=5 = f=1 (matches R7 single-attacker case)
|
||||
- **R10**: wildlife corridors will have different saturation (more anchors for length, fewer for width)
|
||||
- **R11**: maritime cabins likely saturate earlier (N=3-4)
|
||||
- **R14**: V1/V2/V3 verticals all need ≥86% coverage = N=4 minimum
|
||||
|
||||
## Honest scope
|
||||
|
||||
- Single geometry tested (5×5 m bedroom). Other rooms have different knees.
|
||||
- 2D still (R6.2.1 = 3D ceiling/floor mounts not yet built).
|
||||
- Free-space (multipath probably adds +5-15% beyond Fresnel-only).
|
||||
- Greedy + 8 restarts → 1-2 pp shy of global optimum at most.
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-17.md`. No PROGRESS.md edit. Branch `research/sota-r6.2.2-multistatic-placement`.
|
||||
|
||||
## Remaining work
|
||||
|
||||
- **R3 follow-up**: physics-informed env_sig prediction (zero-shot cross-room via R6 forward operator + room map)
|
||||
- **R6.1**: multi-scatterer additive forward model
|
||||
- **R6.2.1**: 3D ceiling/floor placement
|
||||
- **R6.2.3**: pose-trajectory-aware zones (needs AETHER + R3 data)
|
||||
- **ADR-107**: cross-installation federation w/ secure aggregation
|
||||
|
||||
~4.9h to cron stop. **17 ticks landed. 2 ADRs + 2 deferred follow-ups closed.**
|
||||
@@ -0,0 +1,46 @@
|
||||
# Tick 6 — 2026-05-22 03:55 UTC
|
||||
|
||||
**Thread:** R10 (through-foliage wildlife sensing)
|
||||
**Verdict:** Physics feasibility + per-species gait taxonomy + bounded range estimates.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r10_foliage_attenuation.py` — ITU-R P.833-9 vegetation attenuation model + ESP32-S3 link-budget solver + per-species gait band table.
|
||||
- `examples/research-sota/r10_foliage_results.json` — full machine-readable numbers.
|
||||
- `docs/research/sota-2026-05-22/R10-through-foliage-wildlife.md` — research note with range table, gait taxonomy, vertical applications, honest scope.
|
||||
|
||||
## Headline numbers (this tick)
|
||||
|
||||
**Max ESP32-S3 sensing range through foliage (121 dB link budget, 10 dB SNR margin):**
|
||||
|
||||
| Frequency | Sparse | Moderate | Dense |
|
||||
|---|---:|---:|---:|
|
||||
| 2.4 GHz | **99.6 m** | 12.0 m | 4.1 m |
|
||||
| 5 GHz | 19.9 m | 5.2 m | 2.1 m |
|
||||
|
||||
The 2.4 GHz / sparse cell (~100 m) is the practical sweet spot — **10× the spatial coverage of a camera trap** in matched conditions, always-on rather than PIR-triggered.
|
||||
|
||||
**Per-species gait taxonomy** (DSP-actionable):
|
||||
|
||||
- 0.5–1.5 Hz: bear, sloth, wild boar
|
||||
- 1.2–2.5 Hz: human walking
|
||||
- 1.5–3.5 Hz: elk, raccoon, wolf
|
||||
- 1.8–4.5 Hz: deer, fox
|
||||
- 4.0–15.0 Hz: squirrel, mouse, songbird
|
||||
|
||||
## 10-20 year verticals catalogued
|
||||
|
||||
- Endangered-species population census (replaces camera traps)
|
||||
- Wildlife corridor verification
|
||||
- Invasive-species early warning
|
||||
- Poaching detection (human gait band well-separated from wildlife)
|
||||
- Livestock-on-rangeland tracking
|
||||
- Agricultural pest control
|
||||
|
||||
## Coordination
|
||||
|
||||
Tick-6 used the same `ticks/tick-N.md` convention to avoid PROGRESS.md races.
|
||||
|
||||
## Major out-of-tick news (horizon-tracker just completed)
|
||||
|
||||
Horizon-tracker agent `a62cf580…` reported full M1–M7 completion: 6 MCP tools, 6 CLI subcommands, ADR-104, 16 passing tests. Final summary in `HORIZON.md`. The MCP/CLI track is structurally complete; npm publish handoff is documented for the user.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tick 7 — 2026-05-22 05:14 UTC
|
||||
|
||||
**Thread:** R14 (empathic appliances)
|
||||
**Verdict:** Speculative 10-20y vision note with concrete vertical sketches, ethical framework, privacy threat model, and infrastructure-gap inventory.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `docs/research/sota-2026-05-22/R14-empathic-appliances.md` — research note covering:
|
||||
- Three concrete vertical sketches (stress-responsive lighting / adaptive HVAC / attention-respecting conversational appliances) with timelines (5y / 10y / 15y).
|
||||
- **Infrastructure inventory** — which existing RuView components map to which empathic-appliance category. 5 ✅ in-repo, 4 ⚠️/❌ to-build.
|
||||
- Ethical framework (opt-in-by-default, data-stays-on-device, override-one-tap) committed in writing as constraints any product must honour.
|
||||
- 6-row privacy threat model with concrete mitigations.
|
||||
- Honest scope: lab-condition literature doesn't validate real-home generalisation; no per-occupant identity yet; appliance integration half is out of repo scope.
|
||||
|
||||
## Why this matters for the loop
|
||||
|
||||
R14 is the **first explicitly speculative** vision thread (R5/R7/R8/R9/R10/R12 were all experimental or physics). It catalogues the **product-level surface area** for the longest-horizon items, which informs:
|
||||
|
||||
- Which sensing primitives we should invest in next (per-room baseline learner is the clearest gap).
|
||||
- Which ADRs to write next (consent/override is a separate ADR — possibly ADR-105).
|
||||
- Which MCP tools to add to `@ruv/ruview-mcp` (the deferred `ruview_vitals_subscribe` is now the highest-leverage next addition per ADR-104 + R14).
|
||||
|
||||
## Connections established
|
||||
|
||||
- R14 explicitly cross-links to R5 (saliency is task-specific), R8 (CSI required, not RSSI), R7 (adversarial poisoning defence), ADR-104 (hands-off appliance API surface), ADR-103 (per-room occupancy gate).
|
||||
- The infrastructure-gap inventory (5 in-repo, 4 to-build) is a useful artefact for any future product roadmap discussion.
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-7.md` convention. No PROGRESS.md touch.
|
||||
|
||||
## Major notes from prior tick
|
||||
|
||||
R10 (PR not auto-created due to bash flow issue) ended up committed directly to main and pushed in this tick. Future-tick reminder: always check `git branch --show-current` before `git commit`. The cron prompt assumes branch hygiene that the bash plumbing sometimes breaks under back-to-back tick pressure.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Tick 8 — 2026-05-22 05:25 UTC
|
||||
|
||||
**Thread:** R6 (Fresnel forward model)
|
||||
**Verdict:** Working closed-form forward model + numpy demo. Bedrock physics that the entire `wifi-densepose-signal` DSP pipeline implicitly assumes is now explicit.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r6_fresnel_zone.py` — pure-numpy Fresnel-zone radius + per-subcarrier phase prediction. Four canonical scenarios over 802.11n/ac 20 MHz channels (52 subcarriers, 312.5 kHz spacing).
|
||||
- `examples/research-sota/r6_fresnel_results.json` — machine-readable predictions.
|
||||
- `docs/research/sota-2026-05-22/R6-fresnel-forward-model.md` — research note with the model, the demo headline numbers, what it gives each existing workspace module, R12's revision path with a basis, R10 range correction, honest scope.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
**First Fresnel envelope (the "channel of maximum sensitivity"):**
|
||||
|
||||
| Link | 2.4 GHz @ midpoint | 5 GHz @ midpoint |
|
||||
|---|---:|---:|
|
||||
| 2 m | 25 cm | 17 cm |
|
||||
| 5 m | **40 cm** | 27 cm |
|
||||
| 10 m | 56 cm | 39 cm |
|
||||
|
||||
A typical bedroom 5 m WiFi link has a ~40 cm wide ellipsoid where human occupancy dominates the CSI. Outside that, you're picking up only diffracted edge contributions.
|
||||
|
||||
**Per-subcarrier phase predictions** confirm what R5 measured experimentally: inside zone-1, phase spread across 20 MHz is < 0.5° (band-flat); outside zone-1, spread grows to 15° (band-dispersed). R5's band-spread top-subcarriers are now physically explained, not just measured.
|
||||
|
||||
## Why this matters for the research loop
|
||||
|
||||
Three earlier threads were forced to **bootstrap from data** because no forward model existed:
|
||||
|
||||
- **R7** (mincut adversarial) — could only detect inconsistency, not predict expected. With R6, "physically inconsistent" has a precise definition: residual ≥ noise floor on all links simultaneously.
|
||||
- **R10** (foliage range) — used FSPL + ITU foliage but ignored Fresnel-zone obstruction. R6 says the 100 m sparse-foliage range should be retracted to ~70 m (zone obstruction adds ~30% discount).
|
||||
- **R12** (eigenshift, negative result) — failed because SVD spectrum loses spatial structure. R6's forward operator is the basis that R12's PABS revision needs.
|
||||
|
||||
## Coordination
|
||||
|
||||
Tick-8 via `ticks/tick-8.md`. No PROGRESS.md edit. Branch `research/sota-r6-fresnel-forward`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R1 (ToA multistatic), R2 (room field model — partly subsumed by R6+R12 path), R3 (cross-room re-ID), R4 (federated learning), R11 (through-bulkhead maritime), R13 (contactless BP), R15 (RF biometric across rooms).
|
||||
|
||||
~6.6h to cron stop (12:00 UTC).
|
||||
@@ -0,0 +1,51 @@
|
||||
# Tick 9 — 2026-05-22 05:34 UTC
|
||||
|
||||
**Thread:** R1 (ToA multistatic CRLB)
|
||||
**Verdict:** Quantitative precision floor for WiFi multistatic localisation. Phase ranging beats ToA ranging by **238×** at WiFi bandwidths — but only after solving the integer-ambiguity (cycle-slip) problem.
|
||||
|
||||
## What shipped
|
||||
|
||||
- `examples/research-sota/r1_toa_crlb.py` — pure-numpy CRLB grid over bandwidth/SNR + phase-noise-vs-precision grid + 4-anchor multistatic geometric dilution.
|
||||
- `examples/research-sota/r1_toa_crlb_results.json` — machine-readable predictions.
|
||||
- `docs/research/sota-2026-05-22/R1-toa-crlb.md` — research note with the math, the headline numbers, the integer-ambiguity catch, ADR-029 architectural implication.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
**20 MHz HT20 channel, 20 dB SNR (ESP32-S3 typical):**
|
||||
|
||||
| Method | Single-shot | 100x averaged |
|
||||
|---|---:|---:|
|
||||
| ToA CRLB | 0.413 m | 0.041 m |
|
||||
| Phase (single-subcarrier, 5° noise) | **1.73 mm** | 0.17 mm |
|
||||
| **Phase advantage** | 238× | 240× |
|
||||
|
||||
**4-anchor multistatic 5×5 m room, GDOP 1.5:**
|
||||
|
||||
| Method | Position precision |
|
||||
|---|---:|
|
||||
| ToA | 25.3 cm |
|
||||
| Phase (ambiguity-resolved) | 1.06 mm |
|
||||
|
||||
## Why this matters for the loop
|
||||
|
||||
1. **Bounds what's physically possible** for any WiFi-localisation feature. 25 cm position precision via ToA-only is the room-pose-quality floor; 1 mm via phase is RTK-quality but ambiguity-resolution-bound.
|
||||
2. **Strongest architectural lever for ADR-029**: explicit ToA-then-phase pipeline (≤2× from CRLB by Kay's theory) probably outperforms the current learning-based attention. Provable optimality vs flexibility tradeoff.
|
||||
3. **Composes cleanly with R6**: spatial envelope (R6) × ranging precision (R1) = full multistatic geometry budget. They are independent and additive.
|
||||
4. **Closes a gap R10 created**: foliage drops SNR, which directly worsens ToA CRLB. A 50 m foliage link at 5 dB SNR → ~1 m ToA precision. The 100 m sparse-foliage number from R10 is **not** the same as 100 m localisable.
|
||||
|
||||
## Honest scope landed
|
||||
|
||||
- CRLB is a lower bound; real estimators sit 1-2× above it
|
||||
- 5° phase noise assumes `phase_align.rs` is applied; raw ESP32 is 60-180°
|
||||
- Multipath degrades CRLB by 2-5× even with MUSIC super-resolution
|
||||
- Cycle-slip is unsolved at the WiFi bandwidth level without multi-subcarrier wide-lane unwrap
|
||||
|
||||
## Coordination
|
||||
|
||||
`ticks/tick-9.md`. No PROGRESS.md edit. Branch `research/sota-r1-toa-crlb`.
|
||||
|
||||
## Remaining threads
|
||||
|
||||
R2 (subsumed by R6+R12), R3 (cross-room re-ID), R4 (federated learning), R11 (through-bulkhead maritime), R13 (contactless BP), R15 (RF biometric).
|
||||
|
||||
~6.4h to cron stop. 9 threads landed.
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R10 — through-foliage WiFi attenuation curves (ITU-R P.833 + per-species gait).
|
||||
|
||||
See docs/research/sota-2026-05-22/R10-through-foliage-wildlife.md.
|
||||
|
||||
Plots the ITU-R P.833 vegetation specific attenuation A_v over distance
|
||||
for 2.4 GHz and 5 GHz CSI bands across three foliage densities. Compares
|
||||
to a 1×1 SISO ESP32-S3's link budget to derive a maximum sensing range.
|
||||
Pure NumPy, no plotting libs — emits a JSON file with the curves so a
|
||||
downstream consumer can render them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
|
||||
def itu_p833_attenuation(freq_ghz: float, distance_m: float, foliage_density: str) -> float:
|
||||
"""ITU-R P.833 specific-attenuation model for in-foliage propagation.
|
||||
|
||||
Simplified parameterisation:
|
||||
A_max = max attenuation through dense canopy (dB)
|
||||
gamma = decay coefficient (1/m)
|
||||
|
||||
A_v(d) = A_max * (1 - exp(-gamma * d))
|
||||
|
||||
Realistic A_max / gamma per density (calibrated against in-leaf summer
|
||||
deciduous from ITU-R P.833-9 Table 1 + simulation studies):
|
||||
sparse (orchard, savanna) A_max=20 dB, gamma=0.10
|
||||
moderate (suburban tree cover) A_max=35 dB, gamma=0.20
|
||||
dense (rainforest canopy) A_max=50 dB, gamma=0.35
|
||||
The constant gets multiplied by sqrt(f_GHz / 1) for frequency scaling.
|
||||
"""
|
||||
params = {
|
||||
"sparse": (20.0, 0.10),
|
||||
"moderate": (35.0, 0.20),
|
||||
"dense": (50.0, 0.35),
|
||||
}
|
||||
a_max, gamma = params[foliage_density]
|
||||
freq_scaling = np.sqrt(freq_ghz) # higher freq → more attenuation
|
||||
return a_max * freq_scaling * (1.0 - np.exp(-gamma * distance_m))
|
||||
|
||||
|
||||
def esp32_link_budget(freq_ghz: float) -> dict[str, float]:
|
||||
"""ESP32-S3 1x1 SISO link budget at 2.4 / 5 GHz.
|
||||
|
||||
Numbers from Espressif ESP32-S3 datasheet + standard WiFi specs:
|
||||
Tx power (max regulatory) +20 dBm (100 mW, FCC Part 15)
|
||||
Tx antenna gain (PCB) +2 dBi
|
||||
Rx antenna gain (PCB) +2 dBi
|
||||
Rx sensitivity (HT20, MCS0) -97 dBm
|
||||
Total link budget (free-space) = (20 + 2 + 2) - (-97) = 121 dB
|
||||
"""
|
||||
return {
|
||||
"tx_power_dbm": 20.0,
|
||||
"tx_gain_dbi": 2.0,
|
||||
"rx_gain_dbi": 2.0,
|
||||
"rx_sensitivity_dbm": -97.0,
|
||||
"link_budget_db": 121.0,
|
||||
}
|
||||
|
||||
|
||||
def fspl_db(freq_ghz: float, distance_m: float) -> float:
|
||||
"""Free-space path loss in dB. FSPL = 20·log10(4π·d/λ)
|
||||
With f in GHz + d in m: FSPL = 32.45 + 20·log10(f) + 20·log10(d)"""
|
||||
if distance_m <= 0: return 0.0
|
||||
return 32.45 + 20 * np.log10(freq_ghz) + 20 * np.log10(distance_m)
|
||||
|
||||
|
||||
def max_sensing_range(freq_ghz: float, foliage_density: str, snr_margin_db: float = 10.0) -> float:
|
||||
"""Distance at which FSPL + foliage_attenuation = link_budget - snr_margin.
|
||||
Numerical solve by binary search. Returns metres."""
|
||||
lb = esp32_link_budget(freq_ghz)
|
||||
budget = lb["link_budget_db"] - snr_margin_db # require SNR > snr_margin
|
||||
lo, hi = 0.1, 1000.0
|
||||
for _ in range(60):
|
||||
mid = (lo + hi) / 2
|
||||
total_loss = fspl_db(freq_ghz, mid) + itu_p833_attenuation(freq_ghz, mid, foliage_density)
|
||||
if total_loss < budget:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return (lo + hi) / 2
|
||||
|
||||
|
||||
def gait_frequency_band(species: str) -> dict[str, float]:
|
||||
"""Approximate gait stride-frequency bands per species class, from
|
||||
biomechanics literature (Schmitt 2003, Gambaryan 1974, Heglund 1988).
|
||||
These are the temporal frequencies a CSI motion-band filter would
|
||||
target — for context, human walking is ~1.7 Hz, jogging ~2.5 Hz."""
|
||||
bands = {
|
||||
"human-walking": {"min_hz": 1.2, "max_hz": 2.5},
|
||||
"deer": {"min_hz": 1.8, "max_hz": 4.0},
|
||||
"wolf": {"min_hz": 1.5, "max_hz": 3.5},
|
||||
"bear": {"min_hz": 0.5, "max_hz": 1.5},
|
||||
"fox": {"min_hz": 2.0, "max_hz": 4.5},
|
||||
"squirrel": {"min_hz": 4.0, "max_hz": 10.0},
|
||||
"mouse": {"min_hz": 5.0, "max_hz": 15.0},
|
||||
"raccoon": {"min_hz": 1.5, "max_hz": 3.5},
|
||||
"wild-boar": {"min_hz": 1.0, "max_hz": 2.5},
|
||||
"elk": {"min_hz": 1.5, "max_hz": 3.0},
|
||||
}
|
||||
return bands.get(species, {"min_hz": 0.5, "max_hz": 10.0})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r10_foliage_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
distances = np.array([1, 2, 5, 10, 20, 50, 100, 200], dtype=np.float64)
|
||||
freqs = [2.4, 5.0]
|
||||
densities = ["sparse", "moderate", "dense"]
|
||||
|
||||
curves = {}
|
||||
for freq in freqs:
|
||||
curves[str(freq)] = {}
|
||||
for density in densities:
|
||||
atts = [float(itu_p833_attenuation(freq, d, density)) for d in distances]
|
||||
fspls = [float(fspl_db(freq, d)) for d in distances]
|
||||
curves[str(freq)][density] = {
|
||||
"distance_m": distances.tolist(),
|
||||
"foliage_attenuation_db": atts,
|
||||
"fspl_db": fspls,
|
||||
"total_loss_db": [a + f for a, f in zip(atts, fspls)],
|
||||
}
|
||||
|
||||
# Max sensing range per (freq, density)
|
||||
max_ranges = {}
|
||||
for freq in freqs:
|
||||
max_ranges[str(freq)] = {d: float(max_sensing_range(freq, d)) for d in densities}
|
||||
|
||||
species_gaits = {s: gait_frequency_band(s) for s in
|
||||
["human-walking", "deer", "wolf", "bear", "fox",
|
||||
"squirrel", "mouse", "raccoon", "wild-boar", "elk"]}
|
||||
|
||||
out = {
|
||||
"model": "ITU-R P.833-9 specific-attenuation + free-space-path-loss",
|
||||
"link_budget": esp32_link_budget(2.4),
|
||||
"snr_margin_db": 10.0,
|
||||
"curves": curves,
|
||||
"max_sensing_range_m": max_ranges,
|
||||
"species_gait_bands_hz": species_gaits,
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print("=== ESP32-S3 through-foliage sensing range (link budget 121 dB, 10 dB SNR margin) ===")
|
||||
print(f"{'freq (GHz)':>10} {'sparse':>9} {'moderate':>11} {'dense':>9}")
|
||||
for freq in freqs:
|
||||
row = f"{freq:>10.1f} "
|
||||
for d in densities:
|
||||
row += f"{max_ranges[str(freq)][d]:>9.1f}m " if d != "moderate" else f"{max_ranges[str(freq)][d]:>11.1f}m "
|
||||
print(row)
|
||||
print()
|
||||
print("=== Per-species gait frequency bands (Hz) ===")
|
||||
for s, b in species_gaits.items():
|
||||
print(f" {s:<16} {b['min_hz']:.1f} - {b['max_hz']:.1f} Hz")
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,323 @@
|
||||
{
|
||||
"model": "ITU-R P.833-9 specific-attenuation + free-space-path-loss",
|
||||
"link_budget": {
|
||||
"tx_power_dbm": 20.0,
|
||||
"tx_gain_dbi": 2.0,
|
||||
"rx_gain_dbi": 2.0,
|
||||
"rx_sensitivity_dbm": -97.0,
|
||||
"link_budget_db": 121.0
|
||||
},
|
||||
"snr_margin_db": 10.0,
|
||||
"curves": {
|
||||
"2.4": {
|
||||
"sparse": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
2.948504761030617,
|
||||
5.616422196068292,
|
||||
12.191201617409519,
|
||||
19.585539177106636,
|
||||
26.790656384622018,
|
||||
30.775099117538645,
|
||||
30.982460104284222,
|
||||
30.983866705796828
|
||||
],
|
||||
"fspl_db": [
|
||||
40.05422483423212,
|
||||
46.07482474751175,
|
||||
54.0336249209525,
|
||||
60.05422483423212,
|
||||
66.07482474751174,
|
||||
74.03362492095249,
|
||||
80.05422483423212,
|
||||
86.07482474751174
|
||||
],
|
||||
"total_loss_db": [
|
||||
43.00272959526274,
|
||||
51.69124694358004,
|
||||
66.22482653836201,
|
||||
79.63976401133876,
|
||||
92.86548113213377,
|
||||
104.80872403849114,
|
||||
111.03668493851634,
|
||||
117.05869145330857
|
||||
]
|
||||
},
|
||||
"moderate": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
9.828738843119512,
|
||||
17.875829597953555,
|
||||
34.274693559936615,
|
||||
46.88364867308853,
|
||||
53.228660545426806,
|
||||
54.21930518249739,
|
||||
54.22176673514445,
|
||||
54.22176684690384
|
||||
],
|
||||
"fspl_db": [
|
||||
40.05422483423212,
|
||||
46.07482474751175,
|
||||
54.0336249209525,
|
||||
60.05422483423212,
|
||||
66.07482474751174,
|
||||
74.03362492095249,
|
||||
80.05422483423212,
|
||||
86.07482474751174
|
||||
],
|
||||
"total_loss_db": [
|
||||
49.88296367735163,
|
||||
63.9506543454653,
|
||||
88.30831848088911,
|
||||
106.93787350732066,
|
||||
119.30348529293855,
|
||||
128.2529301034499,
|
||||
134.27599156937657,
|
||||
140.2965915944156
|
||||
]
|
||||
},
|
||||
"dense": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
22.874762209122434,
|
||||
38.99433469303874,
|
||||
63.99919514438107,
|
||||
75.12058766227474,
|
||||
77.38903285082235,
|
||||
77.45966497913676,
|
||||
77.45966692414828,
|
||||
77.45966692414834
|
||||
],
|
||||
"fspl_db": [
|
||||
40.05422483423212,
|
||||
46.07482474751175,
|
||||
54.0336249209525,
|
||||
60.05422483423212,
|
||||
66.07482474751174,
|
||||
74.03362492095249,
|
||||
80.05422483423212,
|
||||
86.07482474751174
|
||||
],
|
||||
"total_loss_db": [
|
||||
62.92898704335455,
|
||||
85.0691594405505,
|
||||
118.03282006533357,
|
||||
135.17481249650686,
|
||||
143.46385759833407,
|
||||
151.49328990008925,
|
||||
157.5138917583804,
|
||||
163.53449167166008
|
||||
]
|
||||
}
|
||||
},
|
||||
"5.0": {
|
||||
"sparse": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
4.255800043719799,
|
||||
8.106607166956543,
|
||||
17.59648383889097,
|
||||
28.269290790316198,
|
||||
38.66898168857072,
|
||||
44.42002939962088,
|
||||
44.719329203413345,
|
||||
44.7213594578182
|
||||
],
|
||||
"fspl_db": [
|
||||
46.42940008672038,
|
||||
52.45,
|
||||
60.40880017344075,
|
||||
66.42940008672038,
|
||||
72.45,
|
||||
80.40880017344075,
|
||||
86.42940008672038,
|
||||
92.45
|
||||
],
|
||||
"total_loss_db": [
|
||||
50.68520013044018,
|
||||
60.556607166956546,
|
||||
78.00528401233171,
|
||||
94.69869087703657,
|
||||
111.11898168857073,
|
||||
124.82882957306163,
|
||||
131.14872929013373,
|
||||
137.1713594578182
|
||||
]
|
||||
},
|
||||
"moderate": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
14.186562542173952,
|
||||
25.801537575915912,
|
||||
49.471258883053345,
|
||||
67.67071795499876,
|
||||
76.82895373626346,
|
||||
78.25882610597336,
|
||||
78.26237905118187,
|
||||
78.26237921249265
|
||||
],
|
||||
"fspl_db": [
|
||||
46.42940008672038,
|
||||
52.45,
|
||||
60.40880017344075,
|
||||
66.42940008672038,
|
||||
72.45,
|
||||
80.40880017344075,
|
||||
86.42940008672038,
|
||||
92.45
|
||||
],
|
||||
"total_loss_db": [
|
||||
60.61596262889433,
|
||||
78.25153757591592,
|
||||
109.8800590564941,
|
||||
134.10011804171916,
|
||||
149.27895373626347,
|
||||
158.6676262794141,
|
||||
164.69177913790224,
|
||||
170.71237921249264
|
||||
]
|
||||
},
|
||||
"dense": {
|
||||
"distance_m": [
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
20.0,
|
||||
50.0,
|
||||
100.0,
|
||||
200.0
|
||||
],
|
||||
"foliage_attenuation_db": [
|
||||
33.01687529771379,
|
||||
56.2834740797407,
|
||||
92.3748813613195,
|
||||
108.42722877124301,
|
||||
111.70144737186769,
|
||||
111.80339606760708,
|
||||
111.80339887498941,
|
||||
111.80339887498948
|
||||
],
|
||||
"fspl_db": [
|
||||
46.42940008672038,
|
||||
52.45,
|
||||
60.40880017344075,
|
||||
66.42940008672038,
|
||||
72.45,
|
||||
80.40880017344075,
|
||||
86.42940008672038,
|
||||
92.45
|
||||
],
|
||||
"total_loss_db": [
|
||||
79.44627538443416,
|
||||
108.7334740797407,
|
||||
152.78368153476026,
|
||||
174.8566288579634,
|
||||
184.15144737186768,
|
||||
192.21219624104782,
|
||||
198.2327989617098,
|
||||
204.2533988749895
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"max_sensing_range_m": {
|
||||
"2.4": {
|
||||
"sparse": 99.57923271861807,
|
||||
"moderate": 12.034801111889358,
|
||||
"dense": 4.0622989555207685
|
||||
},
|
||||
"5.0": {
|
||||
"sparse": 19.88605854664752,
|
||||
"moderate": 5.151689752409455,
|
||||
"dense": 2.097082570943368
|
||||
}
|
||||
},
|
||||
"species_gait_bands_hz": {
|
||||
"human-walking": {
|
||||
"min_hz": 1.2,
|
||||
"max_hz": 2.5
|
||||
},
|
||||
"deer": {
|
||||
"min_hz": 1.8,
|
||||
"max_hz": 4.0
|
||||
},
|
||||
"wolf": {
|
||||
"min_hz": 1.5,
|
||||
"max_hz": 3.5
|
||||
},
|
||||
"bear": {
|
||||
"min_hz": 0.5,
|
||||
"max_hz": 1.5
|
||||
},
|
||||
"fox": {
|
||||
"min_hz": 2.0,
|
||||
"max_hz": 4.5
|
||||
},
|
||||
"squirrel": {
|
||||
"min_hz": 4.0,
|
||||
"max_hz": 10.0
|
||||
},
|
||||
"mouse": {
|
||||
"min_hz": 5.0,
|
||||
"max_hz": 15.0
|
||||
},
|
||||
"raccoon": {
|
||||
"min_hz": 1.5,
|
||||
"max_hz": 3.5
|
||||
},
|
||||
"wild-boar": {
|
||||
"min_hz": 1.0,
|
||||
"max_hz": 2.5
|
||||
},
|
||||
"elk": {
|
||||
"min_hz": 1.5,
|
||||
"max_hz": 3.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R11 — Maritime / through-bulkhead RF propagation.
|
||||
|
||||
See docs/research/sota-2026-05-22/R11-maritime-sensing.md.
|
||||
|
||||
Computes:
|
||||
- Steel bulkhead RF attenuation (skin depth) at WiFi bands
|
||||
- Seam-leakage diffraction loss
|
||||
- Saltwater attenuation (man-overboard surface sensing)
|
||||
- Composite link budget for three maritime scenarios
|
||||
|
||||
Pure NumPy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8
|
||||
MU_0 = 4 * np.pi * 1e-7 # H/m
|
||||
EPS_0 = 8.854e-12 # F/m
|
||||
|
||||
# Material properties (typical values)
|
||||
STEEL_SIGMA = 1.0e7 # S/m (mild steel conductivity)
|
||||
SALTWATER_SIGMA = 4.8 # S/m (35 ppt at 20 deg C)
|
||||
SALTWATER_EPSR = 81.0 # relative permittivity
|
||||
|
||||
|
||||
def skin_depth_m(freq_ghz: float, sigma: float, mu_r: float = 1.0) -> float:
|
||||
"""Classical skin depth: delta = 1 / sqrt(pi * f * mu * sigma)."""
|
||||
f = freq_ghz * 1e9
|
||||
return 1.0 / np.sqrt(np.pi * f * MU_0 * mu_r * sigma)
|
||||
|
||||
|
||||
def bulk_attenuation_db_per_mm(freq_ghz: float, sigma: float, mu_r: float = 1.0) -> float:
|
||||
"""Per-mm attenuation through bulk conductor."""
|
||||
delta = skin_depth_m(freq_ghz, sigma, mu_r)
|
||||
# Field decays as exp(-x/delta), power as exp(-2x/delta)
|
||||
# In dB per metre: 20/(delta*ln(10)) = 8.686/delta
|
||||
return 8.686 / delta / 1000 # divide by 1000 to get per-mm
|
||||
|
||||
|
||||
def saltwater_attenuation_db_per_m(freq_ghz: float) -> float:
|
||||
"""Saltwater attenuation per metre via lossy-dielectric model.
|
||||
alpha = (omega/c) * Im(sqrt(eps_r - j*sigma/(omega*eps_0)))
|
||||
Returns dB/m."""
|
||||
omega = 2 * np.pi * freq_ghz * 1e9
|
||||
eps_complex = SALTWATER_EPSR - 1j * SALTWATER_SIGMA / (omega * EPS_0)
|
||||
n_complex = np.sqrt(eps_complex)
|
||||
# Principal sqrt of (a - jb), b>0, has negative imag part. The wave
|
||||
# attenuation coefficient is alpha = omega/c * |Im(n)| -- take abs().
|
||||
alpha = omega * abs(n_complex.imag) / C # Np/m
|
||||
return float(8.686 * alpha) # dB/m
|
||||
|
||||
|
||||
def seam_diffraction_loss_db(seam_width_mm: float, freq_ghz: float) -> float:
|
||||
"""Approximate diffraction loss through a narrow slot in a conductor.
|
||||
For slot width w << lambda, the slot acts as a high-pass filter:
|
||||
L_slot = 20 * log10(lambda / (2 * w)) when w < lambda/2
|
||||
0 when w >= lambda/2
|
||||
Crude but captures the 1st-order physics. Real slot antennas are more
|
||||
complex; for forensic 'how much leaks through the door seal' work
|
||||
this is the right scale."""
|
||||
lam_mm = (C / (freq_ghz * 1e9)) * 1000
|
||||
if seam_width_mm >= lam_mm / 2:
|
||||
return 0.0
|
||||
return max(0.0, 20 * np.log10(lam_mm / (2 * seam_width_mm)))
|
||||
|
||||
|
||||
def maritime_scenario(name: str, freq_ghz: float, bulkhead_mm: float,
|
||||
seam_mm: float, free_air_m: float,
|
||||
saltwater_m: float = 0.0) -> dict:
|
||||
"""Composite path loss for a maritime sensing scenario."""
|
||||
# Free-space loss
|
||||
fspl = 32.45 + 20 * np.log10(freq_ghz) + 20 * np.log10(max(0.1, free_air_m + 0.1))
|
||||
# Bulkhead loss (if any propagation through metal)
|
||||
bulk_loss = bulkhead_mm * bulk_attenuation_db_per_mm(freq_ghz, STEEL_SIGMA)
|
||||
# Seam diffraction (alternative path)
|
||||
seam_loss = seam_diffraction_loss_db(seam_mm, freq_ghz) if seam_mm > 0 else 999.0
|
||||
# Saltwater loss
|
||||
water_loss = saltwater_m * saltwater_attenuation_db_per_m(freq_ghz)
|
||||
# The actual propagation path takes whichever is lower (bulk OR seam)
|
||||
best_metal_path = min(bulk_loss, seam_loss)
|
||||
total = fspl + best_metal_path + water_loss
|
||||
return {
|
||||
"scenario": name,
|
||||
"freq_ghz": freq_ghz,
|
||||
"fspl_db": fspl,
|
||||
"bulk_loss_db": bulk_loss,
|
||||
"seam_loss_db": seam_loss,
|
||||
"metal_path_used": "seam" if seam_loss < bulk_loss else "bulk",
|
||||
"metal_path_loss_db": best_metal_path,
|
||||
"saltwater_loss_db": water_loss,
|
||||
"total_loss_db": total,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": 121 - total - 10, # 10 dB SNR margin for DSP
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r11_maritime_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 1. Skin depth + per-mm attenuation
|
||||
materials_grid = {}
|
||||
for f in [2.4, 5.0]:
|
||||
delta_steel_um = skin_depth_m(f, STEEL_SIGMA) * 1e6 # micrometres
|
||||
att_steel = bulk_attenuation_db_per_mm(f, STEEL_SIGMA)
|
||||
att_water = saltwater_attenuation_db_per_m(f)
|
||||
materials_grid[f"{f}_GHz"] = {
|
||||
"steel_skin_depth_um": delta_steel_um,
|
||||
"steel_atten_dB_per_mm": att_steel,
|
||||
"saltwater_atten_dB_per_m": att_water,
|
||||
}
|
||||
|
||||
# 2. Three maritime scenarios
|
||||
scenarios = [
|
||||
maritime_scenario("man-overboard, surface-floating", 2.4,
|
||||
bulkhead_mm=0, seam_mm=0, free_air_m=200, saltwater_m=0),
|
||||
maritime_scenario("man-overboard, head 30 cm underwater", 2.4,
|
||||
bulkhead_mm=0, seam_mm=0, free_air_m=200, saltwater_m=0.3),
|
||||
maritime_scenario("crew vitals through 10 mm steel cabin door (closed)", 2.4,
|
||||
bulkhead_mm=10, seam_mm=0, free_air_m=3),
|
||||
maritime_scenario("crew vitals through cabin door (2 mm seam gap)", 2.4,
|
||||
bulkhead_mm=10, seam_mm=2, free_air_m=3),
|
||||
maritime_scenario("crew vitals through cabin door (5 mm seam gap)", 2.4,
|
||||
bulkhead_mm=10, seam_mm=5, free_air_m=3),
|
||||
maritime_scenario("container intrusion (steel cargo container, 2 mm walls, 30 mm vent slot)", 2.4,
|
||||
bulkhead_mm=2, seam_mm=30, free_air_m=10),
|
||||
maritime_scenario("through hull (submarine, 30 mm pressure hull)", 2.4,
|
||||
bulkhead_mm=30, seam_mm=0, free_air_m=1),
|
||||
]
|
||||
|
||||
out = {
|
||||
"model": "skin-depth steel + lossy-dielectric saltwater + slot-diffraction seam",
|
||||
"materials": materials_grid,
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
# Print headlines
|
||||
print("=== Skin depth + bulk attenuation ===")
|
||||
for fkey, m in materials_grid.items():
|
||||
print(f" {fkey:>8} steel: skin={m['steel_skin_depth_um']:>6.2f} um, "
|
||||
f"attenuation={m['steel_atten_dB_per_mm']:>9.1f} dB/mm "
|
||||
f"saltwater={m['saltwater_atten_dB_per_m']:>6.1f} dB/m")
|
||||
print()
|
||||
print("=== Composite maritime scenarios @ 2.4 GHz ===")
|
||||
print(f"{'Scenario':<58} {'FSPL':>6} {'Metal':>6} {'Water':>6} {'Total':>6} {'Margin':>7}")
|
||||
for s in scenarios:
|
||||
print(f"{s['scenario']:<58} {s['fspl_db']:>6.1f} "
|
||||
f"{s['metal_path_loss_db']:>6.1f} {s['saltwater_loss_db']:>6.1f} "
|
||||
f"{s['total_loss_db']:>6.1f} {s['snr_margin_db']:>+7.1f}")
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"model": "skin-depth steel + lossy-dielectric saltwater + slot-diffraction seam",
|
||||
"materials": {
|
||||
"2.4_GHz": {
|
||||
"steel_skin_depth_um": 3.248736671806984,
|
||||
"steel_atten_dB_per_mm": 2673.654677948628,
|
||||
"saltwater_atten_dB_per_m": 852.7792439147287
|
||||
},
|
||||
"5.0_GHz": {
|
||||
"steel_skin_depth_um": 2.2507907903927653,
|
||||
"steel_atten_dB_per_mm": 3859.0881200843564,
|
||||
"saltwater_atten_dB_per_m": 867.7495416795573
|
||||
}
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario": "man-overboard, surface-floating",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 86.07916660695635,
|
||||
"bulk_loss_db": 0.0,
|
||||
"seam_loss_db": 999.0,
|
||||
"metal_path_used": "bulk",
|
||||
"metal_path_loss_db": 0.0,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 86.07916660695635,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": 24.92083339304365
|
||||
},
|
||||
{
|
||||
"scenario": "man-overboard, head 30 cm underwater",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 86.07916660695635,
|
||||
"bulk_loss_db": 0.0,
|
||||
"seam_loss_db": 999.0,
|
||||
"metal_path_used": "bulk",
|
||||
"metal_path_loss_db": 0.0,
|
||||
"saltwater_loss_db": 255.83377317441858,
|
||||
"total_loss_db": 341.9129397813749,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": -230.91293978137492
|
||||
},
|
||||
{
|
||||
"scenario": "crew vitals through 10 mm steel cabin door (closed)",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 49.88145871091758,
|
||||
"bulk_loss_db": 26736.546779486278,
|
||||
"seam_loss_db": 999.0,
|
||||
"metal_path_used": "seam",
|
||||
"metal_path_loss_db": 999.0,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 1048.8814587109175,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": -937.8814587109175
|
||||
},
|
||||
{
|
||||
"scenario": "crew vitals through cabin door (2 mm seam gap)",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 49.88145871091758,
|
||||
"bulk_loss_db": 26736.546779486278,
|
||||
"seam_loss_db": 29.891207909453847,
|
||||
"metal_path_used": "seam",
|
||||
"metal_path_loss_db": 29.891207909453847,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 79.77266662037142,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": 31.227333379628575
|
||||
},
|
||||
{
|
||||
"scenario": "crew vitals through cabin door (5 mm seam gap)",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 49.88145871091758,
|
||||
"bulk_loss_db": 26736.546779486278,
|
||||
"seam_loss_db": 21.93240773601309,
|
||||
"metal_path_used": "seam",
|
||||
"metal_path_loss_db": 21.93240773601309,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 71.81386644693066,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": 39.18613355306934
|
||||
},
|
||||
{
|
||||
"scenario": "container intrusion (steel cargo container, 2 mm walls, 30 mm vent slot)",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 60.14065230988498,
|
||||
"bulk_loss_db": 5347.309355897256,
|
||||
"seam_loss_db": 6.369382728340219,
|
||||
"metal_path_used": "seam",
|
||||
"metal_path_loss_db": 6.369382728340219,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 66.5100350382252,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": 44.4899649617748
|
||||
},
|
||||
{
|
||||
"scenario": "through hull (submarine, 30 mm pressure hull)",
|
||||
"freq_ghz": 2.4,
|
||||
"fspl_db": 40.88207853739662,
|
||||
"bulk_loss_db": 80209.64033845883,
|
||||
"seam_loss_db": 999.0,
|
||||
"metal_path_used": "seam",
|
||||
"metal_path_loss_db": 999.0,
|
||||
"saltwater_loss_db": 0.0,
|
||||
"total_loss_db": 1039.8820785373966,
|
||||
"esp32_link_budget_db": 121,
|
||||
"snr_margin_db": -928.8820785373966
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R13 — Critical scrutiny: contactless blood pressure from CSI?
|
||||
|
||||
See docs/research/sota-2026-05-22/R13-contactless-bp-negative.md.
|
||||
|
||||
Two published approaches to contactless BP:
|
||||
(a) Pulse Transit Time (PTT) — measure delay between pulse arrival at
|
||||
two body sites, then PTT -> BP via Bramwell-Hill / Moens-Korteweg.
|
||||
(b) Contour-based ML — learn (pulse waveform contour -> cuff BP).
|
||||
|
||||
This script quantifies the physics floors for both:
|
||||
(a) PTT requires (i) ms-scale temporal resolution AND (ii) spatial
|
||||
separation of two body sites. Spatial resolution is bounded by R6
|
||||
(Fresnel envelope), so we compute whether the per-site signals can
|
||||
be resolved at all.
|
||||
(b) Contour-based ML requires recovering a pulse waveform from a CSI
|
||||
stream where breathing motion is 100x larger. We compute the
|
||||
breathing-vs-pulse motion amplitude ratio and the resulting SNR
|
||||
needed to separate the two by temporal filtering.
|
||||
|
||||
Pure NumPy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8
|
||||
|
||||
|
||||
# ===== Physiology constants =====
|
||||
PWV_HEALTHY_ADULT_MPS = 7.0 # 5-10 m/s typical (Mukkamala 2015, lit median)
|
||||
CAROTID_FEMORAL_DIST_M = 0.55 # typical anatomic distance
|
||||
CHEST_BREATHING_AMPLITUDE_MM = 8.0 # rest tidal volume, typical adult
|
||||
CHEST_HR_AMPLITUDE_MM = 0.3 # ballistocardiographic chest motion (Inan 2015)
|
||||
CAROTID_PULSE_AMPLITUDE_MM = 0.4 # surface pulse displacement (Liu 2014)
|
||||
RESPIRATION_HZ = 0.25 # 15 BPM
|
||||
HR_HZ = 1.2 # 72 BPM
|
||||
MOTION_NOISE_AMPLITUDE_MM = 2.0 # subject "still" but not motionless
|
||||
|
||||
# WiFi
|
||||
WAVELENGTH_2_4GHZ_M = 0.125
|
||||
PHASE_DEG_PER_MM_2_4 = 360.0 / (WAVELENGTH_2_4GHZ_M * 1000) # ~2.88 deg/mm
|
||||
|
||||
|
||||
def ptt_seconds(distance_m: float = CAROTID_FEMORAL_DIST_M,
|
||||
pwv_mps: float = PWV_HEALTHY_ADULT_MPS) -> float:
|
||||
return distance_m / pwv_mps
|
||||
|
||||
|
||||
def ptt_change_per_bp_mmhg() -> float:
|
||||
"""Empirical: 10 mmHg BP change <-> ~5 ms PTT change for typical adult.
|
||||
(Geddes 1981, lit consensus). So sensitivity is ~0.5 ms / mmHg."""
|
||||
return 5e-3 / 10.0 # 0.5 ms/mmHg
|
||||
|
||||
|
||||
def required_ptt_resolution_for_mmhg(target_mmhg: float) -> float:
|
||||
"""How precise must PTT measurement be to resolve a target BP delta?"""
|
||||
return target_mmhg * ptt_change_per_bp_mmhg()
|
||||
|
||||
|
||||
def fresnel_radius_m(freq_ghz: float, link_m: float, p: float = 0.5) -> float:
|
||||
"""Reused from R6."""
|
||||
lam = C / (freq_ghz * 1e9)
|
||||
return float(np.sqrt(lam * link_m * p * (1 - p)))
|
||||
|
||||
|
||||
def signal_phase_change(motion_mm: float) -> float:
|
||||
"""Approximate CSI phase change in degrees for a chest motion amplitude.
|
||||
Assumes round-trip path-length change = motion_mm (chest moves toward / away)."""
|
||||
# Path-length change is roughly 2x the motion (in/out scattering)
|
||||
return 2 * motion_mm * PHASE_DEG_PER_MM_2_4
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r13_bp_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# ====== Part 1: PTT temporal resolution requirements ======
|
||||
ptt_baseline = ptt_seconds()
|
||||
ptt_for_1mmhg = required_ptt_resolution_for_mmhg(1.0)
|
||||
ptt_for_5mmhg = required_ptt_resolution_for_mmhg(5.0)
|
||||
ptt_for_10mmhg = required_ptt_resolution_for_mmhg(10.0)
|
||||
|
||||
# CSI sampling: at 100 Hz, time resolution is 10 ms; at 200 Hz, 5 ms.
|
||||
# We need 0.5 ms (1 mmHg) -- that's 2000 Hz CSI rate, which ESP32 *cannot* do.
|
||||
# Max ESP32 CSI rate is ~1000 Hz (Hernandez 2020); typical deployments are 50-100 Hz.
|
||||
|
||||
# ====== Part 2: Spatial separation of two body sites ======
|
||||
# For PTT, need to resolve carotid (~neck) and femoral (~hip) signals separately.
|
||||
# The Fresnel envelope at typical room ranges is too wide -- the two sites are
|
||||
# within the same envelope and cannot be separated by single-link CSI.
|
||||
|
||||
fresnel_envelope_5m = fresnel_radius_m(2.4, 5.0)
|
||||
fresnel_envelope_2m = fresnel_radius_m(2.4, 2.0)
|
||||
sites_resolvable_5m = (CAROTID_FEMORAL_DIST_M / 2) > fresnel_envelope_5m
|
||||
sites_resolvable_2m = (CAROTID_FEMORAL_DIST_M / 2) > fresnel_envelope_2m
|
||||
|
||||
# Multi-link multistatic could ALMOST resolve them, but the inverse problem
|
||||
# is severely ill-posed with only 4-6 anchors.
|
||||
|
||||
# ====== Part 3: Pulse contour SNR vs breathing ======
|
||||
# Phase change per motion:
|
||||
breath_phase_deg = signal_phase_change(CHEST_BREATHING_AMPLITUDE_MM) # ~46 deg
|
||||
pulse_phase_deg = signal_phase_change(CHEST_HR_AMPLITUDE_MM) # ~1.7 deg
|
||||
motion_phase_deg = signal_phase_change(MOTION_NOISE_AMPLITUDE_MM) # ~11.5 deg
|
||||
|
||||
breath_vs_pulse_amp_ratio = breath_phase_deg / pulse_phase_deg
|
||||
|
||||
# After bandpass filter (HR band 0.8-3.0 Hz, breathing 0.1-0.4 Hz),
|
||||
# breathing should drop by ~40 dB. So in HR band:
|
||||
breath_after_bandpass_db = -40.0 # typical 4th-order Butterworth
|
||||
pulse_in_hr_band_db = 0.0
|
||||
motion_in_hr_band_db = -20.0 # micro-motion bleeds into HR band partially
|
||||
|
||||
# SNR for HR contour recovery:
|
||||
hr_snr_db = pulse_in_hr_band_db - max(motion_in_hr_band_db, breath_after_bandpass_db)
|
||||
|
||||
# For BP contour, we need to recover the SHAPE of the pulse, not just the rate.
|
||||
# Contour-quality recovery typically needs ~20-30 dB above any contaminating
|
||||
# signal (Mukkamala 2015). Our HR-band SNR is +20 dB -- BARELY enough for
|
||||
# rate, NOT enough for shape.
|
||||
|
||||
bp_contour_required_snr_db = 25.0 # literature standard for waveform-shape recovery
|
||||
bp_contour_feasibility = "INFEASIBLE" if hr_snr_db < bp_contour_required_snr_db else "MARGINAL"
|
||||
|
||||
# ====== Part 4: Compare to cuff baseline ======
|
||||
cuff_accuracy_mmhg = 2.0 # arm-cuff BIHS Grade A
|
||||
published_csi_bp_mae_mmhg = 10.0 # representative lit (Yang 2022 et al.)
|
||||
# Conclusion: even the best published CSI BP is 5x worse than a $20 cuff.
|
||||
|
||||
out = {
|
||||
"model": "PTT + pulse-contour physics scrutiny for contactless BP",
|
||||
"ptt": {
|
||||
"baseline_ms": ptt_baseline * 1e3,
|
||||
"sensitivity_ms_per_mmHg": ptt_change_per_bp_mmhg() * 1e3,
|
||||
"required_resolution_for_1mmHg_ms": ptt_for_1mmhg * 1e3,
|
||||
"required_resolution_for_5mmHg_ms": ptt_for_5mmhg * 1e3,
|
||||
"required_resolution_for_10mmHg_ms": ptt_for_10mmhg * 1e3,
|
||||
"esp32_max_csi_rate_hz": 1000,
|
||||
"esp32_max_temporal_resolution_ms": 1.0,
|
||||
"esp32_typical_csi_rate_hz": 100,
|
||||
"esp32_typical_temporal_resolution_ms": 10.0,
|
||||
},
|
||||
"spatial_resolution": {
|
||||
"carotid_femoral_distance_m": CAROTID_FEMORAL_DIST_M,
|
||||
"fresnel_envelope_5m_link_m": fresnel_envelope_5m,
|
||||
"fresnel_envelope_2m_link_m": fresnel_envelope_2m,
|
||||
"sites_resolvable_5m_link": bool(sites_resolvable_5m),
|
||||
"sites_resolvable_2m_link": bool(sites_resolvable_2m),
|
||||
"comment": "Single-link CSI cannot spatially separate two body sites. PTT requires multi-link multistatic with severely ill-posed inverse problem.",
|
||||
},
|
||||
"snr": {
|
||||
"breath_phase_deg": breath_phase_deg,
|
||||
"pulse_phase_deg": pulse_phase_deg,
|
||||
"motion_phase_deg": motion_phase_deg,
|
||||
"breath_vs_pulse_amp_ratio": breath_vs_pulse_amp_ratio,
|
||||
"hr_band_snr_db": hr_snr_db,
|
||||
"bp_contour_required_snr_db": bp_contour_required_snr_db,
|
||||
"bp_contour_feasibility": bp_contour_feasibility,
|
||||
},
|
||||
"vs_baseline": {
|
||||
"arm_cuff_accuracy_mmHg": cuff_accuracy_mmhg,
|
||||
"published_csi_bp_mae_mmHg": published_csi_bp_mae_mmhg,
|
||||
"ratio_worse": published_csi_bp_mae_mmhg / cuff_accuracy_mmhg,
|
||||
},
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print("=== PTT temporal resolution requirements ===")
|
||||
print(f" Baseline PTT (55 cm body, 7 m/s PWV): {ptt_baseline*1e3:.1f} ms")
|
||||
print(f" Sensitivity: {ptt_change_per_bp_mmhg()*1e3:.2f} ms / mmHg")
|
||||
print(f" Required for 1 mmHg precision: {ptt_for_1mmhg*1e3:.2f} ms")
|
||||
print(f" Required for 5 mmHg precision: {ptt_for_5mmhg*1e3:.2f} ms")
|
||||
print(f" Required for 10 mmHg precision: {ptt_for_10mmhg*1e3:.2f} ms")
|
||||
print(f" ESP32 max CSI rate (~1000 Hz): 1.0 ms resolution -- meets 1 mmHg req")
|
||||
print(f" ESP32 typical (~100 Hz): 10.0 ms resolution -- meets only 20 mmHg")
|
||||
print()
|
||||
print("=== Spatial resolution (Fresnel envelope) ===")
|
||||
print(f" Carotid-to-femoral distance: {CAROTID_FEMORAL_DIST_M*100:.0f} cm")
|
||||
print(f" Fresnel envelope @ 5 m link: {fresnel_envelope_5m*100:.0f} cm -- sites NOT resolvable")
|
||||
print(f" Fresnel envelope @ 2 m link: {fresnel_envelope_2m*100:.0f} cm -- sites NOT resolvable")
|
||||
print()
|
||||
print("=== Phase change per motion (CSI 2.4 GHz) ===")
|
||||
print(f" Chest breathing (8 mm): {breath_phase_deg:.1f} deg")
|
||||
print(f" HR ballistocardiographic (0.3 mm): {pulse_phase_deg:.1f} deg")
|
||||
print(f" Subject 'still' motion (2 mm): {motion_phase_deg:.1f} deg")
|
||||
print(f" Breathing-to-pulse amplitude ratio: {breath_vs_pulse_amp_ratio:.0f}x")
|
||||
print()
|
||||
print(f"=== BP contour recovery ===")
|
||||
print(f" HR-band SNR after bandpass: {hr_snr_db:.1f} dB")
|
||||
print(f" Required for BP contour shape: {bp_contour_required_snr_db:.1f} dB")
|
||||
print(f" Verdict: {bp_contour_feasibility}")
|
||||
print()
|
||||
print(f"=== Vs $20 arm cuff baseline ===")
|
||||
print(f" Arm cuff (BIHS Grade A): ±{cuff_accuracy_mmhg:.0f} mmHg")
|
||||
print(f" Best published CSI BP: ±{published_csi_bp_mae_mmhg:.0f} mmHg")
|
||||
print(f" CSI is worse by: {published_csi_bp_mae_mmhg/cuff_accuracy_mmhg:.0f}x")
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"model": "PTT + pulse-contour physics scrutiny for contactless BP",
|
||||
"ptt": {
|
||||
"baseline_ms": 78.57142857142858,
|
||||
"sensitivity_ms_per_mmHg": 0.5,
|
||||
"required_resolution_for_1mmHg_ms": 0.5,
|
||||
"required_resolution_for_5mmHg_ms": 2.5,
|
||||
"required_resolution_for_10mmHg_ms": 5.0,
|
||||
"esp32_max_csi_rate_hz": 1000,
|
||||
"esp32_max_temporal_resolution_ms": 1.0,
|
||||
"esp32_typical_csi_rate_hz": 100,
|
||||
"esp32_typical_temporal_resolution_ms": 10.0
|
||||
},
|
||||
"spatial_resolution": {
|
||||
"carotid_femoral_distance_m": 0.55,
|
||||
"fresnel_envelope_5m_link_m": 0.39515292398428903,
|
||||
"fresnel_envelope_2m_link_m": 0.2499166527731462,
|
||||
"sites_resolvable_5m_link": false,
|
||||
"sites_resolvable_2m_link": true,
|
||||
"comment": "Single-link CSI cannot spatially separate two body sites. PTT requires multi-link multistatic with severely ill-posed inverse problem."
|
||||
},
|
||||
"snr": {
|
||||
"breath_phase_deg": 46.08,
|
||||
"pulse_phase_deg": 1.728,
|
||||
"motion_phase_deg": 11.52,
|
||||
"breath_vs_pulse_amp_ratio": 26.666666666666664,
|
||||
"hr_band_snr_db": 20.0,
|
||||
"bp_contour_required_snr_db": 25.0,
|
||||
"bp_contour_feasibility": "INFEASIBLE"
|
||||
},
|
||||
"vs_baseline": {
|
||||
"arm_cuff_accuracy_mmHg": 2.0,
|
||||
"published_csi_bp_mae_mmHg": 10.0,
|
||||
"ratio_worse": 5.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R1 — Time-of-Arrival CRLB for WiFi multistatic localisation.
|
||||
|
||||
See docs/research/sota-2026-05-22/R1-toa-crlb.md.
|
||||
|
||||
Computes the Cramer-Rao Lower Bound on ToA precision as a function of
|
||||
bandwidth and SNR, then compares it to the phase-based ranging precision
|
||||
unlocked by R6's Fresnel forward model. The headline question:
|
||||
|
||||
At WiFi-grade bandwidths (20 / 40 / 80 / 160 MHz), what is the best
|
||||
possible single-shot ranging precision via raw ToA, vs phase-derived
|
||||
ranging?
|
||||
|
||||
Standard ToA CRLB (Kay '93, Ch 3):
|
||||
|
||||
sigma_ToA >= 1 / ( 2 * pi * beta * sqrt(SNR) ) [s]
|
||||
sigma_d = c * sigma_ToA [m]
|
||||
|
||||
where beta is the effective (RMS) bandwidth. For a brick-wall pulse of
|
||||
bandwidth B (matched-filter spectrum), beta = B / sqrt(3).
|
||||
|
||||
Phase-based ranging precision at carrier f_c (a single subcarrier):
|
||||
|
||||
sigma_d_phi = (c / 2 * pi * f_c) * sigma_phi [m]
|
||||
|
||||
where sigma_phi is the phase-noise standard deviation in radians.
|
||||
|
||||
Pure NumPy, no plotting libs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8
|
||||
|
||||
def toa_crlb_seconds(bandwidth_hz: float, snr_db: float) -> float:
|
||||
"""ToA CRLB in seconds. Bandwidth is the matched-filter / signal
|
||||
bandwidth, NOT the carrier frequency. The factor of sqrt(3) comes
|
||||
from the brick-wall pulse RMS bandwidth: beta_rms = B / sqrt(3)."""
|
||||
snr_lin = 10 ** (snr_db / 10.0)
|
||||
beta_rms = bandwidth_hz / np.sqrt(3.0)
|
||||
return 1.0 / (2 * np.pi * beta_rms * np.sqrt(snr_lin))
|
||||
|
||||
|
||||
def range_precision_toa_m(bandwidth_hz: float, snr_db: float) -> float:
|
||||
"""Single-shot range precision (1 sigma) from ToA CRLB."""
|
||||
return C * toa_crlb_seconds(bandwidth_hz, snr_db)
|
||||
|
||||
|
||||
def range_precision_phase_m(carrier_ghz: float, phase_noise_deg: float) -> float:
|
||||
"""Single-subcarrier phase-based ranging precision. Assumes the
|
||||
integer-ambiguity (cycle slips) problem is solved by some other
|
||||
method (e.g. multi-subcarrier-frequency unwrap). This is the
|
||||
*unambiguous* precision, NOT the absolute distance."""
|
||||
sigma_phi = np.deg2rad(phase_noise_deg)
|
||||
lam = C / (carrier_ghz * 1e9)
|
||||
return lam * sigma_phi / (2 * np.pi)
|
||||
|
||||
|
||||
def averaging_gain(n_samples: int) -> float:
|
||||
"""Independent-sample averaging gain (1/sqrt(N))."""
|
||||
return 1.0 / np.sqrt(n_samples)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r1_toa_crlb_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# WiFi-relevant bandwidths
|
||||
bandwidths_mhz = [20, 40, 80, 160, 320] # 802.11n/ac/ax/be
|
||||
snrs_db = [0, 10, 20, 30, 40]
|
||||
carriers_ghz = [2.4, 5.0, 6.0]
|
||||
|
||||
# 1. ToA CRLB grid
|
||||
toa_grid = {}
|
||||
for bw_mhz in bandwidths_mhz:
|
||||
bw_hz = bw_mhz * 1e6
|
||||
col = {}
|
||||
for snr_db in snrs_db:
|
||||
sigma_t = toa_crlb_seconds(bw_hz, snr_db)
|
||||
sigma_d = range_precision_toa_m(bw_hz, snr_db)
|
||||
col[f"snr_{snr_db}dB"] = {
|
||||
"sigma_toa_ns": sigma_t * 1e9,
|
||||
"sigma_range_m": sigma_d,
|
||||
}
|
||||
toa_grid[f"bw_{bw_mhz}MHz"] = col
|
||||
|
||||
# 2. Phase-based ranging precision (single subcarrier)
|
||||
phase_grid = {}
|
||||
for ghz in carriers_ghz:
|
||||
col = {}
|
||||
for phase_noise_deg in [0.5, 1.0, 2.0, 5.0, 10.0]:
|
||||
sigma_d = range_precision_phase_m(ghz, phase_noise_deg)
|
||||
col[f"sigma_phi_{phase_noise_deg}deg"] = {
|
||||
"sigma_range_mm": sigma_d * 1000,
|
||||
"sigma_range_m": sigma_d,
|
||||
}
|
||||
phase_grid[f"carrier_{ghz}GHz"] = col
|
||||
|
||||
# 3. Practical comparison: 20 MHz HT20 channel, 20 dB SNR, 100 averaged samples
|
||||
bw_practical_hz = 20e6
|
||||
snr_practical = 20
|
||||
n_avg = 100
|
||||
|
||||
toa_single = range_precision_toa_m(bw_practical_hz, snr_practical)
|
||||
toa_avg = toa_single * averaging_gain(n_avg)
|
||||
phase_single = range_precision_phase_m(2.4, 5.0) # 5 deg phase noise
|
||||
phase_avg = phase_single * averaging_gain(n_avg)
|
||||
|
||||
headline = {
|
||||
"scenario": "20 MHz HT20 channel, 20 dB SNR, 100 averaged frames",
|
||||
"toa_single_shot_m": toa_single,
|
||||
"toa_after_100_avg_m": toa_avg,
|
||||
"phase_single_shot_m": phase_single,
|
||||
"phase_after_100_avg_m": phase_avg,
|
||||
"phase_advantage_ratio": toa_single / phase_single,
|
||||
}
|
||||
|
||||
# 4. Multistatic geometric dilution: 4 anchor nodes around a 5x5m room,
|
||||
# each contributes one range measurement. Position-error CRLB scales
|
||||
# with the inverse of the FIM trace, which is roughly:
|
||||
# sigma_pos = sigma_range * sqrt(GDOP / N_anchors)
|
||||
# GDOP for a tight 4-anchor convex-hull is ~1.5 (vs ~3 for collinear).
|
||||
gdop_tight = 1.5
|
||||
n_anchors = 4
|
||||
toa_pos_precision = toa_single * np.sqrt(gdop_tight / n_anchors)
|
||||
phase_pos_precision = phase_single * np.sqrt(gdop_tight / n_anchors)
|
||||
multistatic = {
|
||||
"n_anchors": n_anchors,
|
||||
"gdop": gdop_tight,
|
||||
"toa_position_precision_m": toa_pos_precision,
|
||||
"phase_position_precision_m": phase_pos_precision,
|
||||
}
|
||||
|
||||
out = {
|
||||
"model": "Cramer-Rao Lower Bound on ToA + phase ranging precision",
|
||||
"bandwidth_grid": toa_grid,
|
||||
"phase_grid": phase_grid,
|
||||
"headline_practical": headline,
|
||||
"multistatic_4anchor": multistatic,
|
||||
}
|
||||
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print("=== ToA single-shot range CRLB (m, 1 sigma) ===")
|
||||
hdr = f"{'BW':>8}" + "".join(f"{('SNR=' + str(s) + 'dB'):>12}" for s in snrs_db)
|
||||
print(hdr)
|
||||
for bw_mhz in bandwidths_mhz:
|
||||
row = f"{bw_mhz:>5} MHz"
|
||||
for snr_db in snrs_db:
|
||||
sigma_d = toa_grid[f"bw_{bw_mhz}MHz"][f"snr_{snr_db}dB"]["sigma_range_m"]
|
||||
row += f"{sigma_d:>12.2f}"
|
||||
print(row)
|
||||
print()
|
||||
print("=== Phase-based single-subcarrier range precision (mm, 1 sigma) ===")
|
||||
print(f"{'carrier':>9}" + "".join(f"{('phi=' + str(d) + 'deg'):>14}" for d in [0.5, 1, 2, 5, 10]))
|
||||
for ghz in carriers_ghz:
|
||||
row = f"{ghz:>6.1f} GHz"
|
||||
for phase_noise_deg in [0.5, 1.0, 2.0, 5.0, 10.0]:
|
||||
v = phase_grid[f"carrier_{ghz}GHz"][f"sigma_phi_{phase_noise_deg}deg"]
|
||||
row += f"{v['sigma_range_mm']:>14.2f}"
|
||||
print(row)
|
||||
print()
|
||||
print("=== Headline (20 MHz HT20, 20 dB SNR, 100 averaged frames) ===")
|
||||
print(f" ToA single-shot range CRLB: {toa_single:>8.3f} m")
|
||||
print(f" ToA after 100x avg: {toa_avg:>8.3f} m")
|
||||
print(f" Phase single-subcarrier: {phase_single*1000:>8.2f} mm")
|
||||
print(f" Phase after 100x avg: {phase_avg*1000:>8.2f} mm")
|
||||
print(f" Phase advantage: {headline['phase_advantage_ratio']:>8.0f}x")
|
||||
print()
|
||||
print(f"=== Multistatic 4-anchor convex hull (GDOP {gdop_tight}) ===")
|
||||
print(f" ToA position precision: {toa_pos_precision:>8.3f} m")
|
||||
print(f" Phase position precision: {phase_pos_precision*1000:>8.2f} mm")
|
||||
print(f"\nWrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
"model": "Cramer-Rao Lower Bound on ToA + phase ranging precision",
|
||||
"bandwidth_grid": {
|
||||
"bw_20MHz": {
|
||||
"snr_0dB": {
|
||||
"sigma_toa_ns": 13.7832223855448,
|
||||
"sigma_range_m": 4.132210071186331
|
||||
},
|
||||
"snr_10dB": {
|
||||
"sigma_toa_ns": 4.358637623494103,
|
||||
"sigma_range_m": 1.3067195595235321
|
||||
},
|
||||
"snr_20dB": {
|
||||
"sigma_toa_ns": 1.37832223855448,
|
||||
"sigma_range_m": 0.41322100711863313
|
||||
},
|
||||
"snr_30dB": {
|
||||
"sigma_toa_ns": 0.43586376234941043,
|
||||
"sigma_range_m": 0.13067195595235323
|
||||
},
|
||||
"snr_40dB": {
|
||||
"sigma_toa_ns": 0.137832223855448,
|
||||
"sigma_range_m": 0.041322100711863305
|
||||
}
|
||||
},
|
||||
"bw_40MHz": {
|
||||
"snr_0dB": {
|
||||
"sigma_toa_ns": 6.8916111927724,
|
||||
"sigma_range_m": 2.0661050355931656
|
||||
},
|
||||
"snr_10dB": {
|
||||
"sigma_toa_ns": 2.1793188117470517,
|
||||
"sigma_range_m": 0.6533597797617661
|
||||
},
|
||||
"snr_20dB": {
|
||||
"sigma_toa_ns": 0.68916111927724,
|
||||
"sigma_range_m": 0.20661050355931657
|
||||
},
|
||||
"snr_30dB": {
|
||||
"sigma_toa_ns": 0.21793188117470522,
|
||||
"sigma_range_m": 0.06533597797617662
|
||||
},
|
||||
"snr_40dB": {
|
||||
"sigma_toa_ns": 0.068916111927724,
|
||||
"sigma_range_m": 0.020661050355931652
|
||||
}
|
||||
},
|
||||
"bw_80MHz": {
|
||||
"snr_0dB": {
|
||||
"sigma_toa_ns": 3.4458055963862,
|
||||
"sigma_range_m": 1.0330525177965828
|
||||
},
|
||||
"snr_10dB": {
|
||||
"sigma_toa_ns": 1.0896594058735258,
|
||||
"sigma_range_m": 0.32667988988088303
|
||||
},
|
||||
"snr_20dB": {
|
||||
"sigma_toa_ns": 0.34458055963862,
|
||||
"sigma_range_m": 0.10330525177965828
|
||||
},
|
||||
"snr_30dB": {
|
||||
"sigma_toa_ns": 0.10896594058735261,
|
||||
"sigma_range_m": 0.03266798898808831
|
||||
},
|
||||
"snr_40dB": {
|
||||
"sigma_toa_ns": 0.034458055963862,
|
||||
"sigma_range_m": 0.010330525177965826
|
||||
}
|
||||
},
|
||||
"bw_160MHz": {
|
||||
"snr_0dB": {
|
||||
"sigma_toa_ns": 1.7229027981931,
|
||||
"sigma_range_m": 0.5165262588982914
|
||||
},
|
||||
"snr_10dB": {
|
||||
"sigma_toa_ns": 0.5448297029367629,
|
||||
"sigma_range_m": 0.16333994494044152
|
||||
},
|
||||
"snr_20dB": {
|
||||
"sigma_toa_ns": 0.17229027981931,
|
||||
"sigma_range_m": 0.05165262588982914
|
||||
},
|
||||
"snr_30dB": {
|
||||
"sigma_toa_ns": 0.054482970293676304,
|
||||
"sigma_range_m": 0.016333994494044154
|
||||
},
|
||||
"snr_40dB": {
|
||||
"sigma_toa_ns": 0.017229027981931,
|
||||
"sigma_range_m": 0.005165262588982913
|
||||
}
|
||||
},
|
||||
"bw_320MHz": {
|
||||
"snr_0dB": {
|
||||
"sigma_toa_ns": 0.86145139909655,
|
||||
"sigma_range_m": 0.2582631294491457
|
||||
},
|
||||
"snr_10dB": {
|
||||
"sigma_toa_ns": 0.27241485146838146,
|
||||
"sigma_range_m": 0.08166997247022076
|
||||
},
|
||||
"snr_20dB": {
|
||||
"sigma_toa_ns": 0.086145139909655,
|
||||
"sigma_range_m": 0.02582631294491457
|
||||
},
|
||||
"snr_30dB": {
|
||||
"sigma_toa_ns": 0.027241485146838152,
|
||||
"sigma_range_m": 0.008166997247022077
|
||||
},
|
||||
"snr_40dB": {
|
||||
"sigma_toa_ns": 0.0086145139909655,
|
||||
"sigma_range_m": 0.0025826312944914566
|
||||
}
|
||||
}
|
||||
},
|
||||
"phase_grid": {
|
||||
"carrier_2.4GHz": {
|
||||
"sigma_phi_0.5deg": {
|
||||
"sigma_range_mm": 0.17349537037037038,
|
||||
"sigma_range_m": 0.00017349537037037038
|
||||
},
|
||||
"sigma_phi_1.0deg": {
|
||||
"sigma_range_mm": 0.34699074074074077,
|
||||
"sigma_range_m": 0.00034699074074074076
|
||||
},
|
||||
"sigma_phi_2.0deg": {
|
||||
"sigma_range_mm": 0.6939814814814815,
|
||||
"sigma_range_m": 0.0006939814814814815
|
||||
},
|
||||
"sigma_phi_5.0deg": {
|
||||
"sigma_range_mm": 1.7349537037037037,
|
||||
"sigma_range_m": 0.0017349537037037036
|
||||
},
|
||||
"sigma_phi_10.0deg": {
|
||||
"sigma_range_mm": 3.4699074074074074,
|
||||
"sigma_range_m": 0.0034699074074074072
|
||||
}
|
||||
},
|
||||
"carrier_5.0GHz": {
|
||||
"sigma_phi_0.5deg": {
|
||||
"sigma_range_mm": 0.08327777777777778,
|
||||
"sigma_range_m": 8.327777777777778e-05
|
||||
},
|
||||
"sigma_phi_1.0deg": {
|
||||
"sigma_range_mm": 0.16655555555555557,
|
||||
"sigma_range_m": 0.00016655555555555556
|
||||
},
|
||||
"sigma_phi_2.0deg": {
|
||||
"sigma_range_mm": 0.33311111111111114,
|
||||
"sigma_range_m": 0.0003331111111111111
|
||||
},
|
||||
"sigma_phi_5.0deg": {
|
||||
"sigma_range_mm": 0.8327777777777777,
|
||||
"sigma_range_m": 0.0008327777777777778
|
||||
},
|
||||
"sigma_phi_10.0deg": {
|
||||
"sigma_range_mm": 1.6655555555555555,
|
||||
"sigma_range_m": 0.0016655555555555555
|
||||
}
|
||||
},
|
||||
"carrier_6.0GHz": {
|
||||
"sigma_phi_0.5deg": {
|
||||
"sigma_range_mm": 0.06939814814814814,
|
||||
"sigma_range_m": 6.939814814814814e-05
|
||||
},
|
||||
"sigma_phi_1.0deg": {
|
||||
"sigma_range_mm": 0.13879629629629628,
|
||||
"sigma_range_m": 0.00013879629629629629
|
||||
},
|
||||
"sigma_phi_2.0deg": {
|
||||
"sigma_range_mm": 0.27759259259259256,
|
||||
"sigma_range_m": 0.00027759259259259257
|
||||
},
|
||||
"sigma_phi_5.0deg": {
|
||||
"sigma_range_mm": 0.6939814814814815,
|
||||
"sigma_range_m": 0.0006939814814814815
|
||||
},
|
||||
"sigma_phi_10.0deg": {
|
||||
"sigma_range_mm": 1.387962962962963,
|
||||
"sigma_range_m": 0.001387962962962963
|
||||
}
|
||||
}
|
||||
},
|
||||
"headline_practical": {
|
||||
"scenario": "20 MHz HT20 channel, 20 dB SNR, 100 averaged frames",
|
||||
"toa_single_shot_m": 0.41322100711863313,
|
||||
"toa_after_100_avg_m": 0.04132210071186332,
|
||||
"phase_single_shot_m": 0.0017349537037037036,
|
||||
"phase_after_100_avg_m": 0.00017349537037037038,
|
||||
"phase_advantage_ratio": 238.17408282221416
|
||||
},
|
||||
"multistatic_4anchor": {
|
||||
"n_anchors": 4,
|
||||
"gdop": 1.5,
|
||||
"toa_position_precision_m": 0.2530451546099066,
|
||||
"phase_position_precision_m": 0.0010624378253564768
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R3 — Cross-room CSI re-identification: simulation of the embedding-overlap problem.
|
||||
|
||||
See docs/research/sota-2026-05-22/R3-crossroom-reid.md.
|
||||
|
||||
Simulates the core problem: a CSI embedding is a sum of two contributions:
|
||||
embedding = person_signature + environment_signature
|
||||
|
||||
Within a single room, the environment signature is constant across all
|
||||
subjects, so K-NN works (~95% acc per AETHER, ADR-024). Across rooms,
|
||||
the environment signature changes by O(1) -- larger than the
|
||||
per-person signature variation -- so naive K-NN collapses to chance.
|
||||
|
||||
This script:
|
||||
1. Generates synthetic embeddings for 10 subjects across 3 rooms
|
||||
2. Measures within-room K-NN accuracy (baseline)
|
||||
3. Measures cross-room K-NN accuracy (raw embeddings)
|
||||
4. Applies domain-invariance via MERIDIAN-style environment subtraction
|
||||
5. Reports the accuracy gap
|
||||
|
||||
Pure NumPy, no ML deps. The simulation makes physically-realistic
|
||||
assumptions about embedding dimensions and noise floors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def generate_synthetic_embeddings(n_subjects: int, n_rooms: int,
|
||||
n_samples_per_subject_per_room: int,
|
||||
embedding_dim: int = 128,
|
||||
person_signature_scale: float = 0.35,
|
||||
environment_signature_scale: float = 1.5,
|
||||
noise_scale: float = 0.3,
|
||||
seed: int = 42) -> np.ndarray:
|
||||
"""Generate (n_subjects, n_rooms, n_samples, embedding_dim) tensor.
|
||||
Each embedding = person_sig[subject] + env_sig[room] + noise."""
|
||||
rng = np.random.default_rng(seed)
|
||||
person_sigs = rng.standard_normal((n_subjects, embedding_dim)) * person_signature_scale
|
||||
env_sigs = rng.standard_normal((n_rooms, embedding_dim)) * environment_signature_scale
|
||||
embeddings = np.zeros((n_subjects, n_rooms, n_samples_per_subject_per_room, embedding_dim))
|
||||
for s in range(n_subjects):
|
||||
for r in range(n_rooms):
|
||||
base = person_sigs[s] + env_sigs[r]
|
||||
noise = rng.standard_normal((n_samples_per_subject_per_room, embedding_dim)) * noise_scale
|
||||
embeddings[s, r] = base + noise
|
||||
return embeddings, person_sigs, env_sigs
|
||||
|
||||
|
||||
def cosine_knn_accuracy(query: np.ndarray, gallery: np.ndarray,
|
||||
query_labels: np.ndarray, gallery_labels: np.ndarray,
|
||||
k: int = 1) -> float:
|
||||
"""1-shot cosine K-NN accuracy. Returns fraction of queries correctly matched."""
|
||||
q_norm = query / (np.linalg.norm(query, axis=1, keepdims=True) + 1e-9)
|
||||
g_norm = gallery / (np.linalg.norm(gallery, axis=1, keepdims=True) + 1e-9)
|
||||
sims = q_norm @ g_norm.T # (n_query, n_gallery)
|
||||
top_k_indices = np.argsort(-sims, axis=1)[:, :k]
|
||||
correct = 0
|
||||
for i, top_k in enumerate(top_k_indices):
|
||||
top_k_labels = gallery_labels[top_k]
|
||||
vals, counts = np.unique(top_k_labels, return_counts=True)
|
||||
majority = vals[np.argmax(counts)]
|
||||
if majority == query_labels[i]:
|
||||
correct += 1
|
||||
return correct / len(query)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r3_reid_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
n_subjects = 10
|
||||
n_rooms = 3
|
||||
n_samples = 20
|
||||
emb_dim = 128
|
||||
|
||||
emb, person_sigs, env_sigs = generate_synthetic_embeddings(
|
||||
n_subjects, n_rooms, n_samples, emb_dim,
|
||||
)
|
||||
|
||||
# ===== 1. Within-room K-NN baseline =====
|
||||
# Train on first 10 samples of each (subject, room), query on the rest
|
||||
within_accuracies = []
|
||||
for r in range(n_rooms):
|
||||
train = emb[:, r, :10, :].reshape(-1, emb_dim)
|
||||
query = emb[:, r, 10:, :].reshape(-1, emb_dim)
|
||||
train_labels = np.repeat(np.arange(n_subjects), 10)
|
||||
query_labels = np.repeat(np.arange(n_subjects), 10)
|
||||
acc = cosine_knn_accuracy(query, train, query_labels, train_labels, k=1)
|
||||
within_accuracies.append(acc)
|
||||
within_mean = float(np.mean(within_accuracies))
|
||||
|
||||
# ===== 2. Cross-room K-NN (raw, no domain invariance) =====
|
||||
# Train on room 0, query on rooms 1 + 2
|
||||
cross_accuracies_raw = []
|
||||
train = emb[:, 0, :, :].reshape(-1, emb_dim)
|
||||
train_labels = np.repeat(np.arange(n_subjects), n_samples)
|
||||
for r in [1, 2]:
|
||||
query = emb[:, r, :, :].reshape(-1, emb_dim)
|
||||
query_labels = np.repeat(np.arange(n_subjects), n_samples)
|
||||
acc = cosine_knn_accuracy(query, train, query_labels, train_labels, k=1)
|
||||
cross_accuracies_raw.append(acc)
|
||||
cross_raw_mean = float(np.mean(cross_accuracies_raw))
|
||||
|
||||
# ===== 3. Cross-room with environment subtraction (MERIDIAN-style) =====
|
||||
# Compute per-room mean (across all subjects in that room)
|
||||
# and subtract it from each embedding. This removes the env_sig
|
||||
# contribution exactly, leaving person_sig + noise.
|
||||
cross_accuracies_meridian = []
|
||||
train_centroid = emb[:, 0, :, :].reshape(-1, emb_dim).mean(axis=0)
|
||||
train_clean = emb[:, 0, :, :].reshape(-1, emb_dim) - train_centroid
|
||||
for r in [1, 2]:
|
||||
query_centroid = emb[:, r, :, :].reshape(-1, emb_dim).mean(axis=0)
|
||||
query_clean = emb[:, r, :, :].reshape(-1, emb_dim) - query_centroid
|
||||
query_labels = np.repeat(np.arange(n_subjects), n_samples)
|
||||
acc = cosine_knn_accuracy(query_clean, train_clean, query_labels, train_labels, k=1)
|
||||
cross_accuracies_meridian.append(acc)
|
||||
cross_meridian_mean = float(np.mean(cross_accuracies_meridian))
|
||||
|
||||
# ===== 4. Cross-room with PARTIAL invariance (incomplete env subtraction) =====
|
||||
# Real MERIDIAN can't perfectly recover the env signal -- it's
|
||||
# estimated from labeled examples. Simulate a 70% effective subtraction.
|
||||
partial_strength = 0.7
|
||||
cross_accuracies_partial = []
|
||||
train_partial = emb[:, 0, :, :].reshape(-1, emb_dim) - partial_strength * train_centroid
|
||||
for r in [1, 2]:
|
||||
query_centroid = emb[:, r, :, :].reshape(-1, emb_dim).mean(axis=0)
|
||||
query_partial = emb[:, r, :, :].reshape(-1, emb_dim) - partial_strength * query_centroid
|
||||
query_labels = np.repeat(np.arange(n_subjects), n_samples)
|
||||
acc = cosine_knn_accuracy(query_partial, train_partial, query_labels, train_labels, k=1)
|
||||
cross_accuracies_partial.append(acc)
|
||||
cross_partial_mean = float(np.mean(cross_accuracies_partial))
|
||||
|
||||
# ===== 5. Embedding distance breakdown =====
|
||||
# How big is environment_sig vs person_sig?
|
||||
person_sig_norm = float(np.linalg.norm(person_sigs, axis=1).mean())
|
||||
env_sig_norm = float(np.linalg.norm(env_sigs, axis=1).mean())
|
||||
|
||||
out = {
|
||||
"config": {
|
||||
"n_subjects": n_subjects, "n_rooms": n_rooms, "n_samples_per_room": n_samples,
|
||||
"embedding_dim": emb_dim,
|
||||
"person_signature_scale": 0.35,
|
||||
"environment_signature_scale": 1.5,
|
||||
"noise_scale": 0.3,
|
||||
},
|
||||
"signature_norms": {
|
||||
"person_norm_avg": person_sig_norm,
|
||||
"environment_norm_avg": env_sig_norm,
|
||||
"env_to_person_ratio": env_sig_norm / person_sig_norm,
|
||||
},
|
||||
"accuracy": {
|
||||
"within_room_baseline": within_mean,
|
||||
"cross_room_raw": cross_raw_mean,
|
||||
"cross_room_meridian_perfect": cross_meridian_mean,
|
||||
"cross_room_meridian_70pct": cross_partial_mean,
|
||||
"chance": 1.0 / n_subjects,
|
||||
},
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print("=== Cross-room re-ID simulation ===")
|
||||
print(f" Embedding dim: {emb_dim}")
|
||||
print(f" Subjects: {n_subjects}")
|
||||
print(f" Rooms: {n_rooms}")
|
||||
print(f" Samples per subject per room: {n_samples}")
|
||||
print()
|
||||
print(f" Person signature norm avg: {person_sig_norm:.2f}")
|
||||
print(f" Environment signature norm: {env_sig_norm:.2f}")
|
||||
print(f" Env/Person ratio: {env_sig_norm / person_sig_norm:.2f}x")
|
||||
print()
|
||||
print(f" Within-room 1-shot K-NN: {within_mean*100:.1f}% (matches AETHER ~95% target)")
|
||||
print(f" Cross-room RAW: {cross_raw_mean*100:.1f}% (chance is {100/n_subjects:.1f}%)")
|
||||
print(f" Cross-room with MERIDIAN 100%: {cross_meridian_mean*100:.1f}%")
|
||||
print(f" Cross-room with MERIDIAN 70%: {cross_partial_mean*100:.1f}%")
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"config": {
|
||||
"n_subjects": 10,
|
||||
"n_rooms": 3,
|
||||
"n_samples_per_room": 20,
|
||||
"embedding_dim": 128,
|
||||
"person_signature_scale": 0.35,
|
||||
"environment_signature_scale": 1.5,
|
||||
"noise_scale": 0.3
|
||||
},
|
||||
"signature_norms": {
|
||||
"person_norm_avg": 3.890960952927665,
|
||||
"environment_norm_avg": 18.141078308016272,
|
||||
"env_to_person_ratio": 4.662364523181974
|
||||
},
|
||||
"accuracy": {
|
||||
"within_room_baseline": 1.0,
|
||||
"cross_room_raw": 0.7,
|
||||
"cross_room_meridian_perfect": 1.0,
|
||||
"cross_room_meridian_70pct": 1.0,
|
||||
"chance": 0.1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R6.2.2 — N-anchor multistatic Fresnel-coverage placement.
|
||||
|
||||
See docs/research/sota-2026-05-22/R6_2_2-multistatic-placement.md.
|
||||
|
||||
Extends R6.2 from single-pair to N anchors with all C(N,2) pairwise
|
||||
Fresnel ellipses. A point is covered if it lies inside the union of
|
||||
any pairwise Fresnel zone.
|
||||
|
||||
Practical question: how many seeds does a typical room need?
|
||||
Answer: report saturation curve over N = 2..8 anchors.
|
||||
|
||||
Search is greedy + restart (full combinatorial O(M^N) is too expensive
|
||||
for M ~100 candidates). Greedy adds the anchor that maximises marginal
|
||||
coverage at each step; restart picks the best of K greedy runs from
|
||||
different starting points to escape local minima.
|
||||
|
||||
Pure NumPy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8
|
||||
|
||||
|
||||
def wavelength_m(freq_ghz: float) -> float:
|
||||
return C / (freq_ghz * 1e9)
|
||||
|
||||
|
||||
def in_first_fresnel(x: np.ndarray, y: np.ndarray, tx: np.ndarray, rx: np.ndarray,
|
||||
wavelength: float) -> np.ndarray:
|
||||
r1 = np.sqrt((x - tx[0])**2 + (y - tx[1])**2)
|
||||
r2 = np.sqrt((x - rx[0])**2 + (y - rx[1])**2)
|
||||
direct = np.linalg.norm(tx - rx)
|
||||
return (r1 + r2) <= (direct + wavelength / 2)
|
||||
|
||||
|
||||
def union_coverage(anchors: list, target_grid_x: np.ndarray, target_grid_y: np.ndarray,
|
||||
wavelength: float) -> float:
|
||||
"""Fraction of target points covered by at least one pairwise Fresnel ellipse."""
|
||||
if len(anchors) < 2:
|
||||
return 0.0
|
||||
covered = np.zeros(len(target_grid_x), dtype=bool)
|
||||
for i in range(len(anchors)):
|
||||
for j in range(i+1, len(anchors)):
|
||||
mask = in_first_fresnel(target_grid_x, target_grid_y,
|
||||
anchors[i], anchors[j], wavelength)
|
||||
covered |= mask
|
||||
return float(covered.sum() / len(target_grid_x))
|
||||
|
||||
|
||||
def rasterise_targets(target_zones: list, resolution: float) -> tuple:
|
||||
"""Flatten target zones into (x, y) arrays."""
|
||||
xs, ys = [], []
|
||||
for name, x0, y0, w, h in target_zones:
|
||||
zx = np.arange(x0, x0 + w, resolution)
|
||||
zy = np.arange(y0, y0 + h, resolution)
|
||||
gx, gy = np.meshgrid(zx, zy)
|
||||
xs.append(gx.ravel())
|
||||
ys.append(gy.ravel())
|
||||
return np.concatenate(xs), np.concatenate(ys)
|
||||
|
||||
|
||||
def candidate_positions(room_w: float, room_h: float, step: float) -> list:
|
||||
"""Wall-perimeter candidate antenna positions."""
|
||||
cands = []
|
||||
for x in np.arange(0, room_w + 0.001, step):
|
||||
cands.append(np.array([x, 0.0]))
|
||||
cands.append(np.array([x, room_h]))
|
||||
for y in np.arange(step, room_h, step):
|
||||
cands.append(np.array([0.0, y]))
|
||||
cands.append(np.array([room_w, y]))
|
||||
return cands
|
||||
|
||||
|
||||
def greedy_search(candidates: list, target_x: np.ndarray, target_y: np.ndarray,
|
||||
wavelength: float, n_anchors: int, n_restarts: int = 8,
|
||||
seed: int = 0) -> dict:
|
||||
"""Greedy: at each step, add the candidate that maximises marginal coverage.
|
||||
Restart K times from random initial pairs to escape local minima."""
|
||||
rng = np.random.default_rng(seed)
|
||||
best = {"anchors": [], "score": -1.0, "trace": []}
|
||||
for restart in range(n_restarts):
|
||||
# Random initial pair
|
||||
idx0, idx1 = rng.choice(len(candidates), size=2, replace=False)
|
||||
chosen = [candidates[idx0], candidates[idx1]]
|
||||
trace = [union_coverage(chosen, target_x, target_y, wavelength)]
|
||||
while len(chosen) < n_anchors:
|
||||
best_marginal = -1.0
|
||||
best_idx = None
|
||||
for k, c in enumerate(candidates):
|
||||
if any(np.allclose(c, a) for a in chosen):
|
||||
continue
|
||||
trial = chosen + [c]
|
||||
score = union_coverage(trial, target_x, target_y, wavelength)
|
||||
if score > best_marginal:
|
||||
best_marginal = score
|
||||
best_idx = k
|
||||
if best_idx is None:
|
||||
break
|
||||
chosen.append(candidates[best_idx])
|
||||
trace.append(best_marginal)
|
||||
final = trace[-1]
|
||||
if final > best["score"]:
|
||||
best = {
|
||||
"anchors": [a.tolist() for a in chosen],
|
||||
"score": final,
|
||||
"trace": trace,
|
||||
"restart_used": restart,
|
||||
}
|
||||
return best
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="R6.2.2: N-anchor Fresnel multistatic placement")
|
||||
parser.add_argument("--room", nargs=2, type=float, default=[5.0, 5.0])
|
||||
parser.add_argument("--freq-ghz", type=float, default=2.4)
|
||||
parser.add_argument("--step", type=float, default=0.5)
|
||||
parser.add_argument("--n-max", type=int, default=8)
|
||||
parser.add_argument("--restarts", type=int, default=8)
|
||||
parser.add_argument("--out", default="examples/research-sota/r6_2_2_multistatic_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
target_zones = [
|
||||
("bed", 1.5, 0.5, 2.0, 1.5),
|
||||
("chair", 3.5, 3.5, 0.8, 0.8),
|
||||
("desk", 0.2, 2.5, 1.0, 0.6), # third zone for more interesting saturation
|
||||
]
|
||||
lam = wavelength_m(args.freq_ghz)
|
||||
candidates = candidate_positions(args.room[0], args.room[1], args.step)
|
||||
target_x, target_y = rasterise_targets(target_zones, 0.1)
|
||||
|
||||
print(f"Room: {args.room[0]:.1f} x {args.room[1]:.1f} m")
|
||||
print(f"Frequency: {args.freq_ghz} GHz (lambda = {lam*100:.2f} cm)")
|
||||
print(f"Targets: {len(target_zones)} zones, {len(target_x)} grid points")
|
||||
print(f"Candidates: {len(candidates)} positions (step={args.step}m)")
|
||||
print()
|
||||
|
||||
saturation = []
|
||||
for n in range(2, args.n_max + 1):
|
||||
result = greedy_search(candidates, target_x, target_y, lam,
|
||||
n_anchors=n, n_restarts=args.restarts)
|
||||
saturation.append({
|
||||
"n_anchors": n,
|
||||
"coverage": result["score"],
|
||||
"n_pairs_used": n * (n - 1) // 2,
|
||||
"anchors": result["anchors"],
|
||||
})
|
||||
|
||||
# Marginal coverage per additional anchor
|
||||
marginal = []
|
||||
for i in range(1, len(saturation)):
|
||||
prev = saturation[i-1]["coverage"]
|
||||
curr = saturation[i]["coverage"]
|
||||
marginal.append({
|
||||
"from_n": saturation[i-1]["n_anchors"],
|
||||
"to_n": saturation[i]["n_anchors"],
|
||||
"marginal_coverage_pp": (curr - prev) * 100,
|
||||
})
|
||||
|
||||
print("=== Coverage saturation ===")
|
||||
print(f"{'N anchors':>10} {'Pairs':>6} {'Coverage':>9} {'Marginal':>9}")
|
||||
prev = 0.0
|
||||
for s in saturation:
|
||||
marg = (s["coverage"] - prev) * 100
|
||||
print(f"{s['n_anchors']:>10} {s['n_pairs_used']:>6} {s['coverage']*100:>7.1f}% {marg:>+7.1f} pp")
|
||||
prev = s["coverage"]
|
||||
|
||||
print()
|
||||
# Knee detection
|
||||
for i, m in enumerate(marginal):
|
||||
if m["marginal_coverage_pp"] < 5.0:
|
||||
print(f"Knee detected: going from N={m['from_n']} to N={m['to_n']} adds only {m['marginal_coverage_pp']:.1f} pp")
|
||||
print(f" Practical N = {m['from_n']} anchors (diminishing returns past this)")
|
||||
break
|
||||
|
||||
out = {
|
||||
"room": {"width_m": args.room[0], "height_m": args.room[1]},
|
||||
"frequency_ghz": args.freq_ghz,
|
||||
"target_zones": [
|
||||
{"name": n, "x0": x0, "y0": y0, "width": w, "height": h}
|
||||
for n, x0, y0, w, h in target_zones
|
||||
],
|
||||
"saturation": saturation,
|
||||
"marginal_gains_pp": marginal,
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
print(f"\nWrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"room": {
|
||||
"width_m": 5.0,
|
||||
"height_m": 5.0
|
||||
},
|
||||
"frequency_ghz": 2.4,
|
||||
"target_zones": [
|
||||
{
|
||||
"name": "bed",
|
||||
"x0": 1.5,
|
||||
"y0": 0.5,
|
||||
"width": 2.0,
|
||||
"height": 1.5
|
||||
},
|
||||
{
|
||||
"name": "chair",
|
||||
"x0": 3.5,
|
||||
"y0": 3.5,
|
||||
"width": 0.8,
|
||||
"height": 0.8
|
||||
},
|
||||
{
|
||||
"name": "desk",
|
||||
"x0": 0.2,
|
||||
"y0": 2.5,
|
||||
"width": 1.0,
|
||||
"height": 0.6
|
||||
}
|
||||
],
|
||||
"saturation": [
|
||||
{
|
||||
"n_anchors": 2,
|
||||
"coverage": 0.35714285714285715,
|
||||
"n_pairs_used": 1,
|
||||
"anchors": [
|
||||
[
|
||||
0.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
1.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 3,
|
||||
"coverage": 0.6336405529953917,
|
||||
"n_pairs_used": 3,
|
||||
"anchors": [
|
||||
[
|
||||
0.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.5
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 4,
|
||||
"coverage": 0.8617511520737328,
|
||||
"n_pairs_used": 6,
|
||||
"anchors": [
|
||||
[
|
||||
0.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.5
|
||||
],
|
||||
[
|
||||
3.5,
|
||||
5.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 5,
|
||||
"coverage": 0.967741935483871,
|
||||
"n_pairs_used": 10,
|
||||
"anchors": [
|
||||
[
|
||||
3.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
2.5,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
4.0
|
||||
],
|
||||
[
|
||||
4.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
1.5,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 6,
|
||||
"coverage": 1.0,
|
||||
"n_pairs_used": 15,
|
||||
"anchors": [
|
||||
[
|
||||
4.5,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
1.5,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
0.5,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
2.5,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 7,
|
||||
"coverage": 1.0,
|
||||
"n_pairs_used": 21,
|
||||
"anchors": [
|
||||
[
|
||||
5.0,
|
||||
3.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.5
|
||||
],
|
||||
[
|
||||
1.5,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
3.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
5.0
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"n_anchors": 8,
|
||||
"coverage": 1.0,
|
||||
"n_pairs_used": 28,
|
||||
"anchors": [
|
||||
[
|
||||
5.0,
|
||||
3.0
|
||||
],
|
||||
[
|
||||
5.0,
|
||||
1.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.5
|
||||
],
|
||||
[
|
||||
1.5,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
2.0
|
||||
],
|
||||
[
|
||||
3.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
5.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"marginal_gains_pp": [
|
||||
{
|
||||
"from_n": 2,
|
||||
"to_n": 3,
|
||||
"marginal_coverage_pp": 27.649769585253452
|
||||
},
|
||||
{
|
||||
"from_n": 3,
|
||||
"to_n": 4,
|
||||
"marginal_coverage_pp": 22.811059907834107
|
||||
},
|
||||
{
|
||||
"from_n": 4,
|
||||
"to_n": 5,
|
||||
"marginal_coverage_pp": 10.599078341013824
|
||||
},
|
||||
{
|
||||
"from_n": 5,
|
||||
"to_n": 6,
|
||||
"marginal_coverage_pp": 3.2258064516129004
|
||||
},
|
||||
{
|
||||
"from_n": 6,
|
||||
"to_n": 7,
|
||||
"marginal_coverage_pp": 0.0
|
||||
},
|
||||
{
|
||||
"from_n": 7,
|
||||
"to_n": 8,
|
||||
"marginal_coverage_pp": 0.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R6.2 — Fresnel-aware antenna placement for room-scale CSI sensing.
|
||||
|
||||
See docs/research/sota-2026-05-22/R6_2-fresnel-antenna-placement.md.
|
||||
|
||||
Given a 2D room + a list of target occupancy zones (e.g. "the bed",
|
||||
"the sofa"), search over candidate Tx/Rx positions and pick the pair
|
||||
that maximises the fraction of target-zone area inside the first
|
||||
Fresnel ellipse.
|
||||
|
||||
The first Fresnel zone in 2D is an ellipse with:
|
||||
- foci at Tx and Rx
|
||||
- semi-major axis a = (d + lambda/2) / 2
|
||||
- semi-minor axis b = sqrt(a^2 - (d/2)^2)
|
||||
where d = |Tx - Rx| and lambda = c / f.
|
||||
|
||||
This is the natural progression from R6 (the 1-D Fresnel radius at
|
||||
midpoint) -- now we evaluate coverage over arbitrary 2D zones.
|
||||
|
||||
Pure NumPy. CLI-shaped: takes room geometry and target zones as args,
|
||||
emits the best Tx/Rx placement + a coverage fraction.
|
||||
|
||||
Example usage:
|
||||
python r6_2_antenna_placement.py \\
|
||||
--room 5.0 5.0 \\
|
||||
--target bed 1.0 0.5 2.0 1.5 \\
|
||||
--target sofa 0.5 3.0 1.5 1.0 \\
|
||||
--freq-ghz 2.4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8
|
||||
|
||||
|
||||
def wavelength_m(freq_ghz: float) -> float:
|
||||
return C / (freq_ghz * 1e9)
|
||||
|
||||
|
||||
def in_first_fresnel(x: np.ndarray, y: np.ndarray, tx: np.ndarray, rx: np.ndarray,
|
||||
wavelength: float) -> np.ndarray:
|
||||
"""Return boolean array: is each (x, y) inside the first Fresnel ellipse
|
||||
of the Tx-Rx link?"""
|
||||
r1 = np.sqrt((x - tx[0])**2 + (y - tx[1])**2)
|
||||
r2 = np.sqrt((x - rx[0])**2 + (y - rx[1])**2)
|
||||
direct = np.linalg.norm(tx - rx)
|
||||
return (r1 + r2) <= (direct + wavelength / 2)
|
||||
|
||||
|
||||
def coverage_score(tx: np.ndarray, rx: np.ndarray, target_zones: list,
|
||||
wavelength: float, grid_resolution: float = 0.05) -> dict:
|
||||
"""Compute the fraction of total target-zone area inside the first
|
||||
Fresnel ellipse. Per-zone breakdowns also returned."""
|
||||
per_zone = {}
|
||||
total_area = 0.0
|
||||
total_covered = 0.0
|
||||
for name, x0, y0, w, h in target_zones:
|
||||
# Rasterise the zone
|
||||
xs = np.arange(x0, x0 + w, grid_resolution)
|
||||
ys = np.arange(y0, y0 + h, grid_resolution)
|
||||
xv, yv = np.meshgrid(xs, ys)
|
||||
xv = xv.ravel()
|
||||
yv = yv.ravel()
|
||||
mask = in_first_fresnel(xv, yv, tx, rx, wavelength)
|
||||
area_zone = len(xv) * grid_resolution ** 2
|
||||
covered_zone = mask.sum() * grid_resolution ** 2
|
||||
per_zone[name] = {
|
||||
"area_m2": float(area_zone),
|
||||
"covered_m2": float(covered_zone),
|
||||
"coverage_fraction": float(covered_zone / area_zone) if area_zone > 0 else 0,
|
||||
}
|
||||
total_area += area_zone
|
||||
total_covered += covered_zone
|
||||
return {
|
||||
"total_coverage_fraction": float(total_covered / total_area) if total_area > 0 else 0,
|
||||
"total_area_m2": float(total_area),
|
||||
"covered_area_m2": float(total_covered),
|
||||
"per_zone": per_zone,
|
||||
}
|
||||
|
||||
|
||||
def search_optimal_placement(room_w: float, room_h: float, target_zones: list,
|
||||
freq_ghz: float, candidate_step: float = 0.25,
|
||||
grid_resolution: float = 0.05) -> dict:
|
||||
"""Brute-force search over candidate (Tx, Rx) positions on the room
|
||||
perimeter. Returns the best pair + score."""
|
||||
lam = wavelength_m(freq_ghz)
|
||||
# Candidate positions: walls only (more realistic; antennas attached to walls)
|
||||
candidates = []
|
||||
for x in np.arange(0, room_w + 0.001, candidate_step):
|
||||
candidates.append(np.array([x, 0.0]))
|
||||
candidates.append(np.array([x, room_h]))
|
||||
for y in np.arange(candidate_step, room_h, candidate_step):
|
||||
candidates.append(np.array([0.0, y]))
|
||||
candidates.append(np.array([room_w, y]))
|
||||
|
||||
best = {"score": -1, "tx": None, "rx": None}
|
||||
all_results = []
|
||||
for i, tx in enumerate(candidates):
|
||||
for j, rx in enumerate(candidates):
|
||||
if j <= i: continue
|
||||
# Skip degenerate (same wall, too close)
|
||||
if np.linalg.norm(tx - rx) < 1.0:
|
||||
continue
|
||||
result = coverage_score(tx, rx, target_zones, lam, grid_resolution)
|
||||
score = result["total_coverage_fraction"]
|
||||
if score > best["score"]:
|
||||
best = {
|
||||
"score": score,
|
||||
"tx": tx.tolist(),
|
||||
"rx": rx.tolist(),
|
||||
"link_length_m": float(np.linalg.norm(tx - rx)),
|
||||
"result": result,
|
||||
}
|
||||
all_results.append({
|
||||
"tx": tx.tolist(), "rx": rx.tolist(),
|
||||
"link_m": float(np.linalg.norm(tx - rx)),
|
||||
"score": score,
|
||||
})
|
||||
return best, all_results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="R6.2: Fresnel-aware antenna placement")
|
||||
parser.add_argument("--room", nargs=2, type=float, default=[5.0, 5.0],
|
||||
help="Room dimensions: width height (m)")
|
||||
parser.add_argument("--target", nargs=5, action="append",
|
||||
help="Target zone: name x0 y0 width height (m)")
|
||||
parser.add_argument("--freq-ghz", type=float, default=2.4)
|
||||
parser.add_argument("--step", type=float, default=0.25,
|
||||
help="Candidate placement grid step (m)")
|
||||
parser.add_argument("--out", default="examples/research-sota/r6_2_placement_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.target:
|
||||
# Sensible defaults: a bedroom with a bed + a chair
|
||||
target_zones = [
|
||||
("bed", 1.5, 0.5, 2.0, 1.5),
|
||||
("chair", 3.5, 3.5, 0.8, 0.8),
|
||||
]
|
||||
else:
|
||||
target_zones = []
|
||||
for t in args.target:
|
||||
name = t[0]
|
||||
x0, y0, w, h = float(t[1]), float(t[2]), float(t[3]), float(t[4])
|
||||
target_zones.append((name, x0, y0, w, h))
|
||||
|
||||
print(f"Room: {args.room[0]:.1f} x {args.room[1]:.1f} m")
|
||||
print(f"Frequency: {args.freq_ghz:.2f} GHz (lambda = {wavelength_m(args.freq_ghz)*100:.2f} cm)")
|
||||
print(f"Target zones ({len(target_zones)}):")
|
||||
for name, x0, y0, w, h in target_zones:
|
||||
print(f" {name}: ({x0:.1f}, {y0:.1f}) - ({x0+w:.1f}, {y0+h:.1f}) area={w*h:.2f} m^2")
|
||||
print()
|
||||
|
||||
best, all_results = search_optimal_placement(
|
||||
args.room[0], args.room[1], target_zones, args.freq_ghz,
|
||||
candidate_step=args.step
|
||||
)
|
||||
|
||||
# Worst placement, for contrast
|
||||
worst = min(all_results, key=lambda r: r["score"])
|
||||
median = sorted(all_results, key=lambda r: r["score"])[len(all_results) // 2]
|
||||
|
||||
print(f"=== Search: evaluated {len(all_results)} antenna pairs ===")
|
||||
print()
|
||||
print(f"BEST placement:")
|
||||
print(f" Tx: {best['tx'][0]:.2f}, {best['tx'][1]:.2f}")
|
||||
print(f" Rx: {best['rx'][0]:.2f}, {best['rx'][1]:.2f}")
|
||||
print(f" Link length: {best['link_length_m']:.2f} m")
|
||||
print(f" Coverage fraction: {best['score']*100:.1f}%")
|
||||
print(f" Per-zone:")
|
||||
for name, info in best["result"]["per_zone"].items():
|
||||
print(f" {name}: {info['coverage_fraction']*100:.1f}% covered ({info['covered_m2']:.2f} / {info['area_m2']:.2f} m^2)")
|
||||
print()
|
||||
print(f"MEDIAN placement: {median['score']*100:.1f}%")
|
||||
print(f"WORST placement: {worst['score']*100:.1f}% (link {worst['link_m']:.2f} m)")
|
||||
print()
|
||||
print(f" Best/median improvement: {best['score']/median['score']:.2f}x")
|
||||
print(f" Best/worst improvement: {best['score']/(worst['score']+1e-6):.1f}x" if worst['score'] > 0 else " Best/worst improvement: infinite (worst zero)")
|
||||
print()
|
||||
|
||||
out = {
|
||||
"room": {"width_m": args.room[0], "height_m": args.room[1]},
|
||||
"frequency_ghz": args.freq_ghz,
|
||||
"wavelength_m": wavelength_m(args.freq_ghz),
|
||||
"target_zones": [
|
||||
{"name": n, "x0": x0, "y0": y0, "width": w, "height": h}
|
||||
for n, x0, y0, w, h in target_zones
|
||||
],
|
||||
"best": best,
|
||||
"median_score": median["score"],
|
||||
"worst_score": worst["score"],
|
||||
"n_pairs_evaluated": len(all_results),
|
||||
}
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"room": {
|
||||
"width_m": 5.0,
|
||||
"height_m": 5.0
|
||||
},
|
||||
"frequency_ghz": 2.4,
|
||||
"wavelength_m": 0.12491666666666666,
|
||||
"target_zones": [
|
||||
{
|
||||
"name": "bed",
|
||||
"x0": 1.5,
|
||||
"y0": 0.5,
|
||||
"width": 2.0,
|
||||
"height": 1.5
|
||||
},
|
||||
{
|
||||
"name": "chair",
|
||||
"x0": 3.5,
|
||||
"y0": 3.5,
|
||||
"width": 0.8,
|
||||
"height": 0.8
|
||||
}
|
||||
],
|
||||
"best": {
|
||||
"score": 0.510989010989011,
|
||||
"tx": [
|
||||
1.25,
|
||||
0.0
|
||||
],
|
||||
"rx": [
|
||||
4.75,
|
||||
5.0
|
||||
],
|
||||
"link_length_m": 6.103277807866851,
|
||||
"result": {
|
||||
"total_coverage_fraction": 0.510989010989011,
|
||||
"total_area_m2": 3.6400000000000006,
|
||||
"covered_area_m2": 1.8600000000000003,
|
||||
"per_zone": {
|
||||
"bed": {
|
||||
"area_m2": 3.0000000000000004,
|
||||
"covered_m2": 1.3050000000000002,
|
||||
"coverage_fraction": 0.435
|
||||
},
|
||||
"chair": {
|
||||
"area_m2": 0.6400000000000001,
|
||||
"covered_m2": 0.5550000000000002,
|
||||
"coverage_fraction": 0.8671875000000001
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"median_score": 0.005494505494505495,
|
||||
"worst_score": 0.0,
|
||||
"n_pairs_evaluated": 2900
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"model": "first-Fresnel-zone ellipsoid + per-subcarrier path-delta forward model",
|
||||
"constants": {
|
||||
"c_mps": 299800000.0
|
||||
},
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "human-standing-at-midpoint",
|
||||
"link_m": 5.0,
|
||||
"scatterer_offset_m": 0.1,
|
||||
"scatterer_position_m": 2.5,
|
||||
"freq_2.4_GHz": {
|
||||
"first_fresnel_radius_m": 0.39515292398428903,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.003998401278721531,
|
||||
"phase_rad_per_subcarrier": [
|
||||
0.20043478616963525,
|
||||
0.2004609731027643,
|
||||
0.20048716003589334,
|
||||
0.20051334696902237,
|
||||
0.2005395339021514,
|
||||
0.20056572083528046,
|
||||
0.2005919077684095,
|
||||
0.20061809470153852,
|
||||
0.20064428163466755,
|
||||
0.2006704685677966,
|
||||
0.2006966555009256,
|
||||
0.20072284243405467,
|
||||
0.2007490293671837,
|
||||
0.2007752163003127,
|
||||
0.20080140323344178,
|
||||
0.2008275901665708,
|
||||
0.20085377709969982,
|
||||
0.20087996403282884,
|
||||
0.20090615096595793,
|
||||
0.20093233789908693,
|
||||
0.20095852483221596,
|
||||
0.20098471176534502,
|
||||
0.20101089869847405,
|
||||
0.20103708563160308,
|
||||
0.20106327256473214,
|
||||
0.20108945949786114,
|
||||
0.2011156464309902,
|
||||
0.20114183336411923,
|
||||
0.20116802029724826,
|
||||
0.2011942072303773,
|
||||
0.20122039416350632,
|
||||
0.20124658109663537,
|
||||
0.2012727680297644,
|
||||
0.20129895496289343,
|
||||
0.20132514189602246,
|
||||
0.20135132882915152,
|
||||
0.20137751576228052,
|
||||
0.20140370269540958,
|
||||
0.2014298896285386,
|
||||
0.20145607656166764,
|
||||
0.20148226349479667,
|
||||
0.20150845042792573,
|
||||
0.20153463736105473,
|
||||
0.20156082429418376,
|
||||
0.20158701122731285,
|
||||
0.20161319816044185,
|
||||
0.20163938509357088,
|
||||
0.20166557202669994,
|
||||
0.20169175895982897,
|
||||
0.201717945892958,
|
||||
0.20174413282608702,
|
||||
0.20177031975921605
|
||||
],
|
||||
"phase_rad_min": 0.20043478616963525,
|
||||
"phase_rad_max": 0.20177031975921605,
|
||||
"phase_rad_spread": 0.0013355335895808007,
|
||||
"phase_wraps": 0
|
||||
},
|
||||
"freq_5.0_GHz": {
|
||||
"first_fresnel_radius_m": 0.27376997644007645,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.003998401278721531,
|
||||
"phase_rad_per_subcarrier": [
|
||||
0.41831006980320795,
|
||||
0.41833625673633695,
|
||||
0.41836244366946607,
|
||||
0.41838863060259507,
|
||||
0.4184148175357241,
|
||||
0.4184410044688532,
|
||||
0.4184671914019822,
|
||||
0.4184933783351112,
|
||||
0.4185195652682403,
|
||||
0.4185457522013693,
|
||||
0.4185719391344983,
|
||||
0.41859812606762736,
|
||||
0.41862431300075637,
|
||||
0.4186504999338854,
|
||||
0.4186766868670145,
|
||||
0.41870287380014354,
|
||||
0.41872906073327254,
|
||||
0.4187552476664016,
|
||||
0.4187814345995306,
|
||||
0.4188076215326596,
|
||||
0.41883380846578866,
|
||||
0.4188599953989178,
|
||||
0.4188861823320468,
|
||||
0.4189123692651758,
|
||||
0.41893855619830483,
|
||||
0.41896474313143384,
|
||||
0.4189909300645629,
|
||||
0.4190171169976919,
|
||||
0.41904330393082095,
|
||||
0.41906949086395,
|
||||
0.41909567779707907,
|
||||
0.41912186473020807,
|
||||
0.4191480516633371,
|
||||
0.41917423859646613,
|
||||
0.41920042552959513,
|
||||
0.4192266124627242,
|
||||
0.41925279939585325,
|
||||
0.41927898632898225,
|
||||
0.4193051732621113,
|
||||
0.41933136019524037,
|
||||
0.41935754712836937,
|
||||
0.4193837340614984,
|
||||
0.4194099209946275,
|
||||
0.4194361079277565,
|
||||
0.4194622948608855,
|
||||
0.41948848179401454,
|
||||
0.41951466872714355,
|
||||
0.4195408556602726,
|
||||
0.4195670425934017,
|
||||
0.4195932295265307,
|
||||
0.4196194164596597,
|
||||
0.4196456033927888
|
||||
],
|
||||
"phase_rad_min": 0.41831006980320795,
|
||||
"phase_rad_max": 0.4196456033927888,
|
||||
"phase_rad_spread": 0.0013355335895808285,
|
||||
"phase_wraps": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "human-walking-into-fresnel",
|
||||
"link_m": 5.0,
|
||||
"scatterer_offset_m": 0.25,
|
||||
"scatterer_position_m": 2.5,
|
||||
"freq_2.4_GHz": {
|
||||
"first_fresnel_radius_m": 0.39515292398428903,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.024937810560444973,
|
||||
"phase_rad_per_subcarrier": [
|
||||
1.2501008225017065,
|
||||
1.2502641489744661,
|
||||
1.2504274754472258,
|
||||
1.2505908019199852,
|
||||
1.250754128392745,
|
||||
1.2509174548655044,
|
||||
1.2510807813382638,
|
||||
1.2512441078110232,
|
||||
1.251407434283783,
|
||||
1.2515707607565425,
|
||||
1.2517340872293021,
|
||||
1.2518974137020618,
|
||||
1.2520607401748214,
|
||||
1.2522240666475808,
|
||||
1.2523873931203406,
|
||||
1.2525507195930998,
|
||||
1.2527140460658595,
|
||||
1.2528773725386189,
|
||||
1.2530406990113787,
|
||||
1.2532040254841381,
|
||||
1.2533673519568977,
|
||||
1.2535306784296574,
|
||||
1.253694004902417,
|
||||
1.2538573313751764,
|
||||
1.254020657847936,
|
||||
1.2541839843206957,
|
||||
1.254347310793455,
|
||||
1.2545106372662147,
|
||||
1.2546739637389743,
|
||||
1.254837290211734,
|
||||
1.2550006166844934,
|
||||
1.2551639431572532,
|
||||
1.2553272696300126,
|
||||
1.2554905961027722,
|
||||
1.2556539225755317,
|
||||
1.2558172490482913,
|
||||
1.2559805755210507,
|
||||
1.2561439019938105,
|
||||
1.25630722846657,
|
||||
1.2564705549393296,
|
||||
1.256633881412089,
|
||||
1.2567972078848488,
|
||||
1.2569605343576082,
|
||||
1.2571238608303679,
|
||||
1.2572871873031273,
|
||||
1.257450513775887,
|
||||
1.2576138402486463,
|
||||
1.2577771667214062,
|
||||
1.2579404931941656,
|
||||
1.2581038196669252,
|
||||
1.2582671461396846,
|
||||
1.2584304726124445
|
||||
],
|
||||
"phase_rad_min": 1.2501008225017065,
|
||||
"phase_rad_max": 1.2584304726124445,
|
||||
"phase_rad_spread": 0.00832965011073794,
|
||||
"phase_wraps": 0
|
||||
},
|
||||
"freq_5.0_GHz": {
|
||||
"first_fresnel_radius_m": 0.27376997644007645,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.024937810560444973,
|
||||
"phase_rad_per_subcarrier": [
|
||||
2.608977075861283,
|
||||
2.609140402334042,
|
||||
2.609303728806802,
|
||||
2.609467055279562,
|
||||
2.6096303817523214,
|
||||
2.6097937082250806,
|
||||
2.6099570346978402,
|
||||
2.6101203611706,
|
||||
2.6102836876433595,
|
||||
2.610447014116119,
|
||||
2.6106103405888783,
|
||||
2.6107736670616384,
|
||||
2.6109369935343976,
|
||||
2.611100320007157,
|
||||
2.611263646479917,
|
||||
2.611426972952677,
|
||||
2.611590299425436,
|
||||
2.6117536258981953,
|
||||
2.6119169523709553,
|
||||
2.6120802788437145,
|
||||
2.612243605316474,
|
||||
2.6124069317892338,
|
||||
2.6125702582619934,
|
||||
2.612733584734753,
|
||||
2.6128969112075127,
|
||||
2.613060237680272,
|
||||
2.613223564153032,
|
||||
2.613386890625791,
|
||||
2.6135502170985507,
|
||||
2.6137135435713104,
|
||||
2.61387687004407,
|
||||
2.6140401965168296,
|
||||
2.614203522989589,
|
||||
2.6143668494623484,
|
||||
2.614530175935108,
|
||||
2.6146935024078677,
|
||||
2.6148568288806273,
|
||||
2.6150201553533865,
|
||||
2.6151834818261466,
|
||||
2.6153468082989058,
|
||||
2.6155101347716654,
|
||||
2.615673461244425,
|
||||
2.615836787717185,
|
||||
2.6160001141899443,
|
||||
2.616163440662704,
|
||||
2.616326767135463,
|
||||
2.616490093608223,
|
||||
2.6166534200809823,
|
||||
2.616816746553742,
|
||||
2.6169800730265016,
|
||||
2.6171433994992612,
|
||||
2.617306725972021
|
||||
],
|
||||
"phase_rad_min": 2.608977075861283,
|
||||
"phase_rad_max": 2.617306725972021,
|
||||
"phase_rad_spread": 0.00832965011073794,
|
||||
"phase_wraps": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scatterer-outside-fresnel",
|
||||
"link_m": 5.0,
|
||||
"scatterer_offset_m": 1.5,
|
||||
"scatterer_position_m": 2.5,
|
||||
"freq_2.4_GHz": {
|
||||
"first_fresnel_radius_m": 0.39515292398428903,
|
||||
"zone": "far-field",
|
||||
"path_delta_m": 0.8309518948453007,
|
||||
"phase_rad_per_subcarrier": [
|
||||
41.65456484993552,
|
||||
41.660007045499924,
|
||||
41.66544924106432,
|
||||
41.67089143662873,
|
||||
41.676333632193135,
|
||||
41.681775827757534,
|
||||
41.68721802332193,
|
||||
41.69266021888634,
|
||||
41.69810241445074,
|
||||
41.703544610015136,
|
||||
41.70898680557954,
|
||||
41.71442900114395,
|
||||
41.71987119670835,
|
||||
41.72531339227275,
|
||||
41.73075558783716,
|
||||
41.73619778340156,
|
||||
41.74163997896596,
|
||||
41.74708217453036,
|
||||
41.75252437009476,
|
||||
41.75796656565916,
|
||||
41.763408761223566,
|
||||
41.76885095678797,
|
||||
41.77429315235237,
|
||||
41.77973534791677,
|
||||
41.78517754348118,
|
||||
41.79061973904558,
|
||||
41.79606193460998,
|
||||
41.801504130174386,
|
||||
41.806946325738785,
|
||||
41.812388521303184,
|
||||
41.81783071686759,
|
||||
41.823272912431996,
|
||||
41.828715107996395,
|
||||
41.834157303560794,
|
||||
41.83959949912521,
|
||||
41.845041694689606,
|
||||
41.850483890254004,
|
||||
41.85592608581841,
|
||||
41.86136828138281,
|
||||
41.86681047694721,
|
||||
41.87225267251161,
|
||||
41.87769486807602,
|
||||
41.88313706364042,
|
||||
41.88857925920482,
|
||||
41.89402145476923,
|
||||
41.89946365033363,
|
||||
41.90490584589803,
|
||||
41.91034804146243,
|
||||
41.91579023702683,
|
||||
41.92123243259123,
|
||||
41.92667462815563,
|
||||
41.932116823720044
|
||||
],
|
||||
"phase_rad_min": 41.65456484993552,
|
||||
"phase_rad_max": 41.932116823720044,
|
||||
"phase_rad_spread": 0.2775519737845258,
|
||||
"phase_wraps": 0
|
||||
},
|
||||
"freq_5.0_GHz": {
|
||||
"first_fresnel_radius_m": 0.27376997644007645,
|
||||
"zone": "far-field",
|
||||
"path_delta_m": 0.8309518948453007,
|
||||
"phase_rad_per_subcarrier": [
|
||||
86.933631945763,
|
||||
86.9390741413274,
|
||||
86.94451633689181,
|
||||
86.94995853245621,
|
||||
86.9554007280206,
|
||||
86.96084292358502,
|
||||
86.9662851191494,
|
||||
86.97172731471382,
|
||||
86.97716951027823,
|
||||
86.98261170584261,
|
||||
86.98805390140703,
|
||||
86.99349609697143,
|
||||
86.99893829253583,
|
||||
87.00438048810022,
|
||||
87.00982268366464,
|
||||
87.01526487922904,
|
||||
87.02070707479345,
|
||||
87.02614927035783,
|
||||
87.03159146592225,
|
||||
87.03703366148665,
|
||||
87.04247585705103,
|
||||
87.04791805261546,
|
||||
87.05336024817986,
|
||||
87.05880244374426,
|
||||
87.06424463930865,
|
||||
87.06968683487307,
|
||||
87.07512903043745,
|
||||
87.08057122600187,
|
||||
87.08601342156628,
|
||||
87.09145561713066,
|
||||
87.09689781269508,
|
||||
87.10234000825947,
|
||||
87.10778220382387,
|
||||
87.11322439938827,
|
||||
87.11866659495267,
|
||||
87.12410879051708,
|
||||
87.1295509860815,
|
||||
87.13499318164588,
|
||||
87.14043537721028,
|
||||
87.1458775727747,
|
||||
87.15131976833908,
|
||||
87.1567619639035,
|
||||
87.1622041594679,
|
||||
87.1676463550323,
|
||||
87.1730885505967,
|
||||
87.17853074616112,
|
||||
87.1839729417255,
|
||||
87.18941513728991,
|
||||
87.19485733285431,
|
||||
87.20029952841871,
|
||||
87.20574172398312,
|
||||
87.21118391954751
|
||||
],
|
||||
"phase_rad_min": 86.933631945763,
|
||||
"phase_rad_max": 87.21118391954751,
|
||||
"phase_rad_spread": 0.2775519737845116,
|
||||
"phase_wraps": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "scatterer-near-Tx",
|
||||
"link_m": 5.0,
|
||||
"scatterer_offset_m": 0.05,
|
||||
"scatterer_position_m": 0.5,
|
||||
"freq_2.4_GHz": {
|
||||
"first_fresnel_radius_m": 0.23709175439057345,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.002771550260963096,
|
||||
"phase_rad_per_subcarrier": [
|
||||
0.13893430028417714,
|
||||
0.13895245213945337,
|
||||
0.13897060399472957,
|
||||
0.13898875585000578,
|
||||
0.139006907705282,
|
||||
0.13902505956055825,
|
||||
0.13904321141583445,
|
||||
0.13906136327111066,
|
||||
0.1390795151263869,
|
||||
0.1390976669816631,
|
||||
0.13911581883693933,
|
||||
0.13913397069221556,
|
||||
0.13915212254749174,
|
||||
0.13917027440276797,
|
||||
0.1391884262580442,
|
||||
0.1392065781133204,
|
||||
0.13922472996859664,
|
||||
0.13924288182387284,
|
||||
0.13926103367914908,
|
||||
0.13927918553442528,
|
||||
0.13929733738970151,
|
||||
0.13931548924497772,
|
||||
0.13933364110025395,
|
||||
0.13935179295553016,
|
||||
0.1393699448108064,
|
||||
0.1393880966660826,
|
||||
0.13940624852135883,
|
||||
0.13942440037663503,
|
||||
0.13944255223191127,
|
||||
0.13946070408718747,
|
||||
0.13947885594246368,
|
||||
0.1394970077977399,
|
||||
0.13951515965301614,
|
||||
0.13953331150829235,
|
||||
0.13955146336356858,
|
||||
0.1395696152188448,
|
||||
0.139587767074121,
|
||||
0.13960591892939725,
|
||||
0.13962407078467345,
|
||||
0.13964222263994966,
|
||||
0.13966037449522586,
|
||||
0.13967852635050212,
|
||||
0.1396966782057783,
|
||||
0.13971483006105453,
|
||||
0.13973298191633077,
|
||||
0.13975113377160697,
|
||||
0.13976928562688318,
|
||||
0.1397874374821594,
|
||||
0.13980558933743562,
|
||||
0.13982374119271185,
|
||||
0.13984189304798808,
|
||||
0.13986004490326429
|
||||
],
|
||||
"phase_rad_min": 0.13893430028417714,
|
||||
"phase_rad_max": 0.13986004490326429,
|
||||
"phase_rad_spread": 0.0009257446190871488,
|
||||
"phase_wraps": 0
|
||||
},
|
||||
"freq_5.0_GHz": {
|
||||
"first_fresnel_radius_m": 0.16426198586404586,
|
||||
"zone": "zone-1",
|
||||
"path_delta_m": 0.002771550260963096,
|
||||
"phase_rad_per_subcarrier": [
|
||||
0.28995773618231585,
|
||||
0.28997588803759206,
|
||||
0.2899940398928683,
|
||||
0.2900121917481445,
|
||||
0.2900303436034208,
|
||||
0.29004849545869693,
|
||||
0.29006664731397314,
|
||||
0.29008479916924934,
|
||||
0.29010295102452566,
|
||||
0.29012110287980186,
|
||||
0.29013925473507807,
|
||||
0.2901574065903542,
|
||||
0.2901755584456305,
|
||||
0.2901937103009067,
|
||||
0.2902118621561829,
|
||||
0.29023001401145915,
|
||||
0.2902481658667354,
|
||||
0.29026631772201156,
|
||||
0.29028446957728776,
|
||||
0.290302621432564,
|
||||
0.29032077328784023,
|
||||
0.2903389251431165,
|
||||
0.2903570769983927,
|
||||
0.2903752288536689,
|
||||
0.2903933807089451,
|
||||
0.2904115325642213,
|
||||
0.2904296844194975,
|
||||
0.2904478362747738,
|
||||
0.29046598813005,
|
||||
0.2904841399853262,
|
||||
0.2905022918406024,
|
||||
0.29052044369587865,
|
||||
0.29053859555115485,
|
||||
0.29055674740643106,
|
||||
0.29057489926170726,
|
||||
0.2905930511169835,
|
||||
0.29061120297225973,
|
||||
0.29062935482753594,
|
||||
0.2906475066828122,
|
||||
0.2906656585380884,
|
||||
0.2906838103933646,
|
||||
0.2907019622486408,
|
||||
0.29072011410391707,
|
||||
0.2907382659591933,
|
||||
0.2907564178144695,
|
||||
0.2907745696697457,
|
||||
0.2907927215250219,
|
||||
0.2908108733802981,
|
||||
0.29082902523557436,
|
||||
0.29084717709085056,
|
||||
0.2908653289461268,
|
||||
0.290883480801403
|
||||
],
|
||||
"phase_rad_min": 0.28995773618231585,
|
||||
"phase_rad_max": 0.290883480801403,
|
||||
"phase_rad_spread": 0.0009257446190871765,
|
||||
"phase_wraps": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"first_fresnel_radii_m": {
|
||||
"2.4": {
|
||||
"wavelength_mm": 124.91666666666666,
|
||||
"link_2.0m": {
|
||||
"p=0.10": 0.14994999166388773,
|
||||
"p=0.25": 0.2164341701303193,
|
||||
"p=0.50": 0.2499166527731462,
|
||||
"p=0.75": 0.2164341701303193,
|
||||
"p=0.90": 0.1499499916638877
|
||||
},
|
||||
"link_5.0m": {
|
||||
"p=0.10": 0.23709175439057345,
|
||||
"p=0.25": 0.3422124705500955,
|
||||
"p=0.50": 0.39515292398428903,
|
||||
"p=0.75": 0.3422124705500955,
|
||||
"p=0.90": 0.2370917543905734
|
||||
},
|
||||
"link_10.0m": {
|
||||
"p=0.10": 0.3352983745859798,
|
||||
"p=0.25": 0.48396151706514845,
|
||||
"p=0.50": 0.5588306243099662,
|
||||
"p=0.75": 0.48396151706514845,
|
||||
"p=0.90": 0.3352983745859797
|
||||
}
|
||||
},
|
||||
"5.0": {
|
||||
"wavelength_mm": 59.96,
|
||||
"link_2.0m": {
|
||||
"p=0.10": 0.10388840166255327,
|
||||
"p=0.25": 0.14994999166388773,
|
||||
"p=0.50": 0.17314733610425545,
|
||||
"p=0.75": 0.14994999166388773,
|
||||
"p=0.90": 0.10388840166255325
|
||||
},
|
||||
"link_5.0m": {
|
||||
"p=0.10": 0.16426198586404586,
|
||||
"p=0.25": 0.23709175439057345,
|
||||
"p=0.50": 0.27376997644007645,
|
||||
"p=0.75": 0.23709175439057345,
|
||||
"p=0.90": 0.16426198586404583
|
||||
},
|
||||
"link_10.0m": {
|
||||
"p=0.10": 0.23230152819127128,
|
||||
"p=0.25": 0.3352983745859798,
|
||||
"p=0.50": 0.3871692136521188,
|
||||
"p=0.75": 0.3352983745859798,
|
||||
"p=0.90": 0.23230152819127126
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R6 — Fresnel-zone forward model for CSI sensitivity.
|
||||
|
||||
See docs/research/sota-2026-05-22/R6-fresnel-forward-model.md.
|
||||
|
||||
For a Tx-Rx link, the first Fresnel zone is a prolate ellipsoid whose
|
||||
radius at fractional position p (0..1) along the LOS path is:
|
||||
|
||||
r_n(p) = sqrt(n * lambda * d * p * (1-p)) (for n=1)
|
||||
|
||||
A point scatterer that crosses the first Fresnel zone perpendicular to
|
||||
the LOS introduces a path-length delta:
|
||||
|
||||
delta_l(x) = sqrt(d1^2 + x^2) + sqrt(d2^2 + x^2) - d1 - d2
|
||||
|
||||
where x is the perpendicular offset. Phase shift on subcarrier k:
|
||||
|
||||
phi_k = 2 * pi * f_k * delta_l / c
|
||||
|
||||
This is the bedrock forward model that the existing `wifi-densepose-signal`
|
||||
DSP implicitly assumes. We make it explicit so:
|
||||
|
||||
1. R12's revision path (PABS basis grounded in Fresnel geometry) has
|
||||
somewhere to start.
|
||||
2. R10's foliage-range estimates can be sanity-checked against Fresnel-
|
||||
ellipsoid clearance, not just FSPL + foliage attenuation.
|
||||
3. Multi-subcarrier interference patterns from real scatterers become
|
||||
predictable rather than mysterious.
|
||||
|
||||
Pure NumPy — emits a JSON file with the predictions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
C = 2.998e8 # speed of light, m/s
|
||||
|
||||
|
||||
def wavelength_m(freq_ghz: float) -> float:
|
||||
return C / (freq_ghz * 1e9)
|
||||
|
||||
|
||||
def fresnel_radius_m(freq_ghz: float, link_length_m: float, p: float, n: int = 1) -> float:
|
||||
"""Radius of the n-th Fresnel zone at fractional link position p.
|
||||
|
||||
p=0 is at Tx, p=1 is at Rx. r is maximum at p=0.5 (midpoint).
|
||||
"""
|
||||
lam = wavelength_m(freq_ghz)
|
||||
return float(np.sqrt(n * lam * link_length_m * p * (1.0 - p)))
|
||||
|
||||
|
||||
def path_delta_m(d1: float, d2: float, perpendicular_offset_m: float) -> float:
|
||||
"""Extra path length introduced by a point scatterer at perpendicular
|
||||
offset x from the LOS, with d1 / d2 the Tx- and Rx-side LOS distances."""
|
||||
x = perpendicular_offset_m
|
||||
return float(np.sqrt(d1**2 + x**2) + np.sqrt(d2**2 + x**2) - (d1 + d2))
|
||||
|
||||
|
||||
def csi_phase_shift_rad(freq_ghz: float, path_delta: float) -> float:
|
||||
"""Phase shift on a single subcarrier given the path-length delta."""
|
||||
return 2 * np.pi * freq_ghz * 1e9 * path_delta / C
|
||||
|
||||
|
||||
def fresnel_zone_classification(freq_ghz: float, link_length_m: float,
|
||||
scatterer_offset_m: float,
|
||||
scatterer_position_m: float) -> str:
|
||||
"""Is the scatterer inside the n-th Fresnel zone?
|
||||
|
||||
Zone n is the volume where r_{n-1} < |offset| <= r_n.
|
||||
"""
|
||||
p = scatterer_position_m / link_length_m
|
||||
if not (0 <= p <= 1):
|
||||
return "outside-link"
|
||||
abs_off = abs(scatterer_offset_m)
|
||||
for n in range(1, 10):
|
||||
r = fresnel_radius_m(freq_ghz, link_length_m, p, n)
|
||||
if abs_off <= r:
|
||||
return f"zone-{n}"
|
||||
return "far-field"
|
||||
|
||||
|
||||
def subcarrier_phase_sweep(freq_ghz: float, link_length_m: float,
|
||||
scatterer_offset_m: float,
|
||||
scatterer_position_m: float,
|
||||
n_subcarriers: int = 52,
|
||||
subcarrier_spacing_khz: float = 312.5) -> dict:
|
||||
"""Predict per-subcarrier phase shift from a single scatterer.
|
||||
|
||||
Uses 802.11n/ac 20 MHz channels: 52 used subcarriers, spaced 312.5 kHz.
|
||||
Subcarrier indices -26..26 excluding DC/pilot tones (we don't bother
|
||||
excluding here — pure sweep).
|
||||
"""
|
||||
d1 = scatterer_position_m
|
||||
d2 = link_length_m - scatterer_position_m
|
||||
if d1 <= 0 or d2 <= 0:
|
||||
raise ValueError("scatterer_position_m must be strictly inside [0, link_length_m]")
|
||||
delta = path_delta_m(d1, d2, scatterer_offset_m)
|
||||
# subcarrier frequencies
|
||||
sub_offsets_hz = (np.arange(n_subcarriers) - n_subcarriers // 2) * subcarrier_spacing_khz * 1e3
|
||||
f_per_sub = freq_ghz * 1e9 + sub_offsets_hz
|
||||
phases_rad = 2 * np.pi * f_per_sub * delta / C
|
||||
return {
|
||||
"path_delta_m": delta,
|
||||
"phase_rad_per_subcarrier": phases_rad.tolist(),
|
||||
"phase_rad_min": float(phases_rad.min()),
|
||||
"phase_rad_max": float(phases_rad.max()),
|
||||
"phase_rad_spread": float(phases_rad.max() - phases_rad.min()),
|
||||
"phase_wraps": int(np.floor((phases_rad.max() - phases_rad.min()) / (2 * np.pi))),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r6_fresnel_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Scenario: 5-metre indoor link (typical bedroom/lab setup)
|
||||
link_lengths = [2.0, 5.0, 10.0]
|
||||
freqs = [2.4, 5.0]
|
||||
p_grid = [0.1, 0.25, 0.5, 0.75, 0.9] # link position fractions
|
||||
|
||||
out = {
|
||||
"model": "first-Fresnel-zone ellipsoid + per-subcarrier path-delta forward model",
|
||||
"constants": {"c_mps": C},
|
||||
"scenarios": [],
|
||||
}
|
||||
|
||||
# 1. First Fresnel radii (the basic envelope)
|
||||
fresnel = {}
|
||||
for f in freqs:
|
||||
fresnel[str(f)] = {}
|
||||
lam = wavelength_m(f)
|
||||
fresnel[str(f)]["wavelength_mm"] = lam * 1000
|
||||
for L in link_lengths:
|
||||
radii = {f"p={p:.2f}": fresnel_radius_m(f, L, p, n=1) for p in p_grid}
|
||||
fresnel[str(f)][f"link_{L}m"] = radii
|
||||
out["first_fresnel_radii_m"] = fresnel
|
||||
|
||||
# 2. Single-scatterer per-subcarrier sweep
|
||||
# Scatterer at midpoint, 10 cm off LOS (human standing near link)
|
||||
scenarios = [
|
||||
("human-standing-at-midpoint", 5.0, 0.10, 2.5),
|
||||
("human-walking-into-fresnel", 5.0, 0.25, 2.5),
|
||||
("scatterer-outside-fresnel", 5.0, 1.50, 2.5),
|
||||
("scatterer-near-Tx", 5.0, 0.05, 0.5),
|
||||
]
|
||||
for name, L, x_off, x_pos in scenarios:
|
||||
case = {"name": name, "link_m": L, "scatterer_offset_m": x_off,
|
||||
"scatterer_position_m": x_pos}
|
||||
for f in freqs:
|
||||
r1 = fresnel_radius_m(f, L, x_pos / L, n=1)
|
||||
zone = fresnel_zone_classification(f, L, x_off, x_pos)
|
||||
sweep = subcarrier_phase_sweep(f, L, x_off, x_pos)
|
||||
case[f"freq_{f}_GHz"] = {
|
||||
"first_fresnel_radius_m": r1,
|
||||
"zone": zone,
|
||||
**sweep,
|
||||
}
|
||||
out["scenarios"].append(case)
|
||||
|
||||
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(args.out).write_text(json.dumps(out, indent=2))
|
||||
|
||||
print("=== First Fresnel zone radii (m) ===")
|
||||
print(f"{'freq':>5} {'lambda':>8} {'link':>5} " + " ".join(f"p={p:.2f}" for p in p_grid))
|
||||
for f in freqs:
|
||||
lam_mm = wavelength_m(f) * 1000
|
||||
for L in link_lengths:
|
||||
radii = [fresnel_radius_m(f, L, p, n=1) for p in p_grid]
|
||||
row = f"{f:>5.1f} {lam_mm:>5.1f}mm {L:>4.1f}m " + " ".join(f"{r:>6.3f}" for r in radii)
|
||||
print(row)
|
||||
print()
|
||||
|
||||
print("=== Single-scatterer per-subcarrier predictions ===")
|
||||
for case in out["scenarios"]:
|
||||
print(f"{case['name']:>32} ", end="")
|
||||
for f in freqs:
|
||||
k = f"freq_{f}_GHz"
|
||||
v = case[k]
|
||||
print(f"{f:.1f}GHz: r1={v['first_fresnel_radius_m']*100:.1f}cm "
|
||||
f"zone={v['zone']:<8} "
|
||||
f"phase-spread={np.degrees(v['phase_rad_spread']):.3f} deg "
|
||||
f"wraps={v['phase_wraps']}", end=" ")
|
||||
print()
|
||||
print()
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,6 +11,7 @@
|
||||
import { z } from "zod";
|
||||
import type { RuviewConfig, SensingLatestResponse } from "../types.js";
|
||||
import { sensingGet } from "../http.js";
|
||||
import { validateSensingLatestResponse } from "../validate.js";
|
||||
|
||||
export const csiLatestSchema = z.object({
|
||||
/** Override the sensing-server URL for this call only. */
|
||||
@@ -49,6 +50,20 @@ export async function csiLatest(
|
||||
};
|
||||
}
|
||||
|
||||
const validation = validateSensingLatestResponse(result.data);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
warn: true,
|
||||
error: `Sensing-server response failed schema validation: ${validation.errors.join("; ")}`,
|
||||
raw_response: result.data,
|
||||
hint:
|
||||
"The sensing-server may have upgraded its schema. " +
|
||||
"Check schema_version in the raw_response and update " +
|
||||
"ruview-mcp/src/types.ts if needed.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
ts: result.data.window.ts,
|
||||
@@ -56,7 +71,7 @@ export async function csiLatest(
|
||||
captured_at: result.data.captured_at,
|
||||
n_paths: result.data.window.n_paths,
|
||||
node_mac: result.data.window.node_mac,
|
||||
subcarriers: 56,
|
||||
subcarriers: result.data.window.amplitudes.length,
|
||||
frames: result.data.window.amplitudes[0]?.length ?? 0,
|
||||
window: result.data.window,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Runtime schema validation for sensing-server responses.
|
||||
*
|
||||
* These validators catch schema drift (when the sensing-server's API
|
||||
* changes without updating the MCP layer) and provide actionable errors
|
||||
* to the calling agent rather than silently returning malformed data.
|
||||
*
|
||||
* The schema is pinned to sensing-server schema version 2 per ADR-101
|
||||
* frame_subscriber.rs. When the server bumps schema_version, a validation
|
||||
* error here is the correct signal to update the MCP types.
|
||||
*/
|
||||
|
||||
export type ValidationResult =
|
||||
| { valid: true }
|
||||
| { valid: false; errors: string[] };
|
||||
|
||||
/**
|
||||
* Validate a CsiWindow conforms to the expected 56×20 shape.
|
||||
*/
|
||||
export function validateCsiWindow(window: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof window !== "object" || window === null) {
|
||||
return { valid: false, errors: ["window is not an object"] };
|
||||
}
|
||||
|
||||
const w = window as Record<string, unknown>;
|
||||
|
||||
if (typeof w["ts"] !== "number") {
|
||||
errors.push("window.ts must be a number");
|
||||
}
|
||||
|
||||
if (typeof w["n_paths"] !== "number") {
|
||||
errors.push("window.n_paths must be a number");
|
||||
}
|
||||
|
||||
const amplitudes = w["amplitudes"];
|
||||
if (!Array.isArray(amplitudes)) {
|
||||
errors.push("window.amplitudes must be an array");
|
||||
} else {
|
||||
if (amplitudes.length !== 56) {
|
||||
errors.push(
|
||||
`window.amplitudes must have 56 rows (subcarriers), got ${amplitudes.length}`
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < Math.min(amplitudes.length, 3); i++) {
|
||||
if (!Array.isArray(amplitudes[i])) {
|
||||
errors.push(`window.amplitudes[${i}] must be an array`);
|
||||
} else if ((amplitudes[i] as unknown[]).length !== 20) {
|
||||
errors.push(
|
||||
`window.amplitudes[${i}] must have 20 frames, got ${(amplitudes[i] as unknown[]).length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a full SensingLatestResponse (schema_version 2, ADR-101).
|
||||
*/
|
||||
export function validateSensingLatestResponse(data: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return { valid: false, errors: ["response is not an object"] };
|
||||
}
|
||||
|
||||
const d = data as Record<string, unknown>;
|
||||
|
||||
const schemaVersion = d["schema_version"];
|
||||
if (typeof schemaVersion !== "number") {
|
||||
errors.push("schema_version must be a number");
|
||||
} else if (schemaVersion !== 2) {
|
||||
errors.push(
|
||||
`schema_version ${schemaVersion} is not supported. ` +
|
||||
"This MCP server is pinned to schema_version 2 (ADR-101). " +
|
||||
"Update tools/ruview-mcp/src/types.ts to support the new schema."
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof d["captured_at"] !== "string") {
|
||||
errors.push("captured_at must be a string (ISO-8601)");
|
||||
}
|
||||
|
||||
const windowResult = validateCsiWindow(d["window"]);
|
||||
if (!windowResult.valid) {
|
||||
errors.push(...windowResult.errors.map((e) => `window: ${e}`));
|
||||
}
|
||||
|
||||
return errors.length === 0 ? { valid: true } : { valid: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tests for runtime schema validators (validate.ts).
|
||||
*
|
||||
* Pinned to sensing-server schema_version 2 (ADR-101).
|
||||
* These tests document the exact shapes we accept and reject so that
|
||||
* any schema drift from the sensing-server is caught immediately.
|
||||
*/
|
||||
|
||||
import { validateCsiWindow, validateSensingLatestResponse } from "../src/validate.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeAmplitudes(rows = 56, cols = 20): number[][] {
|
||||
return Array.from({ length: rows }, () => Array.from({ length: cols }, () => 0));
|
||||
}
|
||||
|
||||
function makeValidWindow(): unknown {
|
||||
return {
|
||||
ts: 1716300000.0,
|
||||
n_paths: 3,
|
||||
amplitudes: makeAmplitudes(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeValidResponse(): unknown {
|
||||
return {
|
||||
schema_version: 2,
|
||||
captured_at: "2026-05-21T20:00:00.000Z",
|
||||
window: makeValidWindow(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateCsiWindow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateCsiWindow", () => {
|
||||
it("accepts a valid 56×20 window", () => {
|
||||
const result = validateCsiWindow(makeValidWindow());
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null", () => {
|
||||
const result = validateCsiWindow(null);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors).toContain("window is not an object");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong subcarrier count (e.g. 57)", () => {
|
||||
const w = makeValidWindow() as Record<string, unknown>;
|
||||
w["amplitudes"] = makeAmplitudes(57, 20);
|
||||
const result = validateCsiWindow(w);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("56 rows"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wrong frame count (e.g. 10 instead of 20)", () => {
|
||||
const w = makeValidWindow() as Record<string, unknown>;
|
||||
w["amplitudes"] = makeAmplitudes(56, 10);
|
||||
const result = validateCsiWindow(w);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("20 frames"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing ts field", () => {
|
||||
const w = makeValidWindow() as Record<string, unknown>;
|
||||
delete w["ts"];
|
||||
const result = validateCsiWindow(w);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("ts"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateSensingLatestResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateSensingLatestResponse", () => {
|
||||
it("accepts a valid schema_version 2 response", () => {
|
||||
const result = validateSensingLatestResponse(makeValidResponse());
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects schema_version 3 (not yet supported)", () => {
|
||||
const d = makeValidResponse() as Record<string, unknown>;
|
||||
d["schema_version"] = 3;
|
||||
const result = validateSensingLatestResponse(d);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("schema_version 3 is not supported"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing captured_at", () => {
|
||||
const d = makeValidResponse() as Record<string, unknown>;
|
||||
delete d["captured_at"];
|
||||
const result = validateSensingLatestResponse(d);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("captured_at"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects null response", () => {
|
||||
const result = validateSensingLatestResponse(null);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.includes("not an object"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates window validation errors with 'window:' prefix", () => {
|
||||
const d = makeValidResponse() as Record<string, unknown>;
|
||||
const w = (d["window"] as Record<string, unknown>);
|
||||
w["amplitudes"] = makeAmplitudes(57, 20);
|
||||
const result = validateSensingLatestResponse(d);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((e) => e.startsWith("window:"))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Vendored
+1
-1
Submodule vendor/ruvector updated: 53f0419782...f1204835d1
Vendored
+1
-1
Submodule vendor/sublinear-time-solver updated: c25dddf163...657fed8538
Reference in New Issue
Block a user