unifi-wifi: model-rank + optimize-radios run on cloud-connector data (non-UOS consoles)

Both analyses now accept `--console "<name>"` and run against the UniFi cloud connector
instead of the UOS Mongo server, so RF airtime tuning works on standalone/non-UOS consoles
(e.g. Brooklyn/Skybar). The UOS Mongo path is unchanged.

- New shared analyzer scripts/rf-analyze.py: pulls per-AP/band airtime history via the
  connector POST /stat/report/hourly.ap (SAME schema as ace_stat.stat_hourly) + /stat/device
  for names/zones, derives cu_interf = cu_total - cu_self_rx - cu_self_tx, and runs the SAME
  model-rank ranking and optimize-radios greedy power-down/disable logic (ported faithfully).
- Roam graph (/stat/event) is usually empty on small/stationary sites -> graceful degrade:
  model-rank ranks by airtime pressure; optimize-radios returns power-down candidates + 0
  disables (coverage-safe). NEIGHBOR_JSON (SNR matrix) still enables disables, as on UOS.
- model-rank.sh / optimize-radios.sh: added the `--console` route (resolves the key from
  vault services/unifi-site-manager, execs rf-analyze.py). Validated on Brooklyn/Skybar:
  2.4GHz saturated (Yoga AP cu 63%/interf 55%), 5GHz idle (1-5%) - the expected pain-band split.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 08:43:09 -07:00
parent 6fdc21d955
commit f987812fe5
4 changed files with 269 additions and 2 deletions

View File

