diff --git a/.claude/skills/unifi-wifi/SKILL.md b/.claude/skills/unifi-wifi/SKILL.md index 5d0f60c..c21ea80 100644 --- a/.claude/skills/unifi-wifi/SKILL.md +++ b/.claude/skills/unifi-wifi/SKILL.md @@ -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 [days=7] [band=ng|na|6e|all] ``` (Cascades 2.4: 75 radios at 74–94% utilization, 61–81% 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 [band=ng|na|6e] [days=30] + NEIGHBOR_JSON= bash .../radio-usage.sh 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 diff --git a/.claude/skills/unifi-wifi/scripts/radio-usage.sh b/.claude/skills/unifi-wifi/scripts/radio-usage.sh new file mode 100644 index 0000000..bb092ef --- /dev/null +++ b/.claude/skills/unifi-wifi/scripts/radio-usage.sh @@ -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 (-num_sta_avg) — how busy the radio is on average +# peak = max station snapshot (-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= (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 [band=ng|na|6e] [days=30] +# NEIGHBOR_JSON= 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 [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... ---- +cat <&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'] 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"