unifi-wifi: add radio-usage.sh — per-AP band client-usage history (disable-safe vs power-down)

Answers "is this 2.4 radio actually used?" from accumulated controller stats (ace_stat.stat_daily,
~77d). Reports per-AP time-avg concurrent users (<radio>-num_sta_avg) + peak station snapshot
(<band>-num_sta), distinguishing avg~0/peak>0 (takes bursts -> POWER-DOWN) from peak==0 (genuinely
unused -> disable-safe). With NEIGHBOR_JSON it crosses low-use APs against the AP-to-AP SNR matrix to
emit a defensible safe-to-disable shortlist (low-use AP + strong overlapping neighbor with headroom),
noting mutual-coverage conflicts and deferring conflict-free selection to optimize-radios.

Validated live on Cascades: of 76 APs only 1 has peak==0 over 77d (the offline AP 108); every other
2.4 radio takes real client bursts (peaks 5-58) at very low avg (12 APs <0.5 concurrent). I.e. the
usage history independently CONFIRMS the conservative power-down-not-disable call. Read-only (Mongo
plane). Uses var-assignment to avoid the legacy-mongo REPL echo. SKILL.md documents it as step 2b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:38:26 -07:00
parent e1031ae91a
commit 659c3a2bda
2 changed files with 126 additions and 0 deletions

View File

