towerops/lib/towerops/recommendations/rules/frequency_change.ex
Graham McIntire f41f5fa1aa feat(insights): AP frequency-change recommendation rule
When a wireless AP has a strong same-channel neighbor (RSSI >= -75 dBm)
and a measurably cleaner alternative channel exists in the same band,
the system now emits an ap_frequency_change insight with a specific
recommended channel and the top interfering BSSIDs as evidence.

- New schema wireless_neighbor_scans (append-only) storing observed
  neighboring APs per device: bssid, ssid, channel, frequency_mhz,
  channel_width_mhz, rssi_dbm, is_own_fleet, seen_at
- New columns on snmp_devices: current_channel, current_frequency_mhz,
  current_channel_width_mhz, last_radio_seen_at — populated by vendor
  SNMP profiles for APs that expose wireless OIDs
- Towerops.Recommendations.Rules.FrequencyChange — pure rule that scores
  each candidate channel using sum-of-linear-power over the last 24h of
  neighbor scans, recommends the cleanest if it beats the current by at
  least 6 dB. Critical urgency when current score >= 100.
- Towerops.Workers.RecommendationsRunWorker — hourly Oban cron, iterates
  organizations and runs all rules, uses insert_insight_if_new/1 for
  idempotent upsert
- LiveView /insights renders a purple frequency-change card with current
  vs recommended channel, dB-cleaner badge, and a top-offender list
  (BSSID, SSID, RSSI, own-fleet flag)
- LLM enrichment automatically applies to the new insight type

Also fixes two pre-existing flaky tests that surfaced in CI:

- test/towerops_web/live/agent_live_test.exs:567 — now sets an explicit
  Req.Test transport_error stub for ReleaseChecker and uses Req.Test.allow
  so the LiveView process inherits it. Previously relied on absence of a
  stub, which leaked across tests and produced different behavior under
  full-suite parallel runs.
- test/towerops_web/telemetry_test.exs:140 — relaxes the "no log" check
  to refute the specific messages the function under test would emit,
  rather than asserting an empty capture_log/1 buffer (which catches
  stray logs from concurrent tests, e.g. Postgrex sandbox disconnects).
2026-05-09 16:56:19 -05:00

