Files
ruvnet--RuView/examples/research-sota/r6_1_multiscatterer.py
T
rUv bac6962689 research(R6.1): multi-scatterer Fresnel — discovers 4.7 dB penalty matching R13's 5-dB shortfall (#721)
Extends R6's point-scatterer to distributed-body model (6 scatterers:
head + chest + 2 arms + 2 legs). Combined CSI = coherent sum of
per-body-part contributions.

Headline finding: 5 m link, 2.4 GHz, subject 25 cm off LOS, breathing
at 0.25 Hz with 8 mm chest amplitude:

| Configuration                          | Breathing SNR (best subcarrier) |
|----------------------------------------|--------------------------------:|
| Single-scatterer ideal (R6)            |  +23.7 dB |
| Multi-scatterer realistic (R6.1)       |  +19.0 dB |
| MULTI-SCATTERER PENALTY                |  +4.7 dB  |

This 4.7 dB penalty matches R13's 5-dB-shortfall finding to within
0.3 dB. R13 NEGATIVE concluded that pulse-contour recovery needs
+25 dB SNR, only +20 dB is available. R6.1 says the 5-dB gap has a
physical origin: static body parts add coherent-sum confusion that
doesn't exist in the idealised single-scatterer model.

The three threads now form a coherent physics story:
- R6   = bound  (idealised single-scatterer = +23.7 dB)
- R6.1 = floor  (realistic 6-scatterer    = +19.0 dB)
- R13  = failure (contour needs +25 dB, gets +20 dB)

Pulse-contour recovery is bounded below by what R6.1 leaves achievable,
which is 4.7 dB worse than R6's idealised limit, enough to make R13's
contour recovery infeasible.

Per-body-part contribution: chest = 27.6% of CSI energy (5x per-limb
reflectivity). The chest IS the breathing signal; limbs are confound.

Architectural implications:
- Chest-centric placement targeting (R6.2.3 motivated)
- Mask limbs in vital_signs pipeline (use pose pipeline ADR-079/101)
- R14 V3 rescope to rate-only (no contour-shape recovery)
- R12 PABS revision unblocked: R6.1 is the explicit A(voxel) operator

Surprise finding: on-LOS placement (y=0) is degenerate -- path delta
is 2nd-order in offset for on-LOS scatterers, so breathing barely
changes path length. Real installations need subject OFF the LOS
line. The R6.2 placement search should respect this.

Honest scope:
- 6 scatterers is 1st-order; 50-100 voxel body would refine
- Reflectivity ratios are guesses (RCS measurements would refine)
- Static body assumption (limbs do micro-move during breathing)
- 2D top-down, no multipath (model general enough to include them)

Composes:
- R5: subcarrier selection picks reliable, not high-SNR
- R6: per-scatterer building block
- R6.2.x: chest-centric placement
- R7: residual-vs-forward-model = tighter adversarial detection
- R12 NEGATIVE: PABS A operator unblocked
- R13 NEGATIVE: 5-dB gap has physical origin
- R14 V3: needs rescope

Coordination: ticks/tick-18.md, no PROGRESS.md edit.
2026-05-22 03:36:42 -04:00

