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>
84 lines
4.2 KiB
Python
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.")
|