@@ -82,6 +82,16 @@ path is Cascades — override with the script's vault-path arg per client.
bash .claude/skills/unifi-wifi/scripts/model-rank.sh <site> [days=7] [band=ng|na|6e|all]
```
(Cascades 2.4: 75 radios at 7494% utilization, 6181% interference, ~1 client each → disable/power-down.)
2b. **Radio usage history** — is a radio actually USED (so you can tell disable-safe from power-down)?
Per-AP time-avg concurrent users + peak station count over the retained window (`stat_daily`, ~77d):
```bash
bash .claude/skills/unifi-wifi/scripts/radio-usage.sh <site> [band=ng|na|6e] [days=30]
NEIGHBOR_JSON=<nbr.json> bash .../radio-usage.sh <site> ng 77 # adds a safe-to-disable shortlist
```
Key distinction: `avg~0 but peak>0` = NOT unused (takes bursts → POWER-DOWN); only `peak==0` over the
window = genuinely unused (disable-safe). With the SNR matrix it cross-checks low-use APs against
overlapping neighbors w/ headroom. (Cascades 2.4: only 1 never-used radio — the offline AP 108; every
other radio takes real bursts → confirms power-down, not disable. optimize-radios does the conflict-free pick.)
3. **Optimize (coverage-safe plan)** — which radios to **power-down** (do first) vs **disable**
(after), per band, without opening coverage holes or capacity cascades:
```bash

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# radio-usage.sh — per-AP client-USAGE history for a band, from accumulated controller stats
# (ace_stat.stat_daily, ~77d retention). Answers "is this radio actually used?" so you can tell a
# truly-idle radio (safe to DISABLE) apart from a lightly-but-genuinely-used one (POWER-DOWN, not off).
#
# Two metrics per AP, because they say different things:
# avg = time-averaged concurrent users (<radioName>-num_sta_avg) — how busy the radio is on average
# peak = max station snapshot (<band>-num_sta) — did ANY device ever land here
# A radio with avg~0 but peak>0 is NOT unused: it takes occasional bursts (legacy/IoT/roamers). Disabling
# it strands those clients onto a farther AP. Only peak==0 over the window = genuinely unused.
#
# With NEIGHBOR_JSON=<path> (from neighbor-collect.sh NBR_JSON=...), cross the low-usage APs against the
# AP-to-AP SNR matrix to emit a DEFENSIBLE safe-to-disable shortlist: a low-usage AP whose strong
# overlapping neighbor has the headroom to absorb its rare clients. This is the data-backed version of
# the optimize-radios DISABLE gate, driven by real client history instead of the roam graph.
#
# Read-only. Mongo plane (no controller cred), like model-rank/optimize-radios.
# Usage: bash radio-usage.sh <site-name|id> [band=ng|na|6e] [days=30]
# NEIGHBOR_JSON=<path> also print the safe-to-disable shortlist
# LOW_AVG=0.5 NBR_SNR_MIN=30 HEADROOM=4 thresholds (avg-users "low", strong-neighbor SNR, absorb cap)
set -uo pipefail
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
UOS="$REPO/.claude/scripts/uos-mongo.sh"
SITEARG="${1:?usage: radio-usage.sh <site> [band=ng|na|6e] [days=30]}"
BAND="${2:-ng}"; DAYS="${3:-30}"
case "$BAND" in ng|na|6e) ;; *) echo "[ERROR] band must be ng|na|6e"; exit 1;; esac
if [[ "$SITEARG" =~ ^[0-9a-f]{24}$ ]]; then SITE="$SITEARG"; else
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$SITEARG" | awk '{print $1}' | head -1)"; fi
[ -n "$SITE" ] || { echo "[ERROR] site not found: $SITEARG"; exit 1; }
echo "[INFO] radio-usage site=$SITE band=$BAND window=${DAYS}d"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# ---- Mongo: per-AP avg(time-avg users) + peak(station snapshot) over the window. Emits ROW<TAB>... ----
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/rows.txt"
var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat');
var SITE="$SITE", BAND="$BAND", DAYS=$DAYS, since=new Date().getTime()-DAYS*86400000;
var name={}, rname={}, rstate={};
ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1,radio_table:1}).forEach(function(a){
name[a.mac]=a.name||a.mac;
(a.radio_table||[]).forEach(function(r){ if(r.radio==BAND){ rname[a.mac]=r.name; rstate[a.mac]=r.tx_power_mode||'auto'; }});
});
var agg={};
st.stat_daily.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d){
var ap=d.ap; if(!ap||name[ap]===undefined)return; var nm=rname[ap]; if(!nm)return;
var av=d[nm+'-num_sta_avg'], pk=d[BAND+'-num_sta'];
if(!agg[ap])agg[ap]={s:0,sn:0,pk:0,days:0,n:0};
var a=agg[ap]; if(av!=null){a.s+=av;a.sn++;} if(pk!=null){if(pk>a.pk)a.pk=pk; if(pk>0)a.days++;} a.n++;
});
Object.keys(agg).forEach(function(m){ var a=agg[m];
var avg=a.sn? (a.s/a.sn):-1;
print("ROW\t"+name[m]+"\t"+avg.toFixed(3)+"\t"+a.pk+"\t"+a.days+"\t"+a.n+"\t"+(rstate[m]||'?'));
});
JS
# ---- Python: format report + (optional) neighbor-matrix safe-to-disable shortlist ----
LOW_AVG="${LOW_AVG:-0.5}"; NBR_SNR_MIN="${NBR_SNR_MIN:-30}"; HEADROOM="${HEADROOM:-4}"
python - "$TMP/rows.txt" "${NEIGHBOR_JSON:-NONE}" "$LOW_AVG" "$NBR_SNR_MIN" "$HEADROOM" "$BAND" <<'PY'
import sys,json
rows=[]
for ln in open(sys.argv[1],encoding='utf-8',errors='replace'):
if not ln.startswith('ROW\t'): continue
_,ap,avg,pk,days,n,st=ln.rstrip('\n').split('\t')
rows.append({'ap':ap,'avg':float(avg),'pk':int(pk),'days':int(days),'n':int(n),'st':st})
if not rows: print("[WARN] no daily stats in window (retention may be shorter)."); sys.exit(0)
NJ,LOW,SNR,HEAD,BAND=sys.argv[2],float(sys.argv[3]),float(sys.argv[4]),float(sys.argv[5]),sys.argv[6]
rows.sort(key=lambda r:(r['avg'],r['pk']))
idle=[r for r in rows if r['pk']==0]
low=[r for r in rows if r['pk']>0 and 0<=r['avg']<LOW and r['st']!='disabled']
def pad(s,w): s=str(s); return s+' '*max(0,w-len(s))
print(f"\n==== {BAND} USAGE per AP ({len(rows)} APs) avg=time-avg concurrent users, peak=max snapshot ====")
print(pad("AP",26)+pad("avg_users",11)+pad("peak",7)+pad("days_used/total",18)+"radio")
for r in rows:
tag=' [IDLE]' if r['pk']==0 else (' [low-use]' if (0<=r['avg']<LOW and r['st']!='disabled') else '')
print(pad(r['ap'],26)+pad(f"{r['avg']:.2f}",11)+pad(r['pk'],7)+pad(f"{r['days']}/{r['n']}",18)+r['st']+tag)
print(f"\nSUMMARY: {len(idle)} radio(s) NEVER used (peak==0) -> genuinely disable-safe; "
f"{len(low)} low-avg(<{LOW}) but with real peaks -> POWER-DOWN, not disable (bursts land here).")
if idle:
print(" never-used: "+", ".join(f"{r['ap']}({r['st']})" for r in idle))
if NJ!='NONE':
try: M=json.load(open(NJ))
except Exception as e: print(f"\n[WARN] could not read NEIGHBOR_JSON {NJ}: {e}"); sys.exit(0)
# union strong neighbors across bands (physical overlap is band-independent)
avgby={r['ap']:r for r in rows}
def strong_nbrs(ap):
out={}
for b,nb in (M.get(ap) or {}).items():
for nm,snr in nb.items():
try: s=float(snr)
except: continue
if s>=SNR: out[nm]=max(out.get(nm,-999),s)
return out
print(f"\n==== SAFE-TO-DISABLE SHORTLIST (low-use AP + strong neighbor w/ headroom; SNR>={SNR:.0f}) ====")
found=0
for r in low+idle:
if r['st']=='disabled': continue
nbrs=strong_nbrs(r['ap'])
# an absorber = active strong neighbor whose avg + this AP's avg stays under HEADROOM
absorbers=[(nm,avgby[nm]['avg']) for nm in nbrs
if nm in avgby and avgby[nm]['st']!='disabled' and (avgby[nm]['avg']+max(r['avg'],0))<=HEAD]
if absorbers:
found+=1
ab=", ".join(f"{nm}(avg {a:.2f})" for nm,a in sorted(absorbers,key=lambda x:x[1])[:3])
print(f" {r['ap']} avg={r['avg']:.2f} peak={r['pk']} -> covered by: {ab}")
if not found:
print(" (none clear the bar — every low-use radio's strong neighbors lack headroom, or no SNR overlap.")
print(" That CONFIRMS the conservative call: power-down, don't disable.)")
print(f"\n[note] shortlist = candidates, not orders. Many are each other's absorbers (mutual coverage),")
print(f" so only a NON-CONFLICTING SUBSET can be disabled — once you disable one, its neighbors are")
print(f" no longer spare. optimize-radios.sh does the greedy conflict-free selection (with the same")
print(f" SNR matrix via NEIGHBOR_JSON); use this list to understand WHY. Disable one at a time, watch")
print(f" the neighbor absorb the load (live-stats/watch-ap) before+after. Rare bursts WILL reassociate.")
PY
echo ""
echo "[tip] add the SNR matrix for the disable shortlist:"
echo " NBR=\$(mktemp -u).json; NBR_JSON=\$NBR bash .../neighbor-collect.sh $SITEARG; NEIGHBOR_JSON=\$NBR bash .../radio-usage.sh $SITEARG $BAND $DAYS"