fix(wireless): model channel usage as spectrum, not channel numbers (v0.23.5-beta)
Closes #98, #97. The Channel Usage panel treated a radio as occupying a channel *number* and coloured a channel amber when two radios shared it. Both halves are wrong, and the result is not merely incomplete — it gives actively bad planning advice. Channels are not slots. In 2.4 GHz the centres are 5 MHz apart while a channel is 20 MHz wide, so an AP on channel 1 occupies about 2402-2422 MHz and pollutes channels 1 through 5. Showing 2 through 5 as free is why 1/6/11 exists as the only three-way non-overlapping plan, and a model built on channel indices cannot express that. Sharing a channel is also not the problem. Two radios on exactly the same channel hear each other and take turns; CSMA/CA does its job and the cost is airtime. Two radios partially overlapping cannot decode each other, transmit over one another, and the cost is corruption. Partial overlap has to rank above co-channel, and it was not represented at all. Occupancy is now modelled in megahertz and overlap classified by how two ranges relate. On the local fleet, a 40 MHz radio on channel 1 is correctly shown bleeding 5 MHz into two channel-6 radios — which are themselves correctly identified as co-channel with each other rather than as the worst problem in the band. The model lives in the backend (utils/rfSpectrum.ts) with 26 tests rather than in the browser, because it is correctness-critical and the frontend has no test runner. Tests pin the arithmetic that is easy to get wrong: 1/6/11 is clear, 1/3/5 is three partial overlaps, channels 4 and 8 abut exactly rather than overlapping, a 20 MHz radio nested inside an 80 MHz one is partial rather than co-channel, and a lone radio is clear rather than co-channel with itself. The panel now draws real spectrum to scale, stacking overlapping radios so each stays visible, with the clash list on hover naming the other radio and how many megahertz they share. Width is taken as the widest entry of a RouterOS capability list such as 20/40/80mhz, since that is the spectrum the radio may occupy and therefore what a planner must treat as unavailable. Per-location scoping (#97): analysis runs per CAPsMAN controller, with a selector, because two APs on channel 6 in different buildings are not a conflict. Radios with no controller are analysed together as standalone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e10f7ba568
commit
4cc8c84ea6
@@ -3,6 +3,7 @@ import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { lookupVendor } from '../utils/oui';
|
||||
import { analyzeSpectrum, summarize, BAND_RANGES } from '../utils/rfSpectrum';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -859,14 +860,35 @@ function deviceScope(req: Request): number | null {
|
||||
return Number.isFinite(id) ? id : null;
|
||||
}
|
||||
|
||||
// GET /api/wireless/rf/channels — active physical radios with band/frequency/width
|
||||
/**
|
||||
* GET /api/wireless/rf/channels — radios with their actual spectrum occupancy.
|
||||
*
|
||||
* Returns the interference analysis rather than raw channel numbers. Overlap is a
|
||||
* property of megahertz, not of channel indices, and getting it wrong gives
|
||||
* actively bad planning advice — so the model is computed and tested server-side
|
||||
* (see utils/rfSpectrum.ts) instead of being re-derived in the browser.
|
||||
*
|
||||
* Radios also carry the CAPsMAN controller that provisions them, because channel
|
||||
* planning is per physical location: two APs on channel 6 in different buildings
|
||||
* are not a conflict (issue #97).
|
||||
*/
|
||||
router.get('/rf/channels', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceScope(req);
|
||||
const rows = await query(`
|
||||
const rows = await query<{
|
||||
device_id: number; device_name: string; name: string; ssid: string | null;
|
||||
band: string | null; frequency: number; channel_width: string | null;
|
||||
registered_clients: number | null;
|
||||
controller_device_id: number | null; controller_name: string | null;
|
||||
}>(`
|
||||
SELECT wi.device_id, d.name AS device_name, wi.name, wi.ssid,
|
||||
wi.band, wi.frequency, wi.channel_width, wi.registered_clients
|
||||
wi.band, wi.frequency, wi.channel_width, wi.registered_clients,
|
||||
cr.controller_device_id,
|
||||
cd.name AS controller_name
|
||||
FROM wireless_interfaces wi
|
||||
JOIN devices d ON d.id = wi.device_id
|
||||
LEFT JOIN capsman_radios cr ON cr.matched_device_id = wi.device_id
|
||||
AND cr.radio_mac = wi.radio_mac
|
||||
LEFT JOIN devices cd ON cd.id = cr.controller_device_id
|
||||
WHERE (d.device_type = 'wireless_ap' OR d.wifi_role IN ('cap','controller','controller_cap'))
|
||||
AND wi.disabled = FALSE
|
||||
AND wi.frequency IS NOT NULL AND wi.frequency > 0
|
||||
@@ -874,7 +896,48 @@ router.get('/rf/channels', async (req: Request, res: Response) => {
|
||||
${deviceId ? 'AND wi.device_id = $1' : ''}
|
||||
ORDER BY wi.frequency ASC, d.name ASC
|
||||
`, deviceId ? [deviceId] : []);
|
||||
res.json(rows);
|
||||
|
||||
// Interference is only meaningful between radios that can hear each other, so
|
||||
// the analysis runs per location: each controller separately, and everything
|
||||
// unmanaged as one group.
|
||||
const groups = new Map<string, typeof rows>();
|
||||
for (const r of rows) {
|
||||
const key = r.controller_device_id != null ? `c:${r.controller_device_id}` : 'standalone';
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
|
||||
const analyzed: Record<string, unknown>[] = [];
|
||||
for (const group of groups.values()) {
|
||||
const verdicts = analyzeSpectrum(group);
|
||||
group.forEach((r, i) => {
|
||||
const v = verdicts[i];
|
||||
analyzed.push({
|
||||
...r,
|
||||
low_mhz: v.spectrum.lowMhz,
|
||||
high_mhz: v.spectrum.highMhz,
|
||||
width_mhz: v.spectrum.widthMhz,
|
||||
channel: v.spectrum.channel,
|
||||
overlap: v.kind,
|
||||
clashes: v.clashes.map((c) => ({
|
||||
kind: c.kind,
|
||||
overlap_mhz: c.overlapMhz,
|
||||
device_name: group[c.with]?.device_name ?? null,
|
||||
interface_name: group[c.with]?.name ?? null,
|
||||
channel: analyzeSpectrum([group[c.with]])[0].spectrum.channel,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const locations = [...groups.entries()].map(([key, group]) => ({
|
||||
key,
|
||||
name: group[0]?.controller_name ?? 'Standalone access points',
|
||||
controller_device_id: group[0]?.controller_device_id ?? null,
|
||||
radios: group.length,
|
||||
summary: summarize(analyzeSpectrum(group)),
|
||||
}));
|
||||
|
||||
res.json({ radios: analyzed, locations, bands: BAND_RANGES });
|
||||
});
|
||||
|
||||
// GET /api/wireless/rf/signals — active wireless clients' RSSI (for density view)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
spectrumFor, classifyPair, overlapMhz, analyzeSpectrum, summarize, freeSpans,
|
||||
widthMhz, channelForFreq, bandForFreq,
|
||||
} from '../rfSpectrum';
|
||||
|
||||
const ch = (n: number) => 2407 + n * 5; // 2.4 GHz channel → centre MHz
|
||||
const radio = (frequency: number, channel_width = '20mhz') => ({ frequency, channel_width });
|
||||
|
||||
describe('widthMhz', () => {
|
||||
it('reads a pinned width', () => {
|
||||
expect(widthMhz('20mhz')).toBe(20);
|
||||
expect(widthMhz('80mhz')).toBe(80);
|
||||
});
|
||||
|
||||
/**
|
||||
* RouterOS reports a capability list on a radio allowed to bond opportunistically.
|
||||
* The widest entry is the spectrum it may occupy, so that is what a planner must
|
||||
* treat as unavailable.
|
||||
*/
|
||||
it('takes the widest entry of a capability list', () => {
|
||||
expect(widthMhz('20/40/80mhz')).toBe(80);
|
||||
expect(widthMhz('20/40mhz')).toBe(40);
|
||||
});
|
||||
|
||||
it('defaults to 20 MHz when unknown', () => {
|
||||
expect(widthMhz(undefined)).toBe(20);
|
||||
expect(widthMhz(null)).toBe(20);
|
||||
expect(widthMhz('auto')).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('band and channel mapping', () => {
|
||||
it('maps 2.4 GHz channels', () => {
|
||||
expect(channelForFreq(2412)).toBe(1);
|
||||
expect(channelForFreq(2437)).toBe(6);
|
||||
expect(channelForFreq(2462)).toBe(11);
|
||||
expect(bandForFreq(2437)).toBe('2.4');
|
||||
});
|
||||
|
||||
it('maps 5 and 6 GHz', () => {
|
||||
expect(channelForFreq(5180)).toBe(36);
|
||||
expect(bandForFreq(5180)).toBe('5');
|
||||
expect(bandForFreq(6115)).toBe('6');
|
||||
});
|
||||
|
||||
it('returns null outside known bands', () => {
|
||||
expect(bandForFreq(900)).toBeNull();
|
||||
expect(channelForFreq(900)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectrumFor', () => {
|
||||
it('centres the occupied range on the frequency', () => {
|
||||
const s = spectrumFor(2412, '20mhz');
|
||||
expect(s.lowMhz).toBe(2402);
|
||||
expect(s.highMhz).toBe(2422);
|
||||
expect(s.channel).toBe(1);
|
||||
});
|
||||
|
||||
it('widens the range for a bonded channel', () => {
|
||||
const s = spectrumFor(5180, '80mhz');
|
||||
expect(s.lowMhz).toBe(5140);
|
||||
expect(s.highMhz).toBe(5220);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyPair — the heart of issue #98', () => {
|
||||
/**
|
||||
* The case the old channel-index model got wrong. Channels 1 and 3 are 10 MHz
|
||||
* apart while each is 20 MHz wide, so they share half their spectrum — yet a
|
||||
* model keyed on channel numbers reports them as two separate, fine channels.
|
||||
*/
|
||||
it('calls two adjacent 2.4 GHz channels a partial overlap', () => {
|
||||
expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(3)))).toBe('partial');
|
||||
expect(overlapMhz(spectrumFor(ch(1)), spectrumFor(ch(3)))).toBe(10);
|
||||
});
|
||||
|
||||
it('calls the classic 1 / 6 / 11 plan clear', () => {
|
||||
expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(6)))).toBe('clear');
|
||||
expect(classifyPair(spectrumFor(ch(6)), spectrumFor(ch(11)))).toBe('clear');
|
||||
expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(11)))).toBe('clear');
|
||||
});
|
||||
|
||||
/**
|
||||
* Channel spacing is 5 MHz, so the arithmetic is unintuitive and worth pinning
|
||||
* down: 1 and 4 are 15 MHz apart and share 5 MHz, while 4 and 8 are a full
|
||||
* 20 MHz apart and merely abut. A plan of 1/4/8 is therefore *partly* broken —
|
||||
* exactly the nuance a channel-number model cannot express.
|
||||
*/
|
||||
it('distinguishes overlap from mere adjacency', () => {
|
||||
expect(classifyPair(spectrumFor(ch(1)), spectrumFor(ch(4)))).toBe('partial');
|
||||
expect(overlapMhz(spectrumFor(ch(1)), spectrumFor(ch(4)))).toBe(5);
|
||||
|
||||
expect(classifyPair(spectrumFor(ch(4)), spectrumFor(ch(8)))).toBe('clear');
|
||||
expect(overlapMhz(spectrumFor(ch(4)), spectrumFor(ch(8)))).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sharing a channel exactly is the *benign* case: the radios decode each other
|
||||
* and take turns. Ranking it below partial overlap is the point of the issue.
|
||||
*/
|
||||
it('calls an identical channel co-channel, not partial', () => {
|
||||
expect(classifyPair(spectrumFor(ch(6)), spectrumFor(ch(6)))).toBe('co-channel');
|
||||
});
|
||||
|
||||
it('treats a narrow radio nested inside a bonded one as partial', () => {
|
||||
// 20 MHz on ch 40 sits entirely inside 80 MHz centred on ch 36.
|
||||
expect(classifyPair(spectrumFor(5180, '80mhz'), spectrumFor(5200, '20mhz'))).toBe('partial');
|
||||
});
|
||||
|
||||
it('never clashes across bands', () => {
|
||||
expect(classifyPair(spectrumFor(2437), spectrumFor(5180))).toBe('clear');
|
||||
expect(overlapMhz(spectrumFor(2437), spectrumFor(5180))).toBe(0);
|
||||
});
|
||||
|
||||
it('treats touching-but-not-overlapping ranges as clear', () => {
|
||||
// 2402–2422 and 2422–2442 share only a boundary.
|
||||
expect(classifyPair(spectrumFor(2412), spectrumFor(2432))).toBe('clear');
|
||||
});
|
||||
|
||||
it('is symmetric', () => {
|
||||
const a = spectrumFor(ch(1)), b = spectrumFor(ch(3));
|
||||
expect(classifyPair(a, b)).toBe(classifyPair(b, a));
|
||||
expect(overlapMhz(a, b)).toBe(overlapMhz(b, a));
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeSpectrum', () => {
|
||||
it('reports a lone radio as clear, not co-channel with itself', () => {
|
||||
const [v] = analyzeSpectrum([radio(ch(6))]);
|
||||
expect(v.kind).toBe('clear');
|
||||
expect(v.clashes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('gives every radio in a 1 / 6 / 11 deployment a clean bill', () => {
|
||||
const v = analyzeSpectrum([radio(ch(1)), radio(ch(6)), radio(ch(11))]);
|
||||
expect(v.every((x) => x.kind === 'clear')).toBe(true);
|
||||
expect(summarize(v)).toEqual({ radios: 3, clear: 3, coChannel: 0, partial: 0 });
|
||||
});
|
||||
|
||||
it('flags the misleading case: three radios spread across 1, 3 and 5', () => {
|
||||
const v = analyzeSpectrum([radio(ch(1)), radio(ch(3)), radio(ch(5))]);
|
||||
expect(v.every((x) => x.kind === 'partial')).toBe(true);
|
||||
expect(summarize(v).partial).toBe(3);
|
||||
});
|
||||
|
||||
it('ranks partial above co-channel when a radio has both', () => {
|
||||
// ch6 twice (co-channel) plus a ch8 that partially overlaps them.
|
||||
const v = analyzeSpectrum([radio(ch(6)), radio(ch(6)), radio(ch(8))]);
|
||||
expect(v[0].kind).toBe('partial');
|
||||
expect(v[0].clashes[0].kind).toBe('partial'); // worst first
|
||||
expect(v[0].clashes.map((c) => c.kind)).toContain('co-channel');
|
||||
});
|
||||
|
||||
it('separates radios by band', () => {
|
||||
const v = analyzeSpectrum([radio(ch(6)), radio(5180, '80mhz'), radio(6115)]);
|
||||
expect(v.every((x) => x.kind === 'clear')).toBe(true);
|
||||
});
|
||||
|
||||
it('summarises a mixed deployment', () => {
|
||||
const v = analyzeSpectrum([radio(ch(1)), radio(ch(1)), radio(ch(4)), radio(ch(11))]);
|
||||
const s = summarize(v);
|
||||
expect(s.radios).toBe(4);
|
||||
expect(s.partial).toBe(3); // both ch1s and the ch4 overlap each other
|
||||
expect(s.clear).toBe(1); // ch11 is untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('freeSpans', () => {
|
||||
it('finds the room left in 2.4 GHz after channels 1 and 11', () => {
|
||||
const v = analyzeSpectrum([radio(ch(1)), radio(ch(11))]);
|
||||
const spans = freeSpans(v, '2.4');
|
||||
// The gap between them must be able to hold channel 6 (2427–2447).
|
||||
expect(spans.some((s) => s.startMhz <= 2427 && s.endMhz >= 2447)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no usable gap when a band is saturated', () => {
|
||||
const v = analyzeSpectrum([radio(2450, '160mhz')]); // swamps 2370–2530
|
||||
expect(freeSpans(v, '2.4')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores gaps narrower than a channel', () => {
|
||||
// ch1 (2402-2422) and ch5 (2422-2442) leave no gap at all.
|
||||
const v = analyzeSpectrum([radio(ch(1)), radio(ch(5))]);
|
||||
const spans = freeSpans(v, '2.4');
|
||||
expect(spans.every((s) => s.endMhz - s.startMhz >= 20)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the whole band when nothing is deployed', () => {
|
||||
expect(freeSpans([], '2.4')).toEqual([{ startMhz: 2400, endMhz: 2500 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Wi-Fi spectrum occupancy and interference analysis.
|
||||
*
|
||||
* The channel map used to model a radio as occupying a *channel number*, and
|
||||
* coloured a channel amber when two radios sat on it. Both halves of that are
|
||||
* wrong, and the result actively misleads spectrum planning
|
||||
* (github.com/2GT-Media-Group-LLC/mikrotik-manager/issues/98).
|
||||
*
|
||||
* **Channels are not slots.** In 2.4 GHz the channel centres are 5 MHz apart while
|
||||
* a channel is 20 MHz wide, so an AP on channel 1 occupies roughly 2401–2423 MHz
|
||||
* and pollutes channels 1 through 5. Showing channels 2–5 as free is not a missing
|
||||
* feature, it is bad advice: 1/6/11 is the only three-way non-overlapping set, and
|
||||
* a model built on channel indices cannot express why.
|
||||
*
|
||||
* **Sharing a channel is not the problem.** Two radios on exactly the same channel
|
||||
* hear each other and take turns — CSMA/CA does its job and the cost is airtime.
|
||||
* Two radios *partially* overlapping cannot decode each other, so they transmit
|
||||
* over one another and the cost is corruption. Partial overlap is the harmful case
|
||||
* and must rank above co-channel, not below it.
|
||||
*
|
||||
* So occupancy is modelled in megahertz and overlap is classified by how two
|
||||
* ranges relate, not by whether two numbers match.
|
||||
*/
|
||||
|
||||
export type RfBand = '2.4' | '5' | '6';
|
||||
|
||||
export interface BandRange {
|
||||
band: RfBand;
|
||||
label: string;
|
||||
startMhz: number;
|
||||
endMhz: number;
|
||||
}
|
||||
|
||||
/** Spectrum drawn for each band. Wide enough to contain every legal channel. */
|
||||
export const BAND_RANGES: BandRange[] = [
|
||||
{ band: '2.4', label: '2.4 GHz', startMhz: 2400, endMhz: 2500 },
|
||||
{ band: '5', label: '5 GHz', startMhz: 5150, endMhz: 5895 },
|
||||
{ band: '6', label: '6 GHz', startMhz: 5925, endMhz: 7125 },
|
||||
];
|
||||
|
||||
export function bandForFreq(freq: number): RfBand | null {
|
||||
if (freq >= 2400 && freq < 2500) return '2.4';
|
||||
if (freq >= 4900 && freq < 5925) return '5';
|
||||
if (freq >= 5925 && freq <= 7125) return '6';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function channelForFreq(freq: number): number | null {
|
||||
const band = bandForFreq(freq);
|
||||
if (band === '2.4') return freq === 2484 ? 14 : Math.round((freq - 2407) / 5);
|
||||
if (band === '5') return Math.round((freq - 5000) / 5);
|
||||
if (band === '6') return Math.round((freq - 5950) / 5);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operating width in MHz from a RouterOS width string.
|
||||
*
|
||||
* RouterOS reports a *capability* list such as `20/40/80mhz` on a radio configured
|
||||
* to bond opportunistically, and a single value such as `20mhz` when pinned. The
|
||||
* widest entry is taken, because that is the spectrum the radio may occupy and
|
||||
* therefore the spectrum a planner must treat as unavailable.
|
||||
*/
|
||||
export function widthMhz(width: string | null | undefined): number {
|
||||
if (!width) return 20;
|
||||
const all = (width.match(/\d+/g) || []).map(Number).filter((v) => v >= 20 && v <= 320);
|
||||
return all.length ? Math.max(...all) : 20;
|
||||
}
|
||||
|
||||
export interface RadioSpectrum {
|
||||
centerMhz: number;
|
||||
widthMhz: number;
|
||||
lowMhz: number;
|
||||
highMhz: number;
|
||||
band: RfBand | null;
|
||||
channel: number | null;
|
||||
}
|
||||
|
||||
/** The slice of spectrum a radio actually occupies. */
|
||||
export function spectrumFor(freq: number, width?: string | null): RadioSpectrum {
|
||||
const w = widthMhz(width);
|
||||
return {
|
||||
centerMhz: freq,
|
||||
widthMhz: w,
|
||||
lowMhz: freq - w / 2,
|
||||
highMhz: freq + w / 2,
|
||||
band: bandForFreq(freq),
|
||||
channel: channelForFreq(freq),
|
||||
};
|
||||
}
|
||||
|
||||
export type OverlapKind = 'clear' | 'co-channel' | 'partial';
|
||||
|
||||
/**
|
||||
* How two occupied ranges relate.
|
||||
*
|
||||
* Identical ranges are co-channel: the radios decode each other and share airtime.
|
||||
* Anything else that intersects — including one range nested inside a wider one —
|
||||
* is partial, because the two cannot reliably carrier-sense one another.
|
||||
*/
|
||||
export function classifyPair(a: RadioSpectrum, b: RadioSpectrum): OverlapKind {
|
||||
if (a.band !== b.band) return 'clear';
|
||||
const overlap = Math.min(a.highMhz, b.highMhz) - Math.max(a.lowMhz, b.lowMhz);
|
||||
if (overlap <= 0) return 'clear';
|
||||
if (a.lowMhz === b.lowMhz && a.highMhz === b.highMhz) return 'co-channel';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
/** How much spectrum two radios share, in MHz. */
|
||||
export function overlapMhz(a: RadioSpectrum, b: RadioSpectrum): number {
|
||||
if (a.band !== b.band) return 0;
|
||||
return Math.max(0, Math.min(a.highMhz, b.highMhz) - Math.max(a.lowMhz, b.lowMhz));
|
||||
}
|
||||
|
||||
const SEVERITY: Record<OverlapKind, number> = { clear: 0, 'co-channel': 1, partial: 2 };
|
||||
|
||||
export interface Clash {
|
||||
/** Index into the input array of the other radio. */
|
||||
with: number;
|
||||
kind: Exclude<OverlapKind, 'clear'>;
|
||||
overlapMhz: number;
|
||||
}
|
||||
|
||||
export interface RadioVerdict {
|
||||
spectrum: RadioSpectrum;
|
||||
/** The worst relationship this radio has with any other. */
|
||||
kind: OverlapKind;
|
||||
clashes: Clash[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify every radio against every other. Order-independent and symmetric.
|
||||
*
|
||||
* Radios on different bands never clash, and a radio is never compared with
|
||||
* itself — a single AP is `clear`, not co-channel with its own reflection.
|
||||
*/
|
||||
export function analyzeSpectrum(
|
||||
radios: { frequency: number; channel_width?: string | null }[]
|
||||
): RadioVerdict[] {
|
||||
const spectra = radios.map((r) => spectrumFor(r.frequency, r.channel_width));
|
||||
|
||||
return spectra.map((s, i) => {
|
||||
const clashes: Clash[] = [];
|
||||
let worst: OverlapKind = 'clear';
|
||||
|
||||
for (let j = 0; j < spectra.length; j++) {
|
||||
if (i === j) continue;
|
||||
const kind = classifyPair(s, spectra[j]);
|
||||
if (kind === 'clear') continue;
|
||||
clashes.push({ with: j, kind, overlapMhz: overlapMhz(s, spectra[j]) });
|
||||
if (SEVERITY[kind] > SEVERITY[worst]) worst = kind;
|
||||
}
|
||||
|
||||
// Worst first, so the UI leads with the problem rather than a benign neighbour.
|
||||
clashes.sort((a, b) => SEVERITY[b.kind] - SEVERITY[a.kind] || b.overlapMhz - a.overlapMhz);
|
||||
return { spectrum: s, kind: worst, clashes };
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpectrumSummary {
|
||||
radios: number;
|
||||
clear: number;
|
||||
coChannel: number;
|
||||
partial: number;
|
||||
}
|
||||
|
||||
export function summarize(verdicts: RadioVerdict[]): SpectrumSummary {
|
||||
return {
|
||||
radios: verdicts.length,
|
||||
clear: verdicts.filter((v) => v.kind === 'clear').length,
|
||||
coChannel: verdicts.filter((v) => v.kind === 'co-channel').length,
|
||||
partial: verdicts.filter((v) => v.kind === 'partial').length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous stretches of a band that no radio occupies — where an additional AP
|
||||
* could go without partially overlapping anything already deployed.
|
||||
*
|
||||
* Only spans at least `minWidth` wide are returned, since a 5 MHz gap between two
|
||||
* 20 MHz radios is not somewhere you can put an access point.
|
||||
*/
|
||||
export function freeSpans(
|
||||
verdicts: RadioVerdict[],
|
||||
band: RfBand,
|
||||
minWidth = 20
|
||||
): { startMhz: number; endMhz: number }[] {
|
||||
const range = BAND_RANGES.find((b) => b.band === band);
|
||||
if (!range) return [];
|
||||
|
||||
const occupied = verdicts
|
||||
.filter((v) => v.spectrum.band === band)
|
||||
.map((v) => ({ lo: Math.max(v.spectrum.lowMhz, range.startMhz), hi: Math.min(v.spectrum.highMhz, range.endMhz) }))
|
||||
.filter((o) => o.hi > o.lo)
|
||||
.sort((a, b) => a.lo - b.lo);
|
||||
|
||||
const spans: { startMhz: number; endMhz: number }[] = [];
|
||||
let cursor = range.startMhz;
|
||||
for (const o of occupied) {
|
||||
if (o.lo - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: o.lo });
|
||||
cursor = Math.max(cursor, o.hi);
|
||||
}
|
||||
if (range.endMhz - cursor >= minWidth) spans.push({ startMhz: cursor, endMhz: range.endMhz });
|
||||
return spans;
|
||||
}
|
||||
Reference in New Issue
Block a user