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>
79 lines
5.1 KiB
Bash
79 lines
5.1 KiB
Bash
#!/usr/bin/env bash
|
|
# model-rank.sh — rank AP radios as airtime-reduction candidates from ACCUMULATED history.
|
|
# Data (all from ace_stat, already collected by the controller — no new collector needed):
|
|
# - airtime/interference: stat_hourly (o:'ap') <band>-cu_total, <band>-cu_interf, <band>-num_sta
|
|
# - coverage overlap: wifi_connectivity_event (client roams between AP pairs)
|
|
# Per band (ng/na/6e). A radio is a strong DISABLE candidate when it carries high interference
|
|
# airtime AND its clients heavily roam to other APs (redundant coverage); a POWER-DOWN candidate
|
|
# when busy/interfering but with less roam redundancy. This is a v1 ranker, not the final greedy
|
|
# optimizer — see references/interference-model.md.
|
|
#
|
|
# Usage: bash .claude/skills/unifi-wifi/scripts/model-rank.sh <site-name|site_id> [days=7] [band=ng|na|6e|all]
|
|
set -euo pipefail
|
|
REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
|
|
# Cloud connector path (non-UOS console): `model-rank.sh --console "<name>" [days] [band]` routes to
|
|
# rf-analyze.py (Site Manager API; see references/site-manager-api.md). The UOS Mongo path below is unchanged.
|
|
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: model-rank.sh --console <name> [--site <s>] [days] [band]"; exit 2; }
|
|
CDAYS="${CPOS[0]:-7}"; 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" rank --console "$CONSOLE" --days "$CDAYS" --band "$CBAND" --site "$CSITE"
|
|
fi
|
|
UOS="$REPO/.claude/scripts/uos-mongo.sh"
|
|
arg="${1:?usage: model-rank.sh <site> [days] [band] | model-rank.sh --console <name> [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}"
|
|
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
|
|
echo "[INFO] site=$SITE window=${DAYS}d band=$BAND"
|
|
|
|
cat <<JS | bash "$UOS" 2>&1 | grep -viE 'pq.html|post-quantum|store now|server may need'
|
|
var SITE='$SITE', DAYS=$DAYS, BAND='$BAND';
|
|
var ace=db.getSiblingDB('ace'), st=db.getSiblingDB('ace_stat');
|
|
var since = new Date().getTime() - DAYS*86400000;
|
|
var bands = (BAND=='all') ? ['ng','na','6e'] : [BAND];
|
|
// ap mac -> name
|
|
var name={}; ace.device.find({site_id:SITE,type:'uap'},{mac:1,name:1}).forEach(function(a){name[a.mac]=a.name||a.mac;});
|
|
// roam volume per AP (coverage-overlap proxy: high roam = redundant neighbors exist)
|
|
var roam={};
|
|
st.wifi_connectivity_event.find({site_id:SITE, time:{\$gte:since}},{from_endpoint:1,to_endpoint:1}).forEach(function(e){
|
|
[e.from_endpoint&&e.from_endpoint.mac, e.to_endpoint&&e.to_endpoint.mac].forEach(function(m){ if(m) roam[m]=(roam[m]||0)+1; });
|
|
});
|
|
// airtime profile per AP per band from stat_hourly
|
|
var prof={};
|
|
st.stat_hourly.find({o:'ap', site_id:SITE, time:{\$gte:since}}).forEach(function(d){
|
|
var ap=d.ap; if(!ap) return; if(!prof[ap])prof[ap]={};
|
|
bands.forEach(function(b){
|
|
var cu=d[b+'-cu_total'], intf=d[b+'-cu_interf'], sta=d[b+'-num_sta'];
|
|
if(cu==null && intf==null) return;
|
|
if(!prof[ap][b])prof[ap][b]={cu:0,intf:0,sta:0,n:0};
|
|
var p=prof[ap][b]; p.cu+=(cu||0); p.intf+=(intf||0); p.sta+=(sta||0); p.n++;
|
|
});
|
|
});
|
|
bands.forEach(function(b){
|
|
var rows=[];
|
|
for(var ap in prof){ var p=prof[ap][b]; if(!p||!p.n) continue;
|
|
var avgCu=p.cu/p.n, avgIntf=p.intf/p.n, avgSta=p.sta/p.n, rm=roam[ap]||0;
|
|
// score: airtime pressure (cu+interf) weighted, * redundancy(roam) / (load+1)
|
|
var score = (avgCu + avgIntf) * Math.log(1+rm) / (1+avgSta);
|
|
rows.push({ap:ap, name:name[ap]||ap, cu:avgCu, intf:avgIntf, sta:avgSta, roam:rm, score:score});
|
|
}
|
|
rows.sort(function(a,b){return b.score-a.score;});
|
|
print("\\n==== band="+b+" top airtime-reduction candidates (disable/power-down) ====");
|
|
print(" rank AP cu% interf% ~clients roams score hint");
|
|
rows.slice(0,15).forEach(function(r,i){
|
|
var hint = (r.roam>50 && r.sta<3) ? "DISABLE (redundant, low load)" : (r.cu+r.intf>40 ? "POWER-DOWN (busy)" : "review");
|
|
print(" "+(i+1)+"\\t"+(r.name.substring(0,24)+" ").substring(0,24)+" "+r.cu.toFixed(0)+"\\t"+r.intf.toFixed(0)+"\\t"+r.sta.toFixed(1)+"\\t"+r.roam+"\\t"+r.score.toFixed(1)+"\\t"+hint);
|
|
});
|
|
print(" (APs profiled on "+b+": "+rows.length+")");
|
|
});
|
|
print("\\n[note] v1 heuristic: score = (cu_total + cu_interf) * log(1+roams) / (clients+1). High = busy+interfered AND clients have somewhere else to roam = safe to shrink/disable. Validate per-zone before applying. Full greedy coverage-safe optimizer = v2 (interference-model.md).");
|
|
JS
|