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:
rUv
2026-05-22 04:17:47 -04:00
committed by GitHub
parent 3d3d54d523
commit 39d18d1c99
4 changed files with 562 additions and 0 deletions
@@ -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
}
}