@@ -72,6 +72,18 @@ bash $S net brooklyn clients # DEEP per-client RSSI/signal/satisfaction
bash $S net brooklyn raw /integration/v1/sites # proxy any /proxy/network/... path
```
## Analysis on non-UOS consoles (model-rank / optimize-radios)
The existing airtime-reduction analyses run against cloud-connector data via a `--console` flag
(UOS-Mongo path unchanged). They pull `/stat/report/hourly.ap` (same schema as `stat_hourly`)
through the connector and run the SAME logic via `scripts/rf-analyze.py`:
```bash
bash .claude/skills/unifi-wifi/scripts/model-rank.sh --console "Brooklyn/Skybar" 7 ng
bash .claude/skills/unifi-wifi/scripts/optimize-radios.sh --console "Brooklyn/Skybar" 14 ng
```
Roam graph is usually EMPTY on small/stationary sites (no `/stat/event` roam log) -> model-rank
ranks by airtime pressure only; optimize-radios returns power-down candidates and 0 disables
(coverage-safe). For disables, supply `NEIGHBOR_JSON` (AP-to-AP SNR from neighbor-collect) as on UOS.
## Gotchas
- Direct WAN SSH/HTTPS to a standalone UDM is usually firewalled (Brooklyn 67.1.139.219:
22/443/8443 filtered) - the connector is the remote path, not direct.

View File

@@ -11,8 +11,17 @@
# 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
CONSOLE="${2:?--console needs a console name}"; CDAYS="${3:-7}"; CBAND="${4:-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"
fi
UOS="$REPO/.claude/scripts/uos-mongo.sh"
arg="${1:?usage: model-rank.sh <site> [days] [band]}"; DAYS="${2:-7}"; BAND="${3:-ng}"
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; }

View File

@@ -20,8 +20,18 @@
# 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
CONSOLE="${2:?--console needs a console name}"; CDAYS="${3:-14}"; CBAND="${4:-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"
fi
UOS="$REPO/.claude/scripts/uos-mongo.sh"
arg="${1:?usage: optimize-radios.sh <site> [days] [band]}"; DAYS="${2:-14}"; BAND="${3:-ng}"
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

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# rf-analyze.py -- run the unifi-wifi airtime-reduction analyses against the UniFi cloud
# CONNECTOR (api.ui.com) for NON-UOS consoles. Same logic as the Mongo-fed model-rank.sh /
# optimize-radios.sh, but the data comes from the console's LOCAL Network API proxied through
# the cloud connector (see references/site-manager-api.md):
# - per-AP/band airtime history: POST /stat/report/hourly.ap (SAME schema as ace_stat.stat_hourly)
# - identity/zone: GET /stat/device (type=uap, name -> floor/zone)
# - roam graph (optional): POST /stat/event (often empty on small/stationary sites ->
# degrades to airtime-pressure ranking / power-down-only planning)
#
# Usage (normally invoked by model-rank.sh/optimize-radios.sh with --console):
# UNIFI_SM_KEY=<key> rf-analyze.py rank|optimize --console "<name|hostId>" [--days N] [--band ng|na|6e] [--site default]
# env (optimize): ROAM_MIN(4) CAP(85) ZONE_DISABLE_PCT(40) REDUN_NG(2) REDUN_OTHER(1)
import json, os, sys, math, re, urllib.request, urllib.error
KEY = os.environ.get('UNIFI_SM_KEY', '')
BASE = 'https://api.ui.com'
def api(path, method='GET', body=None, quiet=False):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method,
headers={'X-API-KEY': KEY, 'Accept': 'application/json', 'Content-Type': 'application/json'})
try:
return json.loads(urllib.request.urlopen(req, timeout=90).read())
except urllib.error.HTTPError as e:
if not quiet: sys.stderr.write('[rf-analyze] %s %s -> HTTP %s\n' % (method, path, e.code))
return None
except Exception as e:
if not quiet: sys.stderr.write('[rf-analyze] %s %s -> %s\n' % (method, path, e))
return None
def die(m): sys.stderr.write('[ERROR] %s\n' % m); sys.exit(1)
# ---- args ----
if len(sys.argv) < 2 or sys.argv[1] not in ('rank', 'optimize'):
die('usage: rf-analyze.py rank|optimize --console <name> [--days N] [--band ng|na|6e] [--site default]')
MODE = sys.argv[1]
opt = {'--console': None, '--days': '7' if MODE == 'rank' else '14', '--band': 'ng', '--site': 'default'}
a = sys.argv[2:]; i = 0
while i < len(a):
if a[i] in opt: opt[a[i]] = a[i+1]; i += 2
else: i += 1
CONSOLE = opt['--console'] or die('--console required')
DAYS = int(opt['--days']); BAND = opt['--band']; SITE = opt['--site']
if BAND not in ('ng', 'na', '6e'): die('band must be ng|na|6e')
if not KEY: die('no UNIFI_SM_KEY (the .sh wrapper resolves it from vault services/unifi-site-manager)')
# ---- resolve console -> hostId ----
hosts = (api('/v1/hosts') or {}).get('data', [])
HID = CNAME = None
for h in hosts:
nm = ((h.get('reportedState') or {}).get('name')) or ''
if h.get('id', '').lower() == CONSOLE.lower() or CONSOLE.lower() in nm.lower():
HID, CNAME = h['id'], nm; break
if not HID: die("no console matching '%s' in the Site Manager fleet" % CONSOLE)
CB = '/v1/connector/consoles/%s/proxy/network/api/s/%s' % (HID, SITE)
# ---- identity + zone from /stat/device ----
dev = (api(CB + '/stat/device') or {}).get('data', [])
name, zone = {}, {}
for d in dev:
if d.get('type') != 'uap': continue
n = d.get('name') or d.get('mac'); name[d['mac']] = n
fz = re.search(r'(\d)(?:st|nd|rd|th)\s*floor', str(n), re.I)
rm = re.search(r'\b(\d)\d{2}\b', str(n))
zone[d['mac']] = ('Floor ' + fz.group(1)) if fz else (('Floor ' + rm.group(1)) if rm else 'misc')
if not name: die('no APs (type=uap) on console %s site %s' % (CNAME, SITE))
# ---- airtime history from /stat/report/hourly.ap (same schema as ace_stat.stat_hourly) ----
import time as _t
end = int(_t.time() * 1000); start = end - DAYS * 86400000
attrs = ['time', 'ap'] + ['%s-%s' % (BAND, f) for f in
('cu_total', 'cu_self_rx', 'cu_self_tx', 'cu_interf', 'num_sta', 'tx_retries', 'wifi_tx_attempts', 'satisfaction')]
rep = api(CB + '/stat/report/hourly.ap', 'POST', {'attrs': attrs, 'start': start, 'end': end})
rows = (rep or {}).get('data', [])
prof = {}
for d in rows:
ap = d.get('ap')
if not ap or ap not in name: continue
cu = d.get(BAND + '-cu_total');
if cu is None: continue
self_ = (d.get(BAND + '-cu_self_rx') or 0) + (d.get(BAND + '-cu_self_tx') or 0)
intf = d.get(BAND + '-cu_interf')
if intf is None: intf = max(0.0, (cu or 0) - self_) # derive when not reported
sta = d.get(BAND + '-num_sta') or 0
retr = d.get(BAND + '-tx_retries') or 0; att = d.get(BAND + '-wifi_tx_attempts') or 0
sat = d.get(BAND + '-satisfaction')
p = prof.setdefault(ap, {'cu': 0, 'intf': 0, 'self': 0, 'sta': 0, 'staPk': 0, 'retr': 0, 'att': 0, 'sat': 0, 'satN': 0, 'n': 0})
p['cu'] += cu or 0; p['intf'] += intf; p['self'] += self_; p['sta'] += sta; p['staPk'] = max(p['staPk'], sta)
p['retr'] += retr; p['att'] += att
if isinstance(sat, (int, float)): p['sat'] += sat; p['satN'] += 1
p['n'] += 1
for k, p in prof.items():
for f in ('cu', 'intf', 'self', 'sta'): p[f] /= p['n']
p['retrPct'] = min(100.0, 100.0 * p['retr'] / p['att']) if p['att'] > 0 else 0.0
p['sat'] = p['sat'] / p['satN'] if p['satN'] else 100.0
# ---- roam graph (optional; often empty on small/stationary sites) ----
roam = {}; dir_ = {}; rss = {}
ev = api(CB + '/stat/event', 'POST', {'_limit': 3000, 'within': DAYS * 24}, quiet=True)
for e in ((ev or {}).get('data', []) if ev else []):
if 'Roam' not in str(e.get('key', '')): continue
A = e.get('ap_from') or e.get('ap'); B = e.get('ap_to') or e.get('ap')
if A: roam[A] = roam.get(A, 0) + 1
if B: roam[B] = roam.get(B, 0) + 1
if A and B and A != B: dir_[A + '>' + B] = dir_.get(A + '>' + B, 0) + 1
HAVE_ROAM = bool(roam)
bn = {'ng': '2.4', 'na': '5', '6e': '6'}[BAND]
print('[INFO] console=%s site=%s band=%s(%sGHz) window=%dd APs profiled=%d roam-graph=%s (source: cloud connector)'
% (CNAME, SITE, BAND, bn, DAYS, len(prof), 'yes' if HAVE_ROAM else 'EMPTY -> airtime-only'))
if not prof: die('no airtime history for band %s (try another band, or the APs may be idle)' % BAND)
# ============================== RANK (model-rank port) ==============================
if MODE == 'rank':
rows_out = []
for ap, p in prof.items():
rm = roam.get(ap, 0)
# with roam: (cu+intf)*log(1+roam)/(1+sta); without: airtime pressure / load (roam-neutral)
score = (p['cu'] + p['intf']) * (math.log(1 + rm) if HAVE_ROAM else 1.0) / (1 + p['sta'])
rows_out.append((ap, name.get(ap, ap), p['cu'], p['intf'], p['sta'], rm, score))
rows_out.sort(key=lambda r: -r[6])
print('\n==== band=%s top airtime-reduction candidates (disable/power-down) ====' % BAND)
print(' rank AP cu% interf% ~clients roams score hint')
for i, r in enumerate(rows_out[:15]):
ap, nm, cu, intf, sta, rm, sc = r
hint = ('DISABLE (redundant, low load)' if (rm > 50 and sta < 3)
else 'POWER-DOWN (busy)' if (cu + intf > 40) else 'review')
print(' %d\t%-24s %.0f\t%.0f\t%.1f\t%d\t%.1f\t%s' % (i+1, nm[:24], cu, intf, sta, rm, sc, hint))
print(' (APs profiled on %s: %d)' % (BAND, len(rows_out)))
note = 'score = (cu_total + cu_interf) * log(1+roams) / (clients+1)'
if not HAVE_ROAM:
note = ('score = (cu_total + cu_interf) / (clients+1) [ROAM GRAPH EMPTY on this console -- '
'ranking by airtime pressure only; no redundancy weighting]')
print('\n[note] v1 heuristic: %s. High = busy+interfered (and clients have somewhere to roam, if roam known) '
'= safe to shrink/disable. Validate per-zone before applying.' % note)
sys.exit(0)
# ============================== OPTIMIZE (optimize-radios port) ==============================
ROAM_MIN = int(os.environ.get('ROAM_MIN', '4')); CAP = float(os.environ.get('CAP', '85'))
ZPCT = float(os.environ.get('ZONE_DISABLE_PCT', '40'))
REDUN = int(os.environ.get('REDUN_NG', '2')) if BAND == 'ng' else int(os.environ.get('REDUN_OTHER', '1'))
RSSI_OK = {'ng': -68, 'na': -72, '6e': -75}[BAND]
# strong bidirectional neighbors (from roam dir edges). No roam + no neighbor matrix -> empty -> no disables.
strong = {a: {} for a in prof}
for A in prof:
for B in prof:
if A == B: continue
if min(dir_.get(A + '>' + B, 0), dir_.get(B + '>' + A, 0)) >= ROAM_MIN:
strong[A][B] = 1
# optional measured SNR matrix (neighbor-collect) via env NEIGHBOR_JSON
nj = os.environ.get('NEIGHBOR_JSON', '')
if nj and os.path.isfile(nj):
nbr = json.load(open(nj)); MIN = int(os.environ.get('NBR_SNR_MIN', '20'))
def ms(x, y): o = nbr.get(x, {}); return max([o.get(b, {}).get(y, -1) for b in ('2.4', '5', '6')] + [-1])
nm2mac = {v: k for k, v in name.items()}
strong = {a: {} for a in prof}
aps_n = list(nbr)
for x in aps_n:
for y in aps_n:
if x == y: continue
if ms(x, y) >= MIN and ms(y, x) >= MIN:
A, B = nm2mac.get(x), nm2mac.get(y)
if A in prof and B in prof: strong[A][B] = 1; strong[B][A] = 1
aps = list(prof); active = {a: True for a in aps}; projCu = {a: prof[a]['cu'] for a in aps}
zoneTot = {}; zoneDis = {}
for a in aps: zoneTot[zone[a]] = zoneTot.get(zone[a], 0) + 1
def activeStrong(a): return [n for n in strong.get(a, {}) if active.get(n)]
def absorbers(a): return [n for n in activeStrong(a) if projCu[n] + prof[a]['self'] <= CAP]
def soleNeighborOf(a):
for x in aps:
if not active.get(x) or x == a or not strong.get(x, {}).get(a): continue
if sum(1 for n in strong.get(x, {}) if n != a and active.get(n)) == 0: return True
return False
disabled = []
go = True
while go:
go = False; best = None; bestBen = -1
for a in aps:
if not active[a]: continue
p = prof[a]
if len(activeStrong(a)) < REDUN: continue
if len(absorbers(a)) < 1: continue
if soleNeighborOf(a): continue
if (zoneDis.get(zone[a], 0) + 1) > math.floor(zoneTot[zone[a]] * ZPCT / 100): continue
ben = p['intf'] + 0.5 * p['retrPct']
if ben > bestBen: bestBen = ben; best = a
if best:
ab = absorbers(best); active[best] = False; zoneDis[zone[best]] = zoneDis.get(zone[best], 0) + 1
share = prof[best]['self'] / len(ab)
for n in ab: projCu[n] += share
disabled.append({'ap': best, 'cover': [name[n] for n in ab]}); go = True
powerdown = []; keep = []
for a in aps:
if not active[a]: continue
p = prof[a]
if p['n'] < 3 or (p['cu'] < 5 and p['intf'] < 5): keep.append((a, 'idle/off')); continue
if p['cu'] >= 40 or p['intf'] >= 35 or p['retrPct'] >= 15 or p['sat'] < 80: powerdown.append(a)
else: keep.append((a, 'low-util'))
def fmt(a):
p = prof[a]
return '%s cu=%.0f%% interf=%.0f%% self=%.0f%% clients(avg/pk)=%.1f/%.0f retr=%.0f%% sat=%.0f' % (
name[a], p['cu'], p['intf'], p['self'], p['sta'], p['staPk'], p['retrPct'], p['sat'])
def byZone(items, kf):
z = {}
for x in items: z.setdefault(zone[kf(x)], []).append(x)
return z
print('[INFO] band=%s rssi>=%d roam>=%d cap=%.0f%% need_neighbors=%d zone_cap=%.0f%% adjacency=%s'
% (BAND, RSSI_OK, ROAM_MIN, CAP, REDUN, ZPCT,
('neighbor SNR matrix' if nj else ('roam graph' if HAVE_ROAM else 'NONE -> power-down only'))))
print('==== PLAN band=%s (APs=%d) POWER-DOWN(first)=%d DISABLE(after)=%d KEEP=%d ===='
% (BAND, len(aps), len(powerdown), len(disabled), len(keep)))
print('Phase A: POWER-DOWN the busy/thrashing radios to ~Low (smaller cells cut mutual cu_interf zone-wide). Do FIRST.')
pz = byZone(powerdown, lambda a: a)
for z in sorted(pz):
print(' [%s] %d' % (z, len(pz[z])))
for a in pz[z][:6]: print(' ' + fmt(a))
if len(pz[z]) > 6: print(' ...(+%d)' % (len(pz[z]) - 6))
print('\nPhase B: re-measure after power-down settles (cu_interf should drop, headroom appears).')
save = sum(prof[d['ap']]['intf'] for d in disabled)
print('\nPhase C: DISABLE only-when-redundant radios (each has >=%d bidirectional good neighbors WITH headroom). '
'Est. interference airtime removed: %.0f.' % (REDUN, save))
if not disabled:
why = 'roam graph empty + no neighbor SNR matrix -> no proven overlap' if not HAVE_ROAM and not nj else 'none clear the capacity+coverage bar yet'
print(' (none -- %s. Power-down is still coverage-safe; for disables, supply NEIGHBOR_JSON from neighbor-collect.)' % why)
dz = byZone(disabled, lambda d: d['ap'])
for z in sorted(dz):
print(' [%s]' % z)
for d in dz[z]: print(' ' + fmt(d['ap']) + ' -> covered by: ' + ', '.join(d['cover'][:3]))
print('\nKEEP: %d radios (isolated-essential or already efficient). Apply per ZONE; never >%.0f%% disabled/zone; validate before+after.'
% (len(keep), ZPCT))