research(R6.2.5): multi-subject occupancy union — N=5 hits 100% for 4 occupants; R6 family complete (#730)

Extends R6.2.3 chest-centric placement to union of chest envelopes
across multiple occupants. Practical question: does coverage degrade
gracefully as occupant count grows?

Result: 2D chest-centric + N=5 + multi-subject union = 100% coverage
for households of 1-4 occupants. N=4 knee returns.

| Scenario   | # zones | Cov @ N=5 |
|------------|--------:|----------:|
| 1 occupant |       1 |     100%  |
| 2 occupants|       2 |     100%  |
| 3 occupants|       3 |     100%  |
| 4 occupants|       4 |     100%  |

4-occupant saturation: N=4 = 99.0% (+26.1 pp marginal), N=5 = 100%,
N=6+ saturated. Knee at N=4 even for 4 occupants.

Cross-eval: single-subject placement gets 70.6% on 4 zones; multi-
subject-optimised gets 100%. +29.4 pp gain from multi-subject
optimisation. CLI MUST accept multiple --target args and compute union.

Why N=4 knee returns: each chest zone is 40x40 cm, fits inside one
Fresnel ellipsoid (~40 cm wide at midpoint of 5 m link). N=4 anchors
give 6 pairwise links, enough to cover 4 disjoint chest zones without
much waste. Chest-centric multi-subject is the SWEET SPOT for Fresnel
envelope geometry.

R6 family complete (9 ticks: R6, R6.1, R6.2, R6.2.1, R6.2.2, R6.2.2.1,
R6.2.3, R6.2.4, R6.2.5). Family's ship recipe:
- 2D chest-centric + multi-subject + N=5 = 100% coverage

Productisation CLI spec (50 LOC over original R6.2):
  wifi-densepose plan-antennas
      --room W H [Z]                  # 2D or 3D
      --target NAME X Y W H [DX DY DZ] # repeatable
      --target-mode {body, chest}     # R6.2.3
      --freq-ghz F
      --n-anchors N                   # auto-saturation if omitted
      --restarts K

Honest scope: 2D only (3D multi-subject = mechanical extension), static
positions, single 5x5 m geometry, greedy with 4 restarts, 4 occupants
max tested.

Composes:
- R6.2 / R6.2.3 direct extension (single -> multi)
- R6.2.2 / R6.2.4 same saturation behaviour
- R14 V1/V2/V3 in households of 2-4 use this recipe
- R3 / ADR-024 per-subject identity + multi-subject placement
- ADR-105/106/107 federation orthogonal
- R12 PABS multi-subject coverage = multi-subject intrusion detection

Coordination: ticks/tick-27.md, no PROGRESS.md edit.
This commit is contained in:
rUv
2026-05-22 05:37:29 -04:00
committed by GitHub
parent 4183ef651f
commit 4e6ef76294
4 changed files with 581 additions and 0 deletions
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""R6.2.5 — Multi-subject occupancy union.
See docs/research/sota-2026-05-22/R6_2_5-multi-subject-union.md.
R6.2 / R6.2.3 picked one chest position per zone. Real households
have 2-4 occupants who can be in different positions simultaneously
(spouse in bed + child at desk + visitor on chair). R6.2.5 extends to
**union of chest envelopes** across all expected occupant positions.
Practical question: does the optimal placement degrade gracefully
when target zones multiply? Does N=5 still hit a useful coverage?
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, y, tx, rx, wavelength):
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, target_x, target_y, wavelength):
if len(anchors) < 2: return 0.0
covered = np.zeros(len(target_x), dtype=bool)
for i in range(len(anchors)):
for j in range(i+1, len(anchors)):
covered |= in_first_fresnel(target_x, target_y,
anchors[i], anchors[j], wavelength)
return float(covered.mean())
def rasterise_zones(zones, resolution=0.05):
xs, ys = [], []
for name, x0, y0, w, h in 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 candidates(room_w, room_h, step):
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(cands, target_x, target_y, lam, n_anchors, restarts=4, seed=0):
rng = np.random.default_rng(seed)
best = {"score": -1.0, "anchors": []}
for r in range(restarts):
idx0, idx1 = rng.choice(len(cands), size=2, replace=False)
chosen = [cands[idx0], cands[idx1]]
while len(chosen) < n_anchors:
best_marg = -1
best_idx = None
for k, c in enumerate(cands):
if any(np.allclose(c, a) for a in chosen): continue
s = union_coverage(chosen + [c], target_x, target_y, lam)
if s > best_marg:
best_marg = s
best_idx = k
if best_idx is None: break
chosen.append(cands[best_idx])
score = union_coverage(chosen, target_x, target_y, lam)
if score > best["score"]:
best = {"score": score, "anchors": [a.tolist() for a in chosen]}
return best
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--out", default="examples/research-sota/r6_2_5_multi_subject_results.json")
args = parser.parse_args()
room_w, room_h = 5.0, 5.0
freq = 2.4
lam = wavelength_m(freq)
step = 0.25
cands = candidates(room_w, room_h, step)
# Scenarios with increasing occupant count
# Each "chest zone" is a 40x40 cm patch
scenarios = {
"1 occupant (chair)": [
("chair_chest", 3.7, 3.7, 0.4, 0.4),
],
"2 occupants (chair + bed)": [
("chair_chest", 3.7, 3.7, 0.4, 0.4),
("bed_chest", 2.2, 0.8, 0.6, 0.4),
],
"3 occupants (chair + bed + desk)": [
("chair_chest", 3.7, 3.7, 0.4, 0.4),
("bed_chest", 2.2, 0.8, 0.6, 0.4),
("desk_chest", 0.5, 2.7, 0.4, 0.2),
],
"4 occupants (+ 2nd chair)": [
("chair_chest", 3.7, 3.7, 0.4, 0.4),
("bed_chest", 2.2, 0.8, 0.6, 0.4),
("desk_chest", 0.5, 2.7, 0.4, 0.2),
("chair2_chest", 1.0, 4.2, 0.4, 0.4),
],
}
print(f"Room {room_w}x{room_h} m, freq {freq} GHz, chest-centric zones")
print()
# For each scenario, find optimum at N=5
results = []
for name, zones in scenarios.items():
tx, ty = rasterise_zones(zones)
result = greedy_search(cands, tx, ty, lam, n_anchors=5)
# Total zone area
zone_area = sum(w * h for _, _, _, w, h in zones)
results.append({
"scenario": name,
"n_zones": len(zones),
"total_zone_area_m2": zone_area,
"coverage_n5": result["score"],
"best_anchors": result["anchors"],
})
print(f"{'Scenario':<40} {'#zones':>6} {'Area':>7} {'Cov@N=5':>9}")
print("-" * 75)
for r in results:
print(f"{r['scenario']:<40} {r['n_zones']:>6} {r['total_zone_area_m2']:>5.2f} m2 {r['coverage_n5']*100:>7.1f}%")
print()
# Stress test: scale N for the 4-occupant scenario
print(f"=== 4-occupant scenario, scaling N from 2..7 ===")
zones4 = scenarios["4 occupants (+ 2nd chair)"]
tx, ty = rasterise_zones(zones4)
print(f"{'N':>3} {'Coverage':>9} {'Marginal':>9}")
prev = 0.0
scale_curve = []
for n in range(2, 8):
result = greedy_search(cands, tx, ty, lam, n_anchors=n)
marg = (result["score"] - prev) * 100
print(f"{n:>3} {result['score']*100:>7.1f}% {marg:>+7.1f} pp")
scale_curve.append({"n_anchors": n, "coverage": result["score"]})
prev = result["score"]
print()
# Cross-eval: how does a single-subject-optimised placement perform on 4 subjects?
single_zone = [("chair_chest", 3.7, 3.7, 0.4, 0.4)]
tx1, ty1 = rasterise_zones(single_zone)
single_opt = greedy_search(cands, tx1, ty1, lam, n_anchors=5)
tx4, ty4 = rasterise_zones(zones4)
cov_single_on_multi = union_coverage(
[np.array(a) for a in single_opt["anchors"]], tx4, ty4, lam
)
print(f"=== Cross-eval ===")
print(f" Single-subject placement on 4-subject zones: {cov_single_on_multi*100:.1f}%")
print(f" 4-subject-optimised placement on 4 zones: {results[-1]['coverage_n5']*100:.1f}%")
print(f" Gain from multi-subject optimisation: {(results[-1]['coverage_n5'] - cov_single_on_multi)*100:+.1f} pp")
print()
out = {
"room": {"width_m": room_w, "height_m": room_h},
"freq_ghz": freq,
"scenarios_n5": results,
"saturation_4subj": scale_curve,
"cross_eval": {
"single_opt_on_multi": cov_single_on_multi,
"multi_opt_on_multi": results[-1]["coverage_n5"],
"gain_pp": (results[-1]["coverage_n5"] - cov_single_on_multi) * 100,
},
}
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,152 @@
{
"room": {
"width_m": 5.0,
"height_m": 5.0
},
"freq_ghz": 2.4,
"scenarios_n5": [
{
"scenario": "1 occupant (chair)",
"n_zones": 1,
"total_zone_area_m2": 0.16000000000000003,
"coverage_n5": 1.0,
"best_anchors": [
[
5.0,
3.25
],
[
0.0,
1.25
],
[
2.0,
5.0
],
[
0.0,
0.0
],
[
0.0,
5.0
]
]
},
{
"scenario": "2 occupants (chair + bed)",
"n_zones": 2,
"total_zone_area_m2": 0.4,
"coverage_n5": 1.0,
"best_anchors": [
[
5.0,
3.25
],
[
0.0,
1.25
],
[
5.0,
0.5
],
[
2.0,
5.0
],
[
0.0,
0.0
]
]
},
{
"scenario": "3 occupants (chair + bed + desk)",
"n_zones": 3,
"total_zone_area_m2": 0.48000000000000004,
"coverage_n5": 1.0,
"best_anchors": [
[
5.0,
3.25
],
[
0.0,
1.25
],
[
2.0,
5.0
],
[
5.0,
0.5
],
[
0.0,
0.0
]
]
},
{
"scenario": "4 occupants (+ 2nd chair)",
"n_zones": 4,
"total_zone_area_m2": 0.6400000000000001,
"coverage_n5": 1.0,
"best_anchors": [
[
3.0,
0.0
],
[
2.5,
5.0
],
[
0.0,
3.75
],
[
4.25,
5.0
],
[
0.75,
5.0
]
]
}
],
"saturation_4subj": [
{
"n_anchors": 2,
"coverage": 0.14516129032258066
},
{
"n_anchors": 3,
"coverage": 0.7290322580645161
},
{
"n_anchors": 4,
"coverage": 0.9903225806451613
},
{
"n_anchors": 5,
"coverage": 1.0
},
{
"n_anchors": 6,
"coverage": 1.0
},
{
"n_anchors": 7,
"coverage": 1.0
}
],
"cross_eval": {
"single_opt_on_multi": 0.7064516129032258,
"multi_opt_on_multi": 1.0,
"gain_pp": 29.354838709677423
}
}