mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
research(R6.2.1): 3D antenna placement — ceiling-only gives 0% coverage; mixed-height wins (#724)
Extends R6.2 from 2D ellipse to 3D ellipsoid + 3D target zones (bed at z=0.3-0.6, chair at z=0.5-1.2, standing at z=1.0-1.7 in a 5x5x2.5 m room). Counter-intuitive headline: | Strategy | Coverage | |-------------------------------------------|---------:| | Desk-height (0.8 m walls) | 22.2% | | Wall-mount (1.5 m walls) | 17.4% | | Ceiling-only (2.5 m grid) | 0.0% | <-- FAILS | Mixed walls + ceiling | 25.7% | <-- BEST Ceiling-only fails because both antennas at 2.5 m create a Fresnel ellipsoid sitting AT ceiling height (2.1-2.9 m vertically). Target zones at 0.3-1.7 m are below the envelope by 0.4-2.0 m. The 39 cm transverse radius is symmetric around LOS, so a flat horizontal link at any height misses targets at any OTHER height. This is the 3D version of R6.1's on-LOS-degeneracy finding. A horizontal link at any single height has its envelope concentrated at that height. Why mixed wins: best placement is Tx (5.0, 4.0, 0.8) + Rx (0.0, 4.0, 1.5). The diagonal-in-z link tilts the ellipsoid through multiple elevations. Covers chair AND standing AND bed simultaneously. Vertical link diversity is the 3D insight 2D analysis missed. Installation-guide updates: - Single pair: one low (0.8 m) + one high (1.5 m), opposite walls - 4-anchor: 2x low corners + 2x high opposite corners - 5-anchor knee: mix 0.8 / 1.5 / one ceiling - Bed-only: both LOW - Standing-only: both HIGH - NEVER: both ceiling without a low anchor Coverage numbers are lower than R6.2's 2D 51% because 3D volumetric coverage is inherently lower than 2D area coverage -- honest 3D physics. Composes: - R6.2 (2D) -- incomplete; height matters as much as horizontal - R6.2.2 (N-anchor) -- N=5 knee should distribute across heights - R6.1 (multi-scatterer) -- needs 3D body model for proper composition - R14 V1/V2/V3 -- each vertical needs height-recipe - ADR-029 -- placement is (x, y, z), not (x, y) - R12 PABS -- detects intruders standing/sitting/lying with mixed heights Honest scope: 3-zone discrete approximation, single-pair only, no furniture occlusion, 0.1 m resolution, greedy search. Coordination: ticks/tick-21.md, no PROGRESS.md edit.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""R6.2.1 — 3D Fresnel-aware antenna placement (ceiling + wall mounts).
|
||||
|
||||
See docs/research/sota-2026-05-22/R6_2_1-3d-placement.md.
|
||||
|
||||
R6.2 was 2D (top-down). Real human occupants stand at heights 0-1.8 m;
|
||||
real WiFi APs typically sit at desk height (0.8 m), wall mounts at
|
||||
1.5 m, or ceiling mounts at 2.5 m. The optimal placement depends on
|
||||
whether antennas + target zones share an elevation.
|
||||
|
||||
This script extends R6.2 to 3D:
|
||||
- First Fresnel zone in 3D is a prolate ellipsoid (rotation of the
|
||||
2D ellipse around the Tx-Rx axis)
|
||||
- Target zones are 3D boxes representing where a person's torso
|
||||
occupies (e.g. chest height 1.0-1.5 m for standing, 0.5-1.0 m for
|
||||
sitting on a chair, 0.3-0.6 m for lying in bed)
|
||||
- Candidate antenna mounts: wall (z fixed by mount height) or
|
||||
ceiling (z = ceiling height)
|
||||
|
||||
A point (x, y, z) is inside the first Fresnel ellipsoid iff:
|
||||
|Tx - p| + |p - Rx| <= |Tx - Rx| + lambda/2
|
||||
|
||||
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_3d(p: np.ndarray, tx: np.ndarray, rx: np.ndarray,
|
||||
wavelength: float) -> np.ndarray:
|
||||
"""Boolean: is each point p (Nx3) inside the first Fresnel ellipsoid?"""
|
||||
r1 = np.linalg.norm(p - tx, axis=1)
|
||||
r2 = np.linalg.norm(p - rx, axis=1)
|
||||
direct = np.linalg.norm(tx - rx)
|
||||
return (r1 + r2) <= (direct + wavelength / 2)
|
||||
|
||||
|
||||
def coverage_3d(tx: np.ndarray, rx: np.ndarray, target_zones: list,
|
||||
wavelength: float, resolution: float = 0.1) -> dict:
|
||||
"""3D rectangular zones. Each zone: (name, x0, y0, z0, dx, dy, dz)."""
|
||||
per_zone = {}
|
||||
total_pts = 0
|
||||
total_covered = 0
|
||||
for name, x0, y0, z0, dx, dy, dz in target_zones:
|
||||
xs = np.arange(x0, x0 + dx, resolution)
|
||||
ys = np.arange(y0, y0 + dy, resolution)
|
||||
zs = np.arange(z0, z0 + dz, resolution)
|
||||
xv, yv, zv = np.meshgrid(xs, ys, zs, indexing="ij")
|
||||
pts = np.stack([xv.ravel(), yv.ravel(), zv.ravel()], axis=1)
|
||||
mask = in_first_fresnel_3d(pts, tx, rx, wavelength)
|
||||
per_zone[name] = {
|
||||
"n_points": len(pts),
|
||||
"n_covered": int(mask.sum()),
|
||||
"coverage_fraction": float(mask.mean()),
|
||||
}
|
||||
total_pts += len(pts)
|
||||
total_covered += mask.sum()
|
||||
return {
|
||||
"total_coverage": float(total_covered / total_pts) if total_pts > 0 else 0,
|
||||
"per_zone": per_zone,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="examples/research-sota/r6_2_1_3d_results.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
room_w, room_h, room_z = 5.0, 5.0, 2.5
|
||||
freq = 2.4
|
||||
lam = wavelength_m(freq)
|
||||
|
||||
# Three realistic 3D target zones:
|
||||
# bed (lying down) (1.5, 0.5, 0.3) - (3.5, 2.0, 0.6) at low altitude
|
||||
# chair (sitting) (3.5, 3.5, 0.5) - (4.3, 4.3, 1.2) at mid altitude
|
||||
# standing zone (workspace) (0.5, 3.5, 1.0) - (1.5, 4.5, 1.7) at upper altitude
|
||||
target_zones = [
|
||||
("bed", 1.5, 0.5, 0.3, 2.0, 1.5, 0.3),
|
||||
("chair", 3.5, 3.5, 0.5, 0.8, 0.8, 0.7),
|
||||
("standing", 0.5, 3.5, 1.0, 1.0, 1.0, 0.7),
|
||||
]
|
||||
|
||||
# Three candidate antenna placement strategies
|
||||
strategies = {
|
||||
"desk-height (0.8 m, wall)": {
|
||||
"z_options": [0.8],
|
||||
"where": "wall",
|
||||
},
|
||||
"wall-mount (1.5 m, wall)": {
|
||||
"z_options": [1.5],
|
||||
"where": "wall",
|
||||
},
|
||||
"ceiling (2.5 m, full ceiling grid)": {
|
||||
"z_options": [2.5],
|
||||
"where": "ceiling",
|
||||
},
|
||||
"wall + ceiling (mixed at any height)": {
|
||||
"z_options": [0.8, 1.5, 2.5],
|
||||
"where": "any",
|
||||
},
|
||||
}
|
||||
|
||||
def gen_candidates(strategy_cfg, step=0.5):
|
||||
cands = []
|
||||
for z in strategy_cfg["z_options"]:
|
||||
if strategy_cfg["where"] in ("wall", "any"):
|
||||
# 4 walls
|
||||
for x in np.arange(0, room_w + 0.001, step):
|
||||
cands.append(np.array([x, 0.0, z]))
|
||||
cands.append(np.array([x, room_h, z]))
|
||||
for y in np.arange(step, room_h, step):
|
||||
cands.append(np.array([0.0, y, z]))
|
||||
cands.append(np.array([room_w, y, z]))
|
||||
if strategy_cfg["where"] in ("ceiling", "any") and z >= room_z - 0.01:
|
||||
# Ceiling grid
|
||||
for x in np.arange(0.5, room_w + 0.001, step):
|
||||
for y in np.arange(0.5, room_h + 0.001, step):
|
||||
cands.append(np.array([x, y, z]))
|
||||
# Deduplicate
|
||||
unique = []
|
||||
for c in cands:
|
||||
if not any(np.allclose(c, u) for u in unique):
|
||||
unique.append(c)
|
||||
return unique
|
||||
|
||||
print(f"Room: {room_w}x{room_h}x{room_z} m at {freq} GHz")
|
||||
print(f"Target zones:")
|
||||
for name, x0, y0, z0, dx, dy, dz in target_zones:
|
||||
print(f" {name}: ({x0},{y0},{z0}) - ({x0+dx},{y0+dy},{z0+dz})")
|
||||
print()
|
||||
|
||||
results = {}
|
||||
for name, cfg in strategies.items():
|
||||
cands = gen_candidates(cfg)
|
||||
best_score = -1
|
||||
best_tx, best_rx = None, None
|
||||
n_evaluated = 0
|
||||
for i, tx in enumerate(cands):
|
||||
for j, rx in enumerate(cands):
|
||||
if j <= i: continue
|
||||
if np.linalg.norm(tx - rx) < 1.0:
|
||||
continue
|
||||
cov = coverage_3d(tx, rx, target_zones, lam, resolution=0.1)
|
||||
n_evaluated += 1
|
||||
if cov["total_coverage"] > best_score:
|
||||
best_score = cov["total_coverage"]
|
||||
best_tx = tx.tolist()
|
||||
best_rx = rx.tolist()
|
||||
best_per_zone = cov["per_zone"]
|
||||
results[name] = {
|
||||
"best_score": float(best_score),
|
||||
"best_tx": best_tx,
|
||||
"best_rx": best_rx,
|
||||
"n_candidates": len(cands),
|
||||
"n_pairs_evaluated": n_evaluated,
|
||||
"best_per_zone": best_per_zone,
|
||||
}
|
||||
|
||||
print("=== 3D placement strategy comparison ===")
|
||||
print(f"{'Strategy':<46} {'Pairs':>6} {'Coverage':>9}")
|
||||
for name, r in results.items():
|
||||
print(f"{name:<46} {r['n_pairs_evaluated']:>6} {r['best_score']*100:>7.1f}%")
|
||||
print()
|
||||
|
||||
# Headline
|
||||
best_strategy = max(results, key=lambda k: results[k]["best_score"])
|
||||
desk_score = results["desk-height (0.8 m, wall)"]["best_score"]
|
||||
ceiling_score = results["ceiling (2.5 m, full ceiling grid)"]["best_score"]
|
||||
mixed_score = results["wall + ceiling (mixed at any height)"]["best_score"]
|
||||
lift = (mixed_score - desk_score) / desk_score * 100 if desk_score > 0 else 0
|
||||
|
||||
print(f"Best strategy: {best_strategy} ({results[best_strategy]['best_score']*100:.1f}%)")
|
||||
print(f" Best Tx: {results[best_strategy]['best_tx']}")
|
||||
print(f" Best Rx: {results[best_strategy]['best_rx']}")
|
||||
print()
|
||||
print(f"Desk-height baseline: {desk_score*100:.1f}%")
|
||||
print(f"Ceiling-only: {ceiling_score*100:.1f}%")
|
||||
print(f"Mixed wall+ceiling: {mixed_score*100:.1f}% (+{lift:.1f}% over desk-height)")
|
||||
print()
|
||||
|
||||
out = {
|
||||
"room": {"width_m": room_w, "depth_m": room_h, "ceiling_m": room_z},
|
||||
"freq_ghz": freq,
|
||||
"target_zones": [
|
||||
{"name": n, "x": x0, "y": y0, "z": z0, "dx": dx, "dy": dy, "dz": dz}
|
||||
for n, x0, y0, z0, dx, dy, dz in target_zones
|
||||
],
|
||||
"strategies": results,
|
||||
"headline": {
|
||||
"best_strategy": best_strategy,
|
||||
"desk_score": desk_score,
|
||||
"ceiling_score": ceiling_score,
|
||||
"mixed_score": mixed_score,
|
||||
"mixed_lift_over_desk_pct": lift,
|
||||
},
|
||||
}
|
||||
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,174 @@
|
||||
{
|
||||
"room": {
|
||||
"width_m": 5.0,
|
||||
"depth_m": 5.0,
|
||||
"ceiling_m": 2.5
|
||||
},
|
||||
"freq_ghz": 2.4,
|
||||
"target_zones": [
|
||||
{
|
||||
"name": "bed",
|
||||
"x": 1.5,
|
||||
"y": 0.5,
|
||||
"z": 0.3,
|
||||
"dx": 2.0,
|
||||
"dy": 1.5,
|
||||
"dz": 0.3
|
||||
},
|
||||
{
|
||||
"name": "chair",
|
||||
"x": 3.5,
|
||||
"y": 3.5,
|
||||
"z": 0.5,
|
||||
"dx": 0.8,
|
||||
"dy": 0.8,
|
||||
"dz": 0.7
|
||||
},
|
||||
{
|
||||
"name": "standing",
|
||||
"x": 0.5,
|
||||
"y": 3.5,
|
||||
"z": 1.0,
|
||||
"dx": 1.0,
|
||||
"dy": 1.0,
|
||||
"dz": 0.7
|
||||
}
|
||||
],
|
||||
"strategies": {
|
||||
"desk-height (0.8 m, wall)": {
|
||||
"best_score": 0.22216796875,
|
||||
"best_tx": [
|
||||
0.5,
|
||||
0.0,
|
||||
0.8
|
||||
],
|
||||
"best_rx": [
|
||||
5.0,
|
||||
5.0,
|
||||
0.8
|
||||
],
|
||||
"n_candidates": 40,
|
||||
"n_pairs_evaluated": 736,
|
||||
"best_per_zone": {
|
||||
"bed": {
|
||||
"n_points": 900,
|
||||
"n_covered": 94,
|
||||
"coverage_fraction": 0.10444444444444445
|
||||
},
|
||||
"chair": {
|
||||
"n_points": 448,
|
||||
"n_covered": 361,
|
||||
"coverage_fraction": 0.8058035714285714
|
||||
},
|
||||
"standing": {
|
||||
"n_points": 700,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"wall-mount (1.5 m, wall)": {
|
||||
"best_score": 0.17431640625,
|
||||
"best_tx": [
|
||||
0.0,
|
||||
5.0,
|
||||
1.5
|
||||
],
|
||||
"best_rx": [
|
||||
4.5,
|
||||
0.0,
|
||||
1.5
|
||||
],
|
||||
"n_candidates": 40,
|
||||
"n_pairs_evaluated": 736,
|
||||
"best_per_zone": {
|
||||
"bed": {
|
||||
"n_points": 900,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
},
|
||||
"chair": {
|
||||
"n_points": 448,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
},
|
||||
"standing": {
|
||||
"n_points": 700,
|
||||
"n_covered": 357,
|
||||
"coverage_fraction": 0.51
|
||||
}
|
||||
}
|
||||
},
|
||||
"ceiling (2.5 m, full ceiling grid)": {
|
||||
"best_score": 0.0,
|
||||
"best_tx": [
|
||||
0.5,
|
||||
0.5,
|
||||
2.5
|
||||
],
|
||||
"best_rx": [
|
||||
0.5,
|
||||
1.5,
|
||||
2.5
|
||||
],
|
||||
"n_candidates": 100,
|
||||
"n_pairs_evaluated": 4608,
|
||||
"best_per_zone": {
|
||||
"bed": {
|
||||
"n_points": 900,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
},
|
||||
"chair": {
|
||||
"n_points": 448,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
},
|
||||
"standing": {
|
||||
"n_points": 700,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"wall + ceiling (mixed at any height)": {
|
||||
"best_score": 0.25732421875,
|
||||
"best_tx": [
|
||||
5.0,
|
||||
4.0,
|
||||
0.8
|
||||
],
|
||||
"best_rx": [
|
||||
0.0,
|
||||
4.0,
|
||||
1.5
|
||||
],
|
||||
"n_candidates": 201,
|
||||
"n_pairs_evaluated": 19464,
|
||||
"best_per_zone": {
|
||||
"bed": {
|
||||
"n_points": 900,
|
||||
"n_covered": 0,
|
||||
"coverage_fraction": 0.0
|
||||
},
|
||||
"chair": {
|
||||
"n_points": 448,
|
||||
"n_covered": 217,
|
||||
"coverage_fraction": 0.484375
|
||||
},
|
||||
"standing": {
|
||||
"n_points": 700,
|
||||
"n_covered": 310,
|
||||
"coverage_fraction": 0.44285714285714284
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"headline": {
|
||||
"best_strategy": "wall + ceiling (mixed at any height)",
|
||||
"desk_score": 0.22216796875,
|
||||
"ceiling_score": 0.0,
|
||||
"mixed_score": 0.25732421875,
|
||||
"mixed_lift_over_desk_pct": 15.824175824175823
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user