unifi-wifi: add coverage-thin.sh — 2.4 coverage-redundancy disable planner (active-2.4 aware)
Answers "which 2.4 radios can we turn OFF given over-coverage, based on AP proximity." Greedy dominating-set on the AP-to-AP 2.4 SNR layer: disables radios whose area stays covered by a nearby ACTIVE-2.4 neighbor, maximizing interference-airtime removed without opening a 2.4 hole. Caps per-zone, guards coverer capacity, flags single-coverer (low-resilience) disables, reports co-channel before/after. Why separate from optimize-radios: optimize uses band-AGNOSTIC physical adjacency, so it counts an AP whose ng radio is DISABLED as a "coverer" via its 5/6 GHz (observed: it proposed disabling 127/229/330/428 "covered by 128" — but 128's 2.4 is already disabled => those would be 2.4 holes). coverage-thin uses the 2.4 SNR layer specifically and only counts neighbors whose 2.4 stays ON. Cascades (live): aggressive MINCOV=1 -> disable 36/76; resilient MINCOV=2 -> disable 34/76 with >=2 active 2.4 coverers each; co-channel ch6 28->13, ch11 25->13, ch1 20->13; ~2400 interference-airtime pts removed. Read-only; needs NEIGHBOR_JSON. SKILL.md step 3b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,17 @@ path is Cascades — override with the script's vault-path arg per client.
|
||||
(bidirectional roams, load-shift simulation, 40%/zone cap, stepwise). On Cascades 2.4 it
|
||||
recommends power-down on 74/75 radios (safe big win) and 0 disables until the live RF table proves
|
||||
redundancy — see interference-model.md.
|
||||
3b. **Coverage-thin 2.4 (which radios to turn OFF)** — when 2.4 is over-deployed (every AP blasting 2.4,
|
||||
heavy co-channel contention), compute a coverage-redundancy (dominating-set) DISABLE plan on the
|
||||
**2.4 SNR layer** so each disabled AP stays covered by a nearby **ACTIVE-2.4** neighbor:
|
||||
```bash
|
||||
NBR=.claude/tmp/<site>-nbr.json; NBR_JSON=$NBR bash .../neighbor-collect.sh <site>
|
||||
NEIGHBOR_JSON=$NBR bash .../coverage-thin.sh <site> [days=14] # MINCOV=2 for the resilient subset
|
||||
```
|
||||
Unlike optimize-radios (band-AGNOSTIC physical adjacency → can wrongly count an ng-DISABLED AP as a
|
||||
coverer via its 5/6 GHz), coverage-thin uses the 2.4 layer and only counts neighbors whose 2.4 stays
|
||||
ON; flags single-coverer disables, caps per-zone, halves co-channel count. Cascades: ~34 of 76 2.4
|
||||
radios disable-able with ≥2 active coverers each (ch6 28→13, ch11/ch1 →13).
|
||||
4. **Validate live (Plane 2)** — current cu_total/satisfaction/per-AP RF, before+after a change, and
|
||||
the AP-to-AP RF-neighbor table that unlocks confident disables:
|
||||
```bash
|
||||
|
||||
136
.claude/skills/unifi-wifi/scripts/coverage-thin.sh
Normal file
136
.claude/skills/unifi-wifi/scripts/coverage-thin.sh
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# coverage-thin.sh — "which 2.4 radios can we turn OFF?" When 2.4 is over-deployed (every AP blasting
|
||||
# 2.4, huge co-channel contention, high airtime) you don't need every 2.4 radio on to cover the floor.
|
||||
# This computes a COVERAGE-REDUNDANCY (dominating-set) plan on the AP-to-AP 2.4 SNR matrix: disable the
|
||||
# radios whose area is still covered by a nearby ACTIVE 2.4 neighbor, maximizing airtime/interference
|
||||
# removed without opening a 2.4 coverage hole.
|
||||
#
|
||||
# Why this exists alongside optimize-radios.sh: optimize uses band-AGNOSTIC physical adjacency (good for
|
||||
# "are APs near each other"), so it can count an AP whose 2.4 is DISABLED as a "coverer" via its 5/6 GHz.
|
||||
# For the 2.4-coverage question that's wrong (a disabled 2.4 radio covers no 2.4 clients). coverage-thin
|
||||
# uses the 2.4 SNR layer specifically and only ever counts neighbors whose 2.4 radio stays ON.
|
||||
#
|
||||
# REQUIRES the SNR matrix: NBR=.claude/tmp/<site>-nbr.json
|
||||
# NBR_JSON=$NBR bash .../neighbor-collect.sh <site>
|
||||
# NEIGHBOR_JSON=$NBR bash .../coverage-thin.sh <site> [days=14]
|
||||
# Read-only (Mongo plane). Tunables (env): COVER_SNR=28 (min AP-to-AP 2.4 SNR to count as coverage),
|
||||
# MINCOV=1 (active 2.4 coverers a disabled AP must retain; flags <2 as low-resilience),
|
||||
# ZPCT=50 (max % of a zone's 2.4 radios off), CLIENT_CAP=12 (max projected avg clients on a coverer).
|
||||
set -uo pipefail
|
||||
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
||||
UOS="$REPO/.claude/scripts/uos-mongo.sh"
|
||||
SITEARG="${1:?usage: coverage-thin.sh <site> [days=14] (NEIGHBOR_JSON=<matrix> required)}"; DAYS="${2:-14}"
|
||||
NJ="${NEIGHBOR_JSON:-}"; [ -n "$NJ" ] && [ -f "$NJ" ] || { echo "[ERROR] NEIGHBOR_JSON=<matrix.json> required (run neighbor-collect.sh with NBR_JSON=...)"; exit 1; }
|
||||
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] coverage-thin site=$SITE window=${DAYS}d matrix=$NJ"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# ---- per-AP 2.4 state + airtime/clients (Mongo) -> TSV ----
|
||||
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need' > "$TMP/ap.tsv"
|
||||
var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat'), SITE="$SITE";
|
||||
var since=new Date().getTime()-$DAYS*86400000;
|
||||
var name={},zone={},chan={},state={};
|
||||
ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1,radio_table:1}).forEach(function(a){
|
||||
var nm=a.name||a.mac; name[a.mac]=nm;
|
||||
var fz=String(nm).match(/(\d)(?:st|nd|rd|th)\s*floor/i), rm=String(nm).match(/\b(\d)\d{2}\b/);
|
||||
zone[a.mac]=fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');
|
||||
(a.radio_table||[]).forEach(function(r){ if(r.radio=='ng'){ chan[a.mac]=r.channel; state[a.mac]=r.tx_power_mode||'auto'; }});
|
||||
});
|
||||
var prof={};
|
||||
st.stat_hourly.find({o:'ap',site_id:SITE,time:{\$gte:since}}).forEach(function(d){
|
||||
var ap=d.ap; if(!ap||name[ap]===undefined)return;
|
||||
var cu=d['ng-cu_total'],intf=d['ng-cu_interf'],sta=d['ng-num_sta'],retr=d['ng-tx_retries'],att=d['ng-wifi_tx_attempts'];
|
||||
if(cu==null&&intf==null)return;
|
||||
if(!prof[ap])prof[ap]={cu:0,intf:0,sta:0,pk:0,retr:0,att:0,n:0};
|
||||
var p=prof[ap]; p.cu+=(cu||0);p.intf+=(intf||0);p.sta+=(sta||0);p.pk=Math.max(p.pk,sta||0);p.retr+=(retr||0);p.att+=(att||0);p.n++;
|
||||
});
|
||||
Object.keys(name).forEach(function(m){ var p=prof[m]||{cu:0,intf:0,sta:0,pk:0,retr:0,att:0,n:0};
|
||||
var cu=p.n?p.cu/p.n:0, intf=p.n?p.intf/p.n:0, sta=p.n?p.sta/p.n:0, retr=p.att>0?Math.min(100,100*p.retr/p.att):0;
|
||||
print("ROW\t"+name[m]+"\t"+zone[m]+"\t"+(chan[m]||'?')+"\t"+(state[m]||'none')+"\t"+cu.toFixed(0)+"\t"+intf.toFixed(0)+"\t"+retr.toFixed(0)+"\t"+sta.toFixed(2)+"\t"+p.pk);
|
||||
});
|
||||
JS
|
||||
|
||||
# ---- greedy coverage-thinning on the 2.4 SNR layer ----
|
||||
COVER_SNR="${COVER_SNR:-28}"; MINCOV="${MINCOV:-1}"; ZPCT="${ZPCT:-50}"; CLIENT_CAP="${CLIENT_CAP:-12}"
|
||||
python - "$TMP/ap.tsv" "$NJ" "$COVER_SNR" "$MINCOV" "$ZPCT" "$CLIENT_CAP" <<'PY'
|
||||
import sys,json
|
||||
ap={}
|
||||
for ln in open(sys.argv[1],encoding='utf-8',errors='replace'):
|
||||
if not ln.startswith('ROW\t'): continue
|
||||
_,nm,z,ch,stt,cu,intf,retr,sta,pk=ln.rstrip('\n').split('\t')
|
||||
ap[nm]={'zone':z,'ch':ch,'st':stt,'cu':float(cu),'intf':float(intf),'retr':float(retr),'avg':float(sta),'pk':int(pk)}
|
||||
M=json.load(open(sys.argv[2])); COVER=float(sys.argv[3]); MINCOV=int(sys.argv[4]); ZPCT=float(sys.argv[5]); CCAP=float(sys.argv[6])
|
||||
def lay(a): return (M.get(a) or {}).get('2.4') or {}
|
||||
def snr(a,b):
|
||||
x=lay(a).get(b); y=lay(b).get(a); vals=[v for v in (x,y) if isinstance(v,(int,float))]
|
||||
return min(vals) if vals else None # both-ways when available; min = conservative
|
||||
# nodes = APs with an ng radio that is currently ON (disabled radios neither candidates nor coverers)
|
||||
nodes=[a for a in ap if ap[a]['st']!='disabled' and ap[a]['st']!='none']
|
||||
adj={a:set(b for b in nodes if b!=a and (snr(a,b) is not None and snr(a,b)>=COVER)) for a in nodes}
|
||||
on={a:True for a in nodes}
|
||||
proj={a:ap[a]['avg'] for a in nodes}
|
||||
zoneTot={}; zoneOff={}
|
||||
for a in ap:
|
||||
if ap[a]['st']=='none': continue
|
||||
z=ap[a]['zone']; zoneTot[z]=zoneTot.get(z,0)+1
|
||||
if ap[a]['st']=='disabled': zoneOff[z]=zoneOff.get(z,0)+1
|
||||
def coverers(a): return [b for b in adj[a] if on[b]]
|
||||
# order: disable the biggest air hog / lowest value first
|
||||
order=sorted(nodes, key=lambda a:-(ap[a]['intf']+0.5*ap[a]['retr']-2*ap[a]['avg']))
|
||||
disabled=[]
|
||||
changed=True
|
||||
while changed:
|
||||
changed=False
|
||||
for a in order:
|
||||
if not on[a]: continue
|
||||
cov=coverers(a)
|
||||
if len(cov)<max(1,MINCOV): continue # area must stay covered by active 2.4
|
||||
# disabling 'a' must not strand an already-OFF neighbor that relied on it
|
||||
bad=False
|
||||
for b in adj[a]:
|
||||
if not on[b] and not [c for c in adj[b] if on[c] and c!=a]: bad=True; break
|
||||
if bad: continue
|
||||
z=ap[a]['zone']
|
||||
if (zoneOff.get(z,0)+1)/max(1,zoneTot.get(z,1)) > ZPCT/100: continue # don't over-thin a zone
|
||||
absorber=min(cov,key=lambda c:proj[c])
|
||||
if proj[absorber]+ap[a]['avg']>CCAP: continue # don't overload the coverer
|
||||
on[a]=False; zoneOff[z]=zoneOff.get(z,0)+1; proj[absorber]+=ap[a]['avg']
|
||||
disabled.append(a); changed=True
|
||||
# report
|
||||
keep=[a for a in nodes if on[a]]
|
||||
intf_removed=sum(ap[a]['intf'] for a in disabled)
|
||||
def pad(s,w): s=str(s); return s+' '*max(0,w-len(s))
|
||||
print(f"\n==== 2.4 COVERAGE-THINNING PLAN (active 2.4 radios={len(nodes)}) DISABLE={len(disabled)} KEEP={len(keep)} ====")
|
||||
print(f"SNR>={COVER:.0f} = 'covers same area'; only ACTIVE-2.4 neighbors count; <{ '2' if MINCOV<2 else MINCOV} coverers flagged.\n")
|
||||
from collections import defaultdict
|
||||
byz=defaultdict(list)
|
||||
for a in disabled: byz[ap[a]['zone']].append(a)
|
||||
for z in sorted(byz):
|
||||
print(f" [{z}] (zone now {zoneOff.get(z,0)}/{zoneTot.get(z,0)} 2.4 radios off)")
|
||||
for a in sorted(byz[z], key=lambda x:-ap[x]['intf']):
|
||||
cov=[b for b in adj[a] if on[b]]
|
||||
cs=", ".join(f"{b}(SNR {int(snr(a,b))})" for b in sorted(cov,key=lambda b:-(snr(a,b) or 0))[:3])
|
||||
flag=" [LOW-RESILIENCE: 1 coverer]" if len(cov)<2 else ""
|
||||
print(f" {a}: ch{ap[a]['ch']} cu={ap[a]['cu']:.0f}% interf={ap[a]['intf']:.0f}% clients(avg/pk)={ap[a]['avg']:.1f}/{ap[a]['pk']} retr={ap[a]['retr']:.0f}%")
|
||||
print(f" -> stays covered on 2.4 by: {cs}{flag}")
|
||||
# co-channel contention before/after on the KEPT set
|
||||
def chans(setlist):
|
||||
c=defaultdict(int)
|
||||
for a in setlist:
|
||||
if ap[a]['ch'] not in ('?','none'): c[ap[a]['ch']]+=1
|
||||
return dict(sorted(c.items(), key=lambda x:-x[1]))
|
||||
print(f"\nco-channel 2.4 radios BEFORE: {chans(nodes)}")
|
||||
print(f"co-channel 2.4 radios AFTER : {chans(keep)}")
|
||||
print(f"\nESTIMATE: ~{intf_removed:.0f} interference-airtime points removed from the air ({len(disabled)} fewer contending 2.4 radios).")
|
||||
print("APPLY (per zone, one at a time, validate before+after with watch-ap/live-stats):")
|
||||
seen=set()
|
||||
for a in disabled:
|
||||
z=ap[a]['zone']
|
||||
if z in seen: continue
|
||||
seen.add(z)
|
||||
print(f" bash .claude/skills/unifi-wifi/scripts/apply-radio.sh <site> ng disable --zone \"{z}\" # then re-measure")
|
||||
print("[note] coverage proven by AP-to-AP 2.4 SNR (APs hearing each other), a proxy for client-level overlap.")
|
||||
print(" Disable one zone, watch a kept neighbor absorb the load + check no dead spot, before the next.")
|
||||
PY
|
||||
Reference in New Issue
Block a user