225 lines
7.2 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Towerops.Recommendations.Rules.FrequencyChange do
@moduledoc """
Recommendation rule that detects access points whose current channel
has strong same-channel interference and a measurably better
alternative within the same band.
Inputs:
- `snmp_devices` rows with a non-nil `current_channel`
- `wireless_neighbor_scans` rows from the last 24h
Output:
- List of `%{}` attribute maps suitable for
`Towerops.Preseem.Insights.insert_insight_if_new/1`. Each insight has
type `"ap_frequency_change"` and metadata containing the current
channel, recommended channel, interference scores, and the top
offending neighbors observed on the current channel.
Scoring:
score(channel) = Σ 10^((rssi + 95) / 10)
over neighbor scans observed on `channel` in the last 24h. Higher
score = more interference. We recommend the candidate channel with
the lowest score, provided the improvement is at least 6 dB.
This rule is deterministic and side-effect free — the worker is
responsible for upserting the resulting insights.
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.WirelessNeighborScan
@lookback_seconds 24 * 3600
# Channel candidate lists per band. Conservative defaults — operators
# can refine per-deployment later.
@candidates_5ghz [36, 40, 44, 48, 149, 153, 157, 161, 165]
@candidates_24ghz [1, 6, 11]
# An interference contributor is "strong" enough to consider for an
# offender list when its RSSI is at or above this threshold.
@strong_offender_threshold_dbm -75
# Don't recommend a switch unless the alternative is at least this much
# quieter than the current channel (in dB-equivalent score units).
@min_improvement_db 6.0
# Score above this puts the recommendation at "critical" urgency.
@critical_score 100.0
@max_offenders 5
@type insight_attrs :: %{
required(:organization_id) => Ecto.UUID.t(),
required(:device_id) => Ecto.UUID.t(),
required(:type) => String.t(),
required(:urgency) => String.t(),
required(:channel) => String.t(),
required(:source) => String.t(),
required(:title) => String.t(),
required(:metadata) => map()
}
@doc """
Evaluate the rule for an organization and return a list of insight
attribute maps. Empty list when nothing should be recommended.
"""
@spec evaluate(Ecto.UUID.t()) :: [insight_attrs()]
def evaluate(organization_id) when is_binary(organization_id) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@lookback_seconds, :second)
|> DateTime.truncate(:second)
organization_id
|> load_aps()
|> Enum.map(&evaluate_ap(&1, organization_id, cutoff))
|> Enum.reject(&is_nil/1)
end
defp load_aps(organization_id) do
Repo.all(
from(s in SnmpDevice,
join: d in assoc(s, :device),
where: d.organization_id == ^organization_id and not is_nil(s.current_channel),
select: {d, s}
)
)
end
defp evaluate_ap({device, snmp}, organization_id, cutoff) do
scans = neighbor_scans_for(device.id, cutoff)
if has_strong_offender?(scans, snmp.current_channel) do
build_insight(device, snmp, scans, organization_id)
end
end
defp neighbor_scans_for(device_id, cutoff) do
Repo.all(
from(s in WirelessNeighborScan,
where: s.device_id == ^device_id and s.seen_at >= ^cutoff,
order_by: [desc: s.rssi_dbm]
)
)
end
defp has_strong_offender?(scans, current_channel) do
Enum.any?(scans, fn s ->
s.channel == current_channel and not is_nil(s.rssi_dbm) and
s.rssi_dbm >= @strong_offender_threshold_dbm
end)
end
defp build_insight(device, snmp, scans, organization_id) do
candidates = candidates_for(snmp.current_frequency_mhz, snmp.current_channel)
scored = score_channels(scans, candidates)
current_score = Map.get(scored, snmp.current_channel, 0.0)
{recommended_channel, recommended_score} =
Enum.min_by(scored, fn {_ch, score} -> score end, fn -> {nil, nil} end)
cond do
is_nil(recommended_channel) ->
nil
recommended_channel == snmp.current_channel ->
nil
not improvement_worth_it?(current_score, recommended_score) ->
nil
true ->
offenders = top_offenders(scans, snmp.current_channel)
%{
organization_id: organization_id,
device_id: device.id,
type: "ap_frequency_change",
urgency: urgency_for(current_score),
channel: "proactive",
source: "system",
title:
"AP #{device.name || snmp.sys_name || "(unnamed)"} should change channel: " <>
"#{snmp.current_channel}#{recommended_channel}",
metadata: %{
"current_channel" => snmp.current_channel,
"current_frequency_mhz" => snmp.current_frequency_mhz,
"channel_width_mhz" => snmp.current_channel_width_mhz,
"recommended_channel" => recommended_channel,
"current_score" => current_score,
"recommended_score" => recommended_score,
"improvement_db" => score_to_db(current_score) - score_to_db(recommended_score),
"top_offenders" => offenders,
"lookback_hours" => 24
}
}
end
end
defp candidates_for(frequency_mhz, current_channel) when is_integer(frequency_mhz) do
cond do
frequency_mhz >= 5000 -> @candidates_5ghz
frequency_mhz >= 2400 and frequency_mhz < 2500 -> @candidates_24ghz
true -> [current_channel]
end
end
defp candidates_for(_freq, current_channel), do: [current_channel]
defp score_channels(scans, candidates) do
by_channel =
scans
|> Enum.filter(&(&1.channel != nil and &1.rssi_dbm != nil))
|> Enum.group_by(& &1.channel)
Map.new(candidates, fn ch ->
score =
by_channel
|> Map.get(ch, [])
|> Enum.reduce(0.0, fn s, acc -> acc + linear_power(s.rssi_dbm) end)
{ch, score}
end)
end
# Convert dBm to a linear power-like contribution. Stronger signals
# contribute much more than weak ones (10 dB ≈ 10× contribution).
defp linear_power(rssi_dbm) do
:math.pow(10.0, (rssi_dbm + 95) / 10.0)
end
defp score_to_db(score) when is_number(score) and score > 0 do
10.0 * :math.log10(score)
end
defp score_to_db(_), do: 0.0
defp improvement_worth_it?(current, recommended) when is_number(current) and is_number(recommended) do
score_to_db(current) - score_to_db(recommended) >= @min_improvement_db
end
defp urgency_for(current_score) when current_score >= @critical_score, do: "critical"
defp urgency_for(_), do: "warning"
defp top_offenders(scans, current_channel) do
scans
|> Enum.filter(&(&1.channel == current_channel and not is_nil(&1.rssi_dbm)))
|> Enum.sort_by(& &1.rssi_dbm, :desc)
|> Enum.take(@max_offenders)
|> Enum.map(fn s ->
%{
"bssid" => s.bssid,
"ssid" => s.ssid,
"rssi_dbm" => s.rssi_dbm,
"channel" => s.channel,
"frequency_mhz" => s.frequency_mhz,
"is_own_fleet" => s.is_own_fleet,
"seen_at" => s.seen_at && DateTime.to_iso8601(s.seen_at)
}
end)
end
end