Files
claudetools/.claude/skills/unifi-wifi/scripts/survey-report.py
Howard Enos fb835fe756 unifi-wifi: data-driven channel selection — add survey-report, kill non-DFS bias
Codifies the scan-first/data-driven workflow proven on Cascades (where the baked-in
non-DFS bias picked the congested channels and a data-driven DFS plan halved 5GHz retry):

- NEW survey-report.py: rolls survey-collect JSON into the fleet per-channel/per-band-group
  measured busy% table + cleanest/dirtiest ranking + a suggested clean 40MHz palette. The
  decision-driver that was missing (we built it by hand).
- channel-plan.sh: na palette is now DATA-DRIVEN, not hardcoded non-DFS. Adds --channels
  (explicit palette) + --dfs ok|avoid|only; default considers ALL 40MHz primaries and lets
  measured busy% choose. Adds load-balancing + a local-search pass -> strong co-channel to 0.
- survey-collect.sh: per-AP "cleanest" report no longer pre-filters out DFS (DFS is usually
  cleanest here); marks DFS with *, points at survey-report.
- SKILL.md: documents the mandatory scan -> survey-report -> channel-plan --channels -> apply
  -> validate order + the Cascades lesson.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:00:47 -07:00

84 lines
4.2 KiB
Python

#!/usr/bin/env python3
# survey-report.py -- fleet-wide channel-congestion analysis from a survey-collect JSON.
#
# THE DECISION-DRIVER: turns the raw per-AP survey (SURVEY_JSON from survey-collect.sh) into the
# fleet-wide per-channel measured busy% table + per-band-group rollup + cleanest/dirtiest ranking,
# so the channel plan is chosen from MEASURED FACTS, not policy. This is what makes the
# DFS-vs-non-DFS (and any channel) call obvious instead of assumed.
#
# WHY THIS EXISTS (Cascades 2026-06-19): the skill collected the survey but never aggregated it, and
# both survey-collect's report and channel-plan's palette were hardcoded to NON-DFS. On this site the
# non-DFS channels (149/157) measured 12-28% busy while DFS measured 2-3% -- the opposite of the
# baked-in assumption. A blind non-DFS plan made things worse; the data-driven DFS plan halved 5GHz
# retry. Always run THIS before channel-plan, and feed channel-plan a palette derived from it.
#
# Usage:
# python survey-report.py <SURVEY_JSON> [band=na|ng]
# SURVEY_JSON=.claude/tmp/<site>-survey.json python survey-report.py - na
#
# Output: per-channel median/mean/max busy% (n APs), band-group rollup (UNII-1/UNII-2a-DFS/
# UNII-2c-DFS/UNII-3), the cleanest + dirtiest channels, and a suggested clean 40MHz palette to
# hand to channel-plan via --channels.
import json, os, sys, statistics as st
from collections import defaultdict
path = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != '-' else os.environ.get('SURVEY_JSON', '')
band = sys.argv[2] if len(sys.argv) > 2 else 'na'
if not path or not os.path.exists(path):
sys.exit("usage: survey-report.py <SURVEY_JSON> [na|ng] (or SURVEY_JSON env). file not found.")
d = json.load(open(path))
sb = {'na': '5', 'ng': '2.4'}[band]
def band_of(c):
if band == 'ng':
return '2.4 GHz'
if 36 <= c <= 48: return 'UNII-1 (non-DFS)'
if 52 <= c <= 64: return 'UNII-2a (DFS)'
if 100 <= c <= 144: return 'UNII-2c (DFS)'
if 149 <= c <= 165: return 'UNII-3 (non-DFS)'
return '?'
ch = defaultdict(list)
for ap, bands in d.items():
for c, busy in bands.get(sb, {}).items():
try: ch[int(c)].append(busy)
except: pass
if not ch:
sys.exit(f"[survey-report] no {band} ({sb}GHz) data in {path}")
print(f"==== FLEET CHANNEL CONGESTION ({band}, {len(d)} APs scanned) -- measured busy% (lower=cleaner) ====")
print(f"{'ch':>4} {'band':<18} {'median':>7} {'mean':>6} {'max':>5} n")
rows = []
for c in sorted(ch):
v = ch[c]; rows.append((st.median(v), c, st.mean(v), max(v), len(v)))
print(f"{c:>4} {band_of(c):<18} {st.median(v):>6.0f}% {st.mean(v):>5.0f}% {max(v):>4.0f}% {len(v):>3}")
grp = defaultdict(list)
for ap, bands in d.items():
for c, busy in bands.get(sb, {}).items():
try: grp[band_of(int(c))].append(busy)
except: pass
print("\nBY BAND GROUP (median busy% across all AP-channel samples):")
for g in sorted(grp):
v = grp[g]
print(f" {g:<18} median={st.median(v):>4.0f}% mean={st.mean(v):>4.0f}% (n={len(v)})")
clean = sorted(rows) # by median busy asc
print("\nCLEANEST channels:", ", ".join(f"ch{c}({m:.0f}%)" for m, c, *_ in clean[:8]))
print("DIRTIEST channels:", ", ".join(f"ch{c}({m:.0f}%)" for m, c, *_ in clean[::-1][:5]))
if band == 'na':
# suggest a clean 40MHz palette: lower-primary channels whose 40MHz pair (c, c+4) are both clean
medbusy = {c: st.median(v) for c, v in ch.items()}
THRESH = max(8, st.median([m for m in medbusy.values()])) # "clean" = <= this
pairs = [(52, 56), (60, 64), (100, 104), (108, 112), (116, 120), (124, 128), (132, 136), (140, 144),
(36, 40), (44, 48), (149, 153), (157, 161)]
palette = [lo for lo, hi in pairs
if medbusy.get(lo, 99) <= THRESH and medbusy.get(hi, 99) <= THRESH]
dfs_clean = [c for c in palette if 52 <= c <= 144]
print(f"\nSUGGESTED clean 40MHz palette (both halves <= {THRESH:.0f}% busy): {palette}")
print(f" of those, DFS (cleaner here but radar-vacate risk): {dfs_clean}")
print(f" -> feed channel-plan: channel-plan.sh <site> na --channels {','.join(map(str,palette))} --apply")
print(" NOTE: choose DFS vs non-DFS from THIS data + the radar tradeoff, not a hardcoded policy.")