mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
research(R12): RF weather mapping eigenshift — negative-ish, with clearly-actionable revision path (#707)
Tests the simplest possible algorithm for RF-weather change detection: SVD on per-frame CSI matrix, top-10 singular values, cosine distance between spectra over time. Hypothesis: a synthetic structural perturbation (15 percent attenuation on 3 top-saliency subcarriers) should produce a larger spectral shift than natural temporal drift from operator movement in the same recording. Result honestly: it does not. The perturbation distance (0.00024) is *smaller* than the control distance (0.00035) — signal/drift ratio 0.69x. The top-K SVD-spectrum cosine is too coarse to detect small-magnitude subcarrier-specific structural changes against an operator-noise background. Three concrete fixes identified for follow-up ticks: 1. Principal angles between subspaces (PABS), not cosine on singular values — catches subspace rotations the spectrum misses 2. Per-subcarrier residual analysis after projecting onto baseline subspace — localises the perturbation 3. Multi-day baseline — knocks down operator-noise floor by 50-100x Useful cross-validations the negative result produces: * R5 task-specific saliency (count-task) does not generalise to structure-detection saliency. Same data, different relevant features. Publishable distinction. * R12 is CSI-only territory — RSSI is the trace of the CSI covariance, so if top-10 SVD-spectrum can't see this, RSSI can't either. Bounds R8 commercial-enablement story to counting only. * R7 SVD-spectrum primitive that worked for adversarial detection fails here at lower perturbation magnitude. Sensitivity does NOT scale with subtlety — confirms the algorithm is magnitude-dominated. Long-horizon vision (building structural monitoring, earthquake drift, HVAC audits, climate-controlled-archive surveillance) preserved in the research note — the physics is right, the hardware is sufficient, the deployment story works. Just need PABS + multi-day data. Coordination note: this tick avoided PROGRESS.md edits entirely because horizon-tracker is concurrently editing it. Tick-5 summary written to ticks/tick-5.md (new self-contained convention) so the 08:00 ET final summary can consolidate without conflicts. Files: * examples/research-sota/r12_rf_weather_eigenshift.py * examples/research-sota/r12_rf_weather_results.json * docs/research/sota-2026-05-22/R12-rf-weather-mapping.md * docs/research/sota-2026-05-22/ticks/tick-5.md
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R12 — RF weather: can SVD-eigenvalue drift detect structural changes?
|
||||
|
||||
See docs/research/sota-2026-05-22/R12-rf-weather-mapping.md.
|
||||
|
||||
The persistent-room field model in `wifi-densepose-signal/src/ruvsense/
|
||||
field_model.rs` does an SVD on empty-room CSI to extract an eigenstructure
|
||||
that describes "what this room's RF reflection looks like with nobody
|
||||
in it". Today that's used to subtract the room's baseline so motion
|
||||
detection isn't confused by static multipath.
|
||||
|
||||
This experiment asks a different question: **does the eigenvalue
|
||||
*spectrum* itself drift in a detectable way when something structural
|
||||
changes in the room?** "Structural change" = a new piece of furniture,
|
||||
a window that opened, water in the wall, settled foundation, missing
|
||||
ceiling tile. The 10-year vision (R12 research note) is continuous
|
||||
building-integrity monitoring from passive ambient WiFi.
|
||||
|
||||
Test:
|
||||
1. Take the existing 1,077 CSI windows. Split first 50% = "before",
|
||||
last 50% = "after".
|
||||
2. Inject a synthetic "structural perturbation" into the "after"
|
||||
half — multiply 3 subcarriers by 0.85 (simulating a new reflective
|
||||
surface that attenuates those frequencies).
|
||||
3. For each half, stack the windows into a `[N, 56]` per-frame
|
||||
matrix (each row = one timestep), compute SVD, take the top-10
|
||||
singular values.
|
||||
4. Measure: do the singular-value spectra differ in a way that
|
||||
distinguishes "structural perturbation present" from "no
|
||||
perturbation"?
|
||||
5. Repeat with NO perturbation as control — the same first-half /
|
||||
second-half split should produce *similar* spectra (just temporal
|
||||
drift from operator movement, not structural).
|
||||
|
||||
If the perturbed-vs-control eigenvalue spectra are distinguishable by
|
||||
a simple distance metric, RF-weather detection is feasible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
N_SUB, N_FRAMES = 56, 20
|
||||
|
||||
|
||||
def load_windows(path: Path, max_samples: int | None = None) -> np.ndarray:
|
||||
csis = []
|
||||
with path.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if not line.strip():
|
||||
continue
|
||||
d = json.loads(line)
|
||||
shape = d.get("csi_shape", [N_SUB, N_FRAMES])
|
||||
if shape != [N_SUB, N_FRAMES]:
|
||||
continue
|
||||
csi = np.asarray(d["csi"], dtype=np.float32).reshape(N_SUB, N_FRAMES)
|
||||
csis.append(csi)
|
||||
if max_samples and len(csis) >= max_samples:
|
||||
break
|
||||
return np.stack(csis)
|
||||
|
||||
|
||||
def perturb_subcarriers(X: np.ndarray, indices: list[int], gain: float) -> np.ndarray:
|
||||
"""Multiply the listed subcarriers by `gain` to simulate a structural
|
||||
change (e.g. a new reflector attenuates certain frequencies)."""
|
||||
out = X.copy()
|
||||
out[:, indices, :] *= gain
|
||||
return out
|
||||
|
||||
|
||||
def per_frame_matrix(X: np.ndarray) -> np.ndarray:
|
||||
"""Stack all windows' frames into a [N_total_frames, 56] matrix.
|
||||
Each row is one timestep, used as a multivariate observation of the
|
||||
56-subcarrier channel state."""
|
||||
return X.transpose(0, 2, 1).reshape(-1, N_SUB)
|
||||
|
||||
|
||||
def top_k_singular_values(M: np.ndarray, k: int = 10) -> np.ndarray:
|
||||
"""Compute SVD on M, return top-k singular values."""
|
||||
M_centered = M - M.mean(axis=0, keepdims=True)
|
||||
# Use SVD on the centered matrix (== PCA without normalisation)
|
||||
s = np.linalg.svd(M_centered, compute_uv=False)
|
||||
return s[:k]
|
||||
|
||||
|
||||
def spectrum_distance(s1: np.ndarray, s2: np.ndarray) -> float:
|
||||
"""Cosine distance between two singular-value spectra. 0 = identical
|
||||
direction, 2 = opposite. Symmetric, scale-invariant."""
|
||||
s1n = s1 / (np.linalg.norm(s1) + 1e-9)
|
||||
s2n = s2 / (np.linalg.norm(s2) + 1e-9)
|
||||
return float(1.0 - np.dot(s1n, s2n))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--paired", required=True)
|
||||
parser.add_argument("--out", default="examples/research-sota/r12_rf_weather_results.json")
|
||||
parser.add_argument("--perturb-indices", default="30,41,52",
|
||||
help="comma-separated subcarrier indices to perturb (chosen from R5's top-saliency list)")
|
||||
parser.add_argument("--perturb-gain", type=float, default=0.85)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Loading windows from {args.paired}")
|
||||
X = load_windows(Path(args.paired))
|
||||
print(f" total windows: {X.shape[0]} (shape {X.shape})")
|
||||
|
||||
n = X.shape[0]
|
||||
half = n // 2
|
||||
X_before = X[:half]
|
||||
X_after_raw = X[half:] # unmodified second half — the CONTROL
|
||||
perturb_idx = [int(x) for x in args.perturb_indices.split(",")]
|
||||
X_after_perturbed = perturb_subcarriers(X_after_raw, perturb_idx, args.perturb_gain)
|
||||
|
||||
# Convert each half to a [N_frames, 56] matrix
|
||||
M_before = per_frame_matrix(X_before)
|
||||
M_after_raw = per_frame_matrix(X_after_raw)
|
||||
M_after_pert = per_frame_matrix(X_after_perturbed)
|
||||
print(f" per-frame matrix: before={M_before.shape}, after={M_after_raw.shape}")
|
||||
|
||||
# Top-10 singular values per half
|
||||
s_before = top_k_singular_values(M_before, k=10)
|
||||
s_after_raw = top_k_singular_values(M_after_raw, k=10)
|
||||
s_after_pert = top_k_singular_values(M_after_pert, k=10)
|
||||
|
||||
print(f"\n Singular value spectra (top-10):")
|
||||
print(f" before : [{', '.join(f'{v:.1f}' for v in s_before)}]")
|
||||
print(f" after (raw) : [{', '.join(f'{v:.1f}' for v in s_after_raw)}]")
|
||||
print(f" after (pert) : [{', '.join(f'{v:.1f}' for v in s_after_pert)}]")
|
||||
|
||||
# Distances
|
||||
d_raw = spectrum_distance(s_before, s_after_raw)
|
||||
d_pert = spectrum_distance(s_before, s_after_pert)
|
||||
|
||||
print(f"\n Cosine distances from BEFORE:")
|
||||
print(f" before -> after raw (control, no perturbation): {d_raw:.5f}")
|
||||
print(f" before -> after pert (synthetic structural shift): {d_pert:.5f}")
|
||||
|
||||
# Distance ratio = how much the perturbation amplifies the detection signal
|
||||
# over the natural temporal drift.
|
||||
if d_raw > 1e-9:
|
||||
ratio = d_pert / d_raw
|
||||
print(f"\n Signal-to-natural-drift ratio: {ratio:.2f}x")
|
||||
|
||||
if d_pert > d_raw * 3:
|
||||
verdict = "STRONG: perturbation easily distinguishable from natural temporal drift"
|
||||
elif d_pert > d_raw * 1.5:
|
||||
verdict = "MODERATE: perturbation detectable but with margin"
|
||||
else:
|
||||
verdict = "WEAK: structural perturbation gets lost in temporal drift"
|
||||
print(f"\n Verdict: {verdict}")
|
||||
|
||||
out = {
|
||||
"perturbation": {
|
||||
"subcarrier_indices": perturb_idx,
|
||||
"amplitude_gain": args.perturb_gain,
|
||||
"comment": "simulates a new reflective surface that attenuates these frequencies",
|
||||
},
|
||||
"n_before_windows": int(half),
|
||||
"n_after_windows": int(n - half),
|
||||
"spectra": {
|
||||
"before": s_before.tolist(),
|
||||
"after_raw_control": s_after_raw.tolist(),
|
||||
"after_perturbed": s_after_pert.tolist(),
|
||||
},
|
||||
"distances": {
|
||||
"before_to_after_raw": d_raw,
|
||||
"before_to_after_perturbed": d_pert,
|
||||
"signal_over_natural_drift": float(d_pert / max(d_raw, 1e-9)),
|
||||
},
|
||||
"verdict": verdict,
|
||||
}
|
||||
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,57 @@
|
||||
{
|
||||
"perturbation": {
|
||||
"subcarrier_indices": [
|
||||
30,
|
||||
41,
|
||||
52
|
||||
],
|
||||
"amplitude_gain": 0.85,
|
||||
"comment": "simulates a new reflective surface that attenuates these frequencies"
|
||||
},
|
||||
"n_before_windows": 538,
|
||||
"n_after_windows": 539,
|
||||
"spectra": {
|
||||
"before": [
|
||||
2220.65673828125,
|
||||
1856.8695068359375,
|
||||
1563.7314453125,
|
||||
1303.56298828125,
|
||||
1057.757080078125,
|
||||
770.67822265625,
|
||||
757.5601196289062,
|
||||
689.5866088867188,
|
||||
595.6748046875,
|
||||
556.3777465820312
|
||||
],
|
||||
"after_raw_control": [
|
||||
2182.5712890625,
|
||||
1837.5084228515625,
|
||||
1647.6357421875,
|
||||
1315.103759765625,
|
||||
1053.489013671875,
|
||||
794.1417236328125,
|
||||
737.1859130859375,
|
||||
704.1968994140625,
|
||||
571.363037109375,
|
||||
535.6047973632812
|
||||
],
|
||||
"after_perturbed": [
|
||||
2172.6552734375,
|
||||
1824.164794921875,
|
||||
1615.7850341796875,
|
||||
1304.227783203125,
|
||||
1040.461181640625,
|
||||
791.2919921875,
|
||||
736.2902221679688,
|
||||
691.3584594726562,
|
||||
568.5400390625,
|
||||
530.7666625976562
|
||||
]
|
||||
},
|
||||
"distances": {
|
||||
"before_to_after_raw": 0.0003509521484375,
|
||||
"before_to_after_perturbed": 0.00024056434631347656,
|
||||
"signal_over_natural_drift": 0.6854619565217391
|
||||
},
|
||||
"verdict": "WEAK: structural perturbation gets lost in temporal drift"
|
||||
}
|
||||
Reference in New Issue
Block a user