Validated the cloud-connector analysis against a KNOWN entity (Cascades, normally UOS-Mongo). The connector reaches the self-hosted "UOS Server" host; Cascades is its site `va6iba3v`. Two fixes from the validation: - rf-analyze.py: pass macs:[<all uap macs>] to /stat/report/*.ap. The UniFi report endpoint returns only a small DEFAULT subset otherwise -- Cascades came back as 10 of 77 APs until the MAC list was supplied. Now profiles all 75 (uaps with 2.4 radios), matching the UOS path. - model-rank.sh / optimize-radios.sh: --console now accepts --site <name> (internal short name from /api/self/sites) for multi-site controllers like the UOS Server (Cascades = va6iba3v). Result lines up with the known UOS-Mongo figures: 75 APs, 2.4GHz util 65-90% / interf 53-78% / ~1 client each, all power-down, 0 disables (roam graph absent via connector -> same coverage-safe degradation; disables still need NEIGHBOR_JSON). Apples-to-apples confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
179 lines
13 KiB
Bash
179 lines
13 KiB
Bash
#!/usr/bin/env bash
|
|
# optimize-radios.sh — capacity-aware, coverage-safe radio-pruning plan from accumulated history.
|
|
# Recommends, per band, which AP radios to POWER-DOWN (do first) and which to DISABLE (after
|
|
# re-measure), to cut airtime contention WITHOUT opening coverage holes or causing capacity
|
|
# cascades. Recommendations only; a human applies per zone with before/after validation.
|
|
#
|
|
# Design hardened via a multi-model critique (Grok + Gemini). Key principles:
|
|
# - Coverage proxy = the ROAM GRAPH, but a neighbor only counts if roaming is BIDIRECTIONAL
|
|
# (min(A->B,B->A) >= ROAM_MIN) and the p25 handoff RSSI clears a BAND-SPECIFIC bar
|
|
# (2.4=-68, 5=-72, 6=-75) — proves two-way overlap, not a one-way escape from a failing AP.
|
|
# - LOAD-SHIFT SIMULATION: disabling A pushes A's own-traffic (cu_self) onto its neighbors; only
|
|
# disable if a strong neighbor stays under CAP (85%) after absorbing it — else POWER-DOWN.
|
|
# - Benefit = cu_interf (the airtime you actually REMOVE) + a tx_retries (thrashing) weight;
|
|
# cu_self is transfer cost, not removal. An AP with no strong neighbor AND high peak load is
|
|
# ISOLATED-ESSENTIAL (keep). Zone disable cap = 40%. Stepwise: power-down -> observe -> disable.
|
|
#
|
|
# Data: ace_stat.stat_hourly (per-AP/band cu_total,cu_interf,cu_self_*,num_sta,tx_retries,satisfaction)
|
|
# + ace_stat.wifi_connectivity_event (directional roam edges + handoff RSSI).
|
|
# Usage: bash .../optimize-radios.sh <site> [days=14] [band=ng|na|6e]
|
|
# env: ROAM_MIN(4) CAP(85) ZONE_DISABLE_PCT(40) REDUN_NG(2) REDUN_OTHER(1)
|
|
set -euo pipefail
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
# Cloud connector path (non-UOS console): `optimize-radios.sh --console "<name>" [days] [band]` routes to
|
|
# rf-analyze.py (Site Manager API; see references/site-manager-api.md). UOS Mongo path below is unchanged.
|
|
# NEIGHBOR_JSON / ROAM_MIN / CAP / ZONE_DISABLE_PCT / REDUN_* env vars pass through to the analyzer.
|
|
if [ "${1:-}" = "--console" ]; then
|
|
shift; CONSOLE=""; CSITE="default"; CPOS=()
|
|
while [ $# -gt 0 ]; do case "$1" in
|
|
--site) CSITE="${2:-default}"; shift 2 2>/dev/null || shift;;
|
|
*) if [ -z "$CONSOLE" ]; then CONSOLE="$1"; else CPOS+=("$1"); fi; shift;;
|
|
esac; done
|
|
[ -n "$CONSOLE" ] || { echo "usage: optimize-radios.sh --console <name> [--site <s>] [days] [band]"; exit 2; }
|
|
CDAYS="${CPOS[0]:-14}"; CBAND="${CPOS[1]:-ng}"
|
|
CKEY="$(bash "$REPO/.claude/scripts/vault.sh" get-field services/unifi-site-manager credentials.api_key 2>/dev/null | tr -d '\r\n')"
|
|
[ -n "$CKEY" ] || { echo "[ERROR] no UniFi Site Manager key (vault services/unifi-site-manager)"; exit 1; }
|
|
CPY="$(command -v py 2>/dev/null || command -v python 2>/dev/null || command -v python3)"
|
|
exec env UNIFI_SM_KEY="$CKEY" "$CPY" "$REPO/.claude/skills/unifi-wifi/scripts/rf-analyze.py" optimize --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE"
|
|
fi
|
|
UOS="$REPO/.claude/scripts/uos-mongo.sh"
|
|
arg="${1:?usage: optimize-radios.sh <site> [days] [band] | optimize-radios.sh --console <name> [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}"
|
|
ROAM_MIN="${ROAM_MIN:-4}"; CAP="${CAP:-85}"; ZPCT="${ZONE_DISABLE_PCT:-40}"
|
|
REDUN_NG="${REDUN_NG:-2}"; REDUN_OTHER="${REDUN_OTHER:-1}"
|
|
if [[ "$arg" =~ ^[0-9a-f]{24}$ ]]; then SITE="$arg"; else
|
|
SITE="$(bash "$UOS" --sites 2>/dev/null | grep -vi 'pq.html' | grep -i "$arg" | awk '{print $1}' | head -1)"
|
|
[ -n "$SITE" ] || { echo "[ERROR] no site matching '$arg'"; exit 1; }
|
|
fi
|
|
case "$BAND" in ng) RSSI_OK=-68; REDUN=$REDUN_NG;; na) RSSI_OK=-72; REDUN=$REDUN_OTHER;; 6e) RSSI_OK=-75; REDUN=$REDUN_OTHER;; *) echo "band must be ng|na|6e"; exit 1;; esac
|
|
# Coverage-adjacency source: the measured AP-to-AP SNR matrix from neighbor-collect.sh (NEIGHBOR_JSON)
|
|
# is preferred — it's dense + accurate. The roam graph is the fallback but is sparse in sites with
|
|
# stationary clients (e.g. senior living), which is why disables came back 0 there. NBR_SNR_MIN = the
|
|
# SNR (dB) at which a managed-AP neighbor counts as real overlap (neighbor-collect default 20).
|
|
NBR_SNR_MIN="${NBR_SNR_MIN:-20}"; STRONG_RAW=""; ADJ_SRC="roam-graph (sparse w/ stationary clients)"
|
|
if [ -n "${NEIGHBOR_JSON:-}" ] && [ -f "${NEIGHBOR_JSON:-}" ]; then
|
|
# Precompute bidirectional strong-overlap NAME pairs HERE (not inside mongo JS — a large embedded
|
|
# object literal crashes the old mongo shell). Emit a compact ';'-joined, tab-internal flat string.
|
|
STRONG_RAW="$(python - "$NEIGHBOR_JSON" "$NBR_SNR_MIN" <<'PY'
|
|
import json,sys
|
|
nbr=json.load(open(sys.argv[1])); MIN=int(sys.argv[2])
|
|
def ms(a,b): # max SNR a->b over any band
|
|
o=nbr.get(a,{}); return max([o.get(bd,{}).get(b,-1) for bd in ('2.4','5','6')]+[-1])
|
|
aps=list(nbr); out=[]
|
|
for i,a in enumerate(aps):
|
|
for b in aps[i+1:]:
|
|
if ms(a,b)>=MIN and ms(b,a)>=MIN: # bidirectional overlap
|
|
out.append(a.replace('"','').replace('\\','')+'\t'+b.replace('"','').replace('\\',''))
|
|
print(';'.join(out))
|
|
PY
|
|
)"
|
|
ADJ_SRC="neighbor SNR matrix /proc/ui_neighbor (SNR>=$NBR_SNR_MIN)"
|
|
fi
|
|
echo "[INFO] site=$SITE band=$BAND window=${DAYS}d rssi>=$RSSI_OK roam>=$ROAM_MIN cap=${CAP}% need_neighbors=$REDUN zone_cap=${ZPCT}%"
|
|
echo "[INFO] coverage adjacency source: $ADJ_SRC"
|
|
|
|
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
|
|
var SITE='$SITE',DAYS=$DAYS,BAND='$BAND',RSSI_OK=$RSSI_OK,ROAM_MIN=$ROAM_MIN,CAP=$CAP,ZPCT=$ZPCT,REDUN=$REDUN;
|
|
var STRONG_RAW="$STRONG_RAW"; // ';'-joined "nameA<tab>nameB" bidirectional strong-overlap pairs, or ""
|
|
var ace=db.getSiblingDB('ace'),st=db.getSiblingDB('ace_stat');
|
|
var since=new Date().getTime()-DAYS*86400000;
|
|
// identity + zone
|
|
var name={},zone={};
|
|
ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1}).forEach(function(a){
|
|
name[a.mac]=a.name||a.mac;
|
|
var fz=String(a.name||'').match(/(\d)(?:st|nd|rd|th)\s*floor/i), rm=String(a.name||'').match(/\b(\d)\d{2}\b/);
|
|
zone[a.mac]=fz?('Floor '+fz[1]):(rm?('Floor '+rm[1]):'misc');
|
|
});
|
|
// airtime profile (avg + peak) for the band
|
|
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[BAND+'-cu_total'],intf=d[BAND+'-cu_interf'],self=(d[BAND+'-cu_self_rx']||0)+(d[BAND+'-cu_self_tx']||0),
|
|
sta=d[BAND+'-num_sta'],retr=d[BAND+'-tx_retries'],att=d[BAND+'-wifi_tx_attempts'],sat=d[BAND+'-satisfaction'];
|
|
if(cu==null&&intf==null) return;
|
|
if(!prof[ap])prof[ap]={cu:0,intf:0,self:0,sta:0,staPk:0,retr:0,att:0,sat:0,satN:0,n:0};
|
|
var p=prof[ap]; p.cu+=(cu||0);p.intf+=(intf||0);p.self+=self;p.sta+=(sta||0);p.staPk=Math.max(p.staPk,sta||0);
|
|
p.retr+=(retr||0);p.att+=(att||0); if(typeof sat==='number'){p.sat+=sat;p.satN++;} p.n++;
|
|
});
|
|
// tx_retries is a COUNT, not a %, so normalize by tx attempts; satisfaction averaged over its own samples.
|
|
for(var k in prof){var p=prof[k];['cu','intf','self','sta'].forEach(function(f){p[f]/=p.n;});
|
|
p.retrPct=p.att>0?Math.min(100,100*p.retr/p.att):0; p.sat=p.satN>0?p.sat/p.satN:100; void 0;} // void 0: stop the mongo REPL echoing this for-loop's numeric completion value
|
|
// directional roam edges + RSSI
|
|
var dir={}; // "A>B"->count ; rs["A>B"]->[rssi to B]
|
|
var rs={};
|
|
st.wifi_connectivity_event.find({site_id:SITE,time:{\$gte:since}},{from_endpoint:1,to_endpoint:1}).forEach(function(e){
|
|
var A=e.from_endpoint&&e.from_endpoint.mac,B=e.to_endpoint&&e.to_endpoint.mac; if(!A||!B||A===B)return;
|
|
var key=A+'>'+B; dir[key]=(dir[key]||0)+1; var r=e.to_endpoint&&e.to_endpoint.rssi;
|
|
if(typeof r==='number'){(rs[key]=rs[key]||[]).push(r);}
|
|
});
|
|
function p25(a){if(!a||!a.length)return -127;a=a.slice().sort(function(x,y){return x-y;});return a[Math.floor(a.length*0.25)];}
|
|
// strong bidirectional neighbors per AP (band-agnostic adjacency; physical overlap is band-independent)
|
|
var strong={};
|
|
Object.keys(prof).forEach(function(A){ strong[A]={};
|
|
Object.keys(prof).forEach(function(B){ if(A===B)return;
|
|
var ab=dir[A+'>'+B]||0, ba=dir[B+'>'+A]||0;
|
|
if(Math.min(ab,ba)>=ROAM_MIN && p25((rs[A+'>'+B]||[]).concat(rs[B+'>'+A]||[]))>=RSSI_OK) strong[A][B]=1;
|
|
});
|
|
});
|
|
// PREFERRED: rebuild adjacency from the measured AP-to-AP SNR matrix (neighbor-collect / /proc/ui_neighbor)
|
|
// when provided — denser + accurate. Overlap is bidirectional: A hears B AND B hears A, both >= NBR_SNR_MIN
|
|
// on any band (physical overlap is band-independent). This is what makes DISABLE decisions data-backed in
|
|
// sites where the roam graph is too sparse (stationary clients).
|
|
if(STRONG_RAW){
|
|
var nm2mac={}; for(var m in name){ nm2mac[name[m]]=m; }
|
|
Object.keys(prof).forEach(function(A){ strong[A]={}; });
|
|
STRONG_RAW.split(';').forEach(function(pr){ if(!pr)return; var t=pr.split('\t');
|
|
var A=nm2mac[t[0]], B=nm2mac[t[1]];
|
|
if(A&&B&&prof[A]&&prof[B]){ strong[A][B]=1; strong[B][A]=1; } // bidirectional overlap proven by SNR
|
|
});
|
|
}
|
|
// greedy capacity-aware disable
|
|
var aps=Object.keys(prof), active={}; aps.forEach(function(a){active[a]=true;});
|
|
var projCu={}; aps.forEach(function(a){projCu[a]=prof[a].cu;}); // projected utilization (grows as we shift load)
|
|
var zoneTot={},zoneDis={}; aps.forEach(function(a){zoneTot[zone[a]]=(zoneTot[zone[a]]||0)+1;});
|
|
function activeStrong(a){var o=strong[a]||{},r=[];for(var n in o)if(active[n])r.push(n);return r;}
|
|
function absorbers(a){ // active strong neighbors that can absorb a's cu_self under CAP
|
|
return activeStrong(a).filter(function(n){return projCu[n]+prof[a].self<=CAP;});
|
|
}
|
|
function soleNeighborOf(a){ for(var x in active){if(!active[x]||x===a)continue; if(!(strong[x]||{})[a])continue;
|
|
var others=0,o=strong[x]||{}; for(var n in o)if(n!==a&&active[n])others++; if(others===0)return true;} return false; }
|
|
var disabled=[];
|
|
var go=true;
|
|
while(go){ go=false; var best=null,bestBen=-1;
|
|
aps.forEach(function(a){ if(!active[a])return; var p=prof[a];
|
|
if(activeStrong(a).length<REDUN) return; // not enough overlap -> keep
|
|
if(absorbers(a).length<1) return; // no neighbor with headroom -> power-down, not disable
|
|
if(soleNeighborOf(a)) return; // protect sole coverage
|
|
if((zoneDis[zone[a]]||0)+1 > Math.floor(zoneTot[zone[a]]*ZPCT/100)) return; // zone cap
|
|
var benefit=p.intf + 0.5*p.retrPct; // remove interference + thrashing; cu_self transfers, not removed
|
|
if(benefit>bestBen){bestBen=benefit;best=a;}
|
|
});
|
|
if(best){ var abs=absorbers(best); active[best]=false; zoneDis[zone[best]]=(zoneDis[zone[best]]||0)+1;
|
|
// shift best's own-load onto its absorbers (split)
|
|
var share=prof[best].self/abs.length; abs.forEach(function(n){projCu[n]+=share;});
|
|
disabled.push({ap:best,cover:abs.map(function(n){return name[n];})}); go=true; }
|
|
}
|
|
// classify the rest
|
|
// POWER-DOWN is always coverage-safe (keeps the BSSID, just shrinks the cell), so recommend it
|
|
// for every saturated/contended radio regardless of whether we have roam-neighbor evidence. DISABLE
|
|
// (above) is the only action that needs positive coverage evidence, hence its strict gate.
|
|
var powerdown=[],keep=[];
|
|
aps.forEach(function(a){ if(!active[a])return; var p=prof[a];
|
|
if(p.n<3 || (p.cu<5 && p.intf<5)){ keep.push({ap:a,why:'idle/off'}); return; } // already-off / idle
|
|
if(p.cu>=40 || p.intf>=35 || p.retrPct>=15 || p.sat<80) powerdown.push(a); // saturated/contended/thrashing -> shrink cell
|
|
else keep.push({ap:a,why:'low-util'});
|
|
});
|
|
function fmt(a){var p=prof[a];return name[a]+" cu="+p.cu.toFixed(0)+"% interf="+p.intf.toFixed(0)+"% self="+p.self.toFixed(0)+"% clients(avg/pk)="+p.sta.toFixed(1)+"/"+p.staPk.toFixed(0)+" retr="+p.retrPct.toFixed(0)+"% sat="+p.sat.toFixed(0);}
|
|
function byZone(list,kf){var z={};list.forEach(function(x){var a=kf(x);(z[zone[a]]=z[zone[a]]||[]).push(x);});return z;}
|
|
print("==== PLAN band="+BAND+" (APs="+aps.length+") POWER-DOWN(first)="+powerdown.length+" DISABLE(after)="+disabled.length+" KEEP="+keep.length+" ====");
|
|
var save=0;disabled.forEach(function(d){save+=prof[d.ap].intf;});
|
|
print("Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf for the whole zone). Do this FIRST.");
|
|
var pz=byZone(powerdown,function(a){return a;});
|
|
Object.keys(pz).sort().forEach(function(z){print(" ["+z+"] "+pz[z].length);pz[z].slice(0,6).forEach(function(a){print(" "+fmt(a));}); if(pz[z].length>6)print(" ...(+"+(pz[z].length-6)+")");});
|
|
print("\nPhase B: re-measure (live-stats.sh) after power-down settles - cu_interf should drop, headroom appears.");
|
|
print("\nPhase C: DISABLE these only-when-redundant radios (each has >="+REDUN+" bidirectional good neighbors WITH headroom to absorb its load). Est. interference airtime removed: "+save.toFixed(0)+".");
|
|
if(!disabled.length) print(" (none clear the capacity+coverage bar yet - expected when every neighbor is saturated; revisit after Phase A.)");
|
|
var dz=byZone(disabled,function(d){return d.ap;});
|
|
Object.keys(dz).sort().forEach(function(z){print(" ["+z+"]");dz[z].forEach(function(d){print(" "+fmt(d.ap)+" -> covered by: "+d.cover.slice(0,3).join(', '));});});
|
|
print("\nKEEP: "+keep.length+" radios (isolated-essential or already efficient). Apply per ZONE; never >"+ZPCT+"% disabled/zone; validate before+after.");
|
|
JS
|