213 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""R6.1 — Multi-scatterer additive Fresnel forward model.
See docs/research/sota-2026-05-22/R6_1-multiscatterer-forward-model.md.
Extends R6's single-point-scatterer model to multiple scatterers
(distributed body). A human is approximated as 6 point scatterers:
head, chest, two arms, two legs. Each has:
- position (x, y) relative to LOS midpoint
- reflectivity (proportional to body-part surface area)
- motion amplitude (chest breathes; limbs static unless walking)
The combined CSI signal is the coherent (complex) sum of per-scatterer
contributions, evaluated per-subcarrier. This is the model that
vital_signs.rs implicitly assumes and tomography.rs explicitly inverts.
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 path_delta_m(scatterer_pos, tx_pos, rx_pos):
"""Path-length delta = (Tx → scatterer + scatterer → Rx) (Tx → Rx)."""
d_tx = np.linalg.norm(scatterer_pos - tx_pos)
d_rx = np.linalg.norm(scatterer_pos - rx_pos)
d_direct = np.linalg.norm(tx_pos - rx_pos)
return d_tx + d_rx - d_direct
def csi_contribution(scatterer_pos, reflectivity, tx_pos, rx_pos,
subcarrier_freqs_hz):
"""Complex contribution of a single scatterer at each subcarrier.
Magnitude proportional to reflectivity / (path loss); phase = 2π·f·Δℓ/c.
Path loss simplified to 1/(d_tx · d_rx) (bistatic 1/r² each leg)."""
delta_l = path_delta_m(scatterer_pos, tx_pos, rx_pos)
d_tx = np.linalg.norm(scatterer_pos - tx_pos)
d_rx = np.linalg.norm(scatterer_pos - rx_pos)
amplitude = reflectivity / max(d_tx * d_rx, 1e-3)
phase = 2 * np.pi * subcarrier_freqs_hz * delta_l / C
return amplitude * np.exp(1j * phase)
def simulate_human(body_model, tx_pos, rx_pos, freq_ghz,
n_subcarriers=52, sub_spacing_khz=312.5):
"""Sum CSI contributions from all body parts.
Returns complex per-subcarrier signal."""
sub_offsets = (np.arange(n_subcarriers) - n_subcarriers // 2) * sub_spacing_khz * 1e3
sub_freqs = freq_ghz * 1e9 + sub_offsets
total = np.zeros(n_subcarriers, dtype=complex)
for part_name, part in body_model.items():
contrib = csi_contribution(np.asarray(part["pos"]), part["refl"],
np.asarray(tx_pos), np.asarray(rx_pos),
sub_freqs)
total += contrib
return total
def default_human_body(center_x, center_y, height_m=1.75):
"""Approximate adult human as 6 point scatterers in 2D (top-down view).
Reflectivity scaled to body-part surface area (rough)."""
return {
"head": {"pos": np.array([center_x, center_y]), "refl": 0.10},
"chest": {"pos": np.array([center_x, center_y]), "refl": 0.50},
"left_arm": {"pos": np.array([center_x - 0.20, center_y]), "refl": 0.10},
"right_arm": {"pos": np.array([center_x + 0.20, center_y]), "refl": 0.10},
"left_leg": {"pos": np.array([center_x - 0.10, center_y - 0.40]), "refl": 0.10},
"right_leg": {"pos": np.array([center_x + 0.10, center_y - 0.40]), "refl": 0.10},
}
def breathe(body, t_seconds, amplitude_mm=8.0, rate_hz=0.25):
"""Modulate chest position with breathing motion (±8 mm tidal volume).
Returns a copy of body with updated chest position."""
out = {k: {**v, "pos": v["pos"].copy()} for k, v in body.items()}
delta_y = (amplitude_mm / 1000) * np.sin(2 * np.pi * rate_hz * t_seconds)
out["chest"]["pos"][1] += delta_y
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="examples/research-sota/r6_1_multiscatterer_results.json")
args = parser.parse_args()
# 5 m bedroom-class link
tx = np.array([0.0, 0.0])
rx = np.array([5.0, 0.0])
freq_ghz = 2.4
lam = wavelength_m(freq_ghz)
# Subject standing at midpoint, 0.25 m off LOS (inside first Fresnel ~40 cm)
# NOTE: on-LOS placement (y=0) gives degenerate path-delta sensitivity --
# breathing y-motion changes the path only at 2nd order. Real installations
# need the subject OFF the LOS line to see breathing-amplitude motion.
body = default_human_body(center_x=2.5, center_y=0.25)
# ===== 1. Single-frame multi-scatterer signature =====
csi_baseline = simulate_human(body, tx, rx, freq_ghz)
mag_baseline = np.abs(csi_baseline)
phase_baseline = np.angle(csi_baseline, deg=True)
# ===== 2. What does each body part contribute alone? =====
per_part_contributions = {}
for name, part in body.items():
single = {name: part}
c = simulate_human(single, tx, rx, freq_ghz)
per_part_contributions[name] = {
"mag_mean": float(np.abs(c).mean()),
"mag_max": float(np.abs(c).max()),
"phase_spread_deg": float(np.angle(c, deg=True).max() - np.angle(c, deg=True).min()),
"fraction_of_total_energy": float((np.abs(c)**2).sum() / (np.abs(csi_baseline)**2).sum()),
}
# ===== 3. Time series with breathing =====
# 30 seconds at 50 Hz CSI rate
fs = 50
t = np.arange(0, 30, 1/fs)
csi_series = np.zeros((len(t), 52), dtype=complex)
for i, ti in enumerate(t):
csi_series[i] = simulate_human(breathe(body, ti), tx, rx, freq_ghz)
# Per-subcarrier breathing-band SNR.
# Project each subcarrier's magnitude onto the breathing-band component
# vs everything else.
csi_mag = np.abs(csi_series)
# FFT each subcarrier's magnitude time-series
fft = np.fft.rfft(csi_mag - csi_mag.mean(axis=0), axis=0)
freqs = np.fft.rfftfreq(len(t), 1/fs)
breath_band = (freqs >= 0.15) & (freqs <= 0.4)
out_of_band = (freqs >= 0.5) & (freqs <= 3.0)
# Power per band
breath_power = (np.abs(fft[breath_band])**2).sum(axis=0)
out_power = (np.abs(fft[out_of_band])**2).sum(axis=0)
snr_per_sub = 10 * np.log10((breath_power + 1e-12) / (out_power + 1e-12))
snr_best_sub = float(snr_per_sub.max())
snr_mean_sub = float(snr_per_sub.mean())
snr_worst_sub = float(snr_per_sub.min())
best_sub_idx = int(snr_per_sub.argmax())
# ===== 4. Compare to R6 single-scatterer baseline =====
# Single chest-only scatterer at the same position
chest_only = {"chest": body["chest"]}
csi_chest_only_series = np.zeros((len(t), 52), dtype=complex)
for i, ti in enumerate(t):
csi_chest_only_series[i] = simulate_human(breathe(chest_only, ti), tx, rx, freq_ghz)
chest_mag = np.abs(csi_chest_only_series)
chest_fft = np.fft.rfft(chest_mag - chest_mag.mean(axis=0), axis=0)
chest_breath_power = (np.abs(chest_fft[breath_band])**2).sum(axis=0)
chest_out_power = (np.abs(chest_fft[out_of_band])**2).sum(axis=0)
chest_snr_per_sub = 10 * np.log10((chest_breath_power + 1e-12) / (chest_out_power + 1e-12))
chest_snr_best = float(chest_snr_per_sub.max())
# The interesting finding: the multi-scatterer model REDUCES breathing SNR
# because the static limb scatterers add noise / phase-offset confusion
# that didn't exist in the single-scatterer R6 model. This is what
# vital_signs.rs implicitly handles via its temporal bandpass.
out = {
"model": "additive complex sum of 6 point-scatterer human body model",
"link": {"tx": tx.tolist(), "rx": rx.tolist(), "freq_ghz": freq_ghz,
"wavelength_m": lam, "length_m": float(np.linalg.norm(tx-rx))},
"per_part_contributions": per_part_contributions,
"breathing_band_snr": {
"scatterer_count": 6,
"best_subcarrier_snr_db": snr_best_sub,
"best_subcarrier_index": best_sub_idx,
"mean_subcarrier_snr_db": snr_mean_sub,
"worst_subcarrier_snr_db": snr_worst_sub,
"chest_only_baseline_snr_db": chest_snr_best,
"multi_scatterer_penalty_db": chest_snr_best - snr_best_sub,
},
}
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
Path(args.out).write_text(json.dumps(out, indent=2))
print("=== R6.1 multi-scatterer human body model ===")
print(f" Link: {tx.tolist()} -> {rx.tolist()} @ {freq_ghz} GHz")
print()
print(f"=== Per-body-part contribution to total CSI energy ===")
for name, info in per_part_contributions.items():
print(f" {name:<10} mag_mean={info['mag_mean']:.3f} "
f"phase_spread={info['phase_spread_deg']:.2f} deg "
f"frac_of_total={info['fraction_of_total_energy']*100:.1f}%")
print()
print(f"=== Breathing-band SNR (15-second time-series) ===")
print(f" Multi-scatterer best subcarrier: {snr_best_sub:+.1f} dB (idx={best_sub_idx})")
print(f" Multi-scatterer mean: {snr_mean_sub:+.1f} dB")
print(f" Multi-scatterer worst: {snr_worst_sub:+.1f} dB")
print(f" Single-scatterer (chest-only): {chest_snr_best:+.1f} dB")
print(f" Multi-scatterer penalty: {chest_snr_best - snr_best_sub:+.1f} dB")
print()
print("Interpretation: static limb scatterers add coherent-sum confusion")
print("that doesn't exist in R6's single-scatterer model. The penalty is")
print("the gap between idealised physics (R6) and real-world deployment.")
print()
print(f"Wrote {args.out}")
if __name__ == "__main__":
main()