diff --git a/CHANGELOG.txt b/CHANGELOG.txt index bf1a9306..a9d2f2b0 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,23 @@ +2026-05-09 +feat(insights): RF-aware channel-conflict — multi-source noise + Preseem + Towerops.Snmp.RfHealth — cross-source helper that combines: + * Most recent noise-floor / noise sensor value (dBm) on the AP + * Preseem rf_score and qoe_score from the linked AccessPoint row + * Average CPE SNR and client count from wireless_clients + Returns a snapshot map and an `interference_severity/1` classifier + (:critical | :warning | :ok). Critical when noise floor >= -80 dBm + OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with + active clients. + OwnFleetChannelConflict rule now enriches each conflicting AP with + noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and per-AP + rf_severity. Urgency promotes to critical when 3+ APs share a + channel OR any conflicting AP shows critical RF health — so a + same-channel conflict in a clean RF environment stays "warning" + while one in a contaminated environment escalates. + UI: amber card now shows a small evidence table with width, noise, + rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF + environment detected" badge when has_critical_rf is true. + 2026-05-09 feat(snmp): canonical AP radio state + own-fleet channel conflict rule Towerops.Snmp.RadioState — derives current_channel from existing diff --git a/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex index 542a7b25..c70a1612 100644 --- a/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex +++ b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex @@ -8,9 +8,17 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do rule needs no neighbor scan data — just the `current_channel` already populated on `snmp_devices` by `Towerops.Snmp.RadioState`. - Emits one `own_fleet_channel_conflict` insight per (site, channel) - group, listing the conflicting APs in the metadata so the operator - can pick which one to move. + Each conflicting AP is enriched with cross-source RF health data via + `Towerops.Snmp.RfHealth` so the operator sees the real impact: + + * Noise floor (dBm) from the AP's most recent noise-floor sensor + * Preseem rf_score and qoe_score (when the AP is matched) + * Average CPE SNR for currently connected subscribers + + Urgency is bumped to **critical** when: + * 3+ APs share a channel, OR + * Any conflicting AP shows critical RF health (noise floor ≥ -80 dBm + or Preseem rf_score < 4.0) """ import Ecto.Query @@ -18,6 +26,7 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do alias Towerops.Devices.Device alias Towerops.Repo alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.RfHealth @spec evaluate(Ecto.UUID.t()) :: [map()] def evaluate(organization_id) when is_binary(organization_id) do @@ -44,42 +53,70 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do defp build_insight({{site_id, channel}, group}, organization_id) do [{first_device, first_snmp} | _] = group - aps = + enriched = Enum.map(group, fn {device, snmp} -> - %{ + health = RfHealth.for_device(device.id) + severity = RfHealth.interference_severity(health) + + ap = %{ "device_id" => device.id, "name" => device.name, "channel" => snmp.current_channel, "frequency_mhz" => snmp.current_frequency_mhz, - "channel_width_mhz" => snmp.current_channel_width_mhz + "channel_width_mhz" => snmp.current_channel_width_mhz, + "noise_floor_dbm" => health.noise_floor_dbm, + "rf_score" => health.rf_score, + "qoe_score" => health.qoe_score, + "avg_cpe_snr_db" => health.avg_cpe_snr_db, + "cpe_count" => health.client_count, + "rf_severity" => Atom.to_string(severity) } + + {ap, severity} end) + aps = Enum.map(enriched, fn {ap, _sev} -> ap end) + severities = Enum.map(enriched, fn {_ap, sev} -> sev end) + has_critical_rf = :critical in severities + %{ organization_id: organization_id, site_id: site_id, device_id: first_device.id, type: "own_fleet_channel_conflict", - urgency: urgency_for(length(group)), + urgency: urgency_for(length(group), has_critical_rf), channel: "proactive", source: "system", title: "#{length(group)} APs at the same site share channel #{channel} " <> "(#{first_snmp.current_frequency_mhz} MHz)", - description: - "Multiple access points at the same site are configured on the " <> - "same channel. Without strict GPS sync this causes co-channel " <> - "interference between sectors and degrades airtime efficiency.", + description: description_for(group, has_critical_rf), metadata: %{ "dedup_key" => "own_fleet_channel_conflict:#{site_id}:#{channel}", "channel" => channel, "frequency_mhz" => first_snmp.current_frequency_mhz, "ap_count" => length(group), + "has_critical_rf" => has_critical_rf, "aps" => aps } } end - defp urgency_for(count) when count >= 3, do: "critical" - defp urgency_for(_), do: "warning" + defp urgency_for(count, has_critical_rf) when count >= 3 or has_critical_rf == true, do: "critical" + + defp urgency_for(_count, _has_critical_rf), do: "warning" + + defp description_for(group, true) do + "#{length(group)} access points at the same site share a channel " <> + "AND at least one of them shows critical RF health " <> + "(elevated noise floor or low Preseem RF score). This is the " <> + "high-impact case — co-channel self-interference compounded by " <> + "external interference." + end + + defp description_for(group, false) do + "#{length(group)} access points at the same site are configured on " <> + "the same channel. Without strict GPS sync this causes co-channel " <> + "interference between sectors and degrades airtime efficiency." + end end diff --git a/lib/towerops/snmp/rf_health.ex b/lib/towerops/snmp/rf_health.ex new file mode 100644 index 00000000..3b548630 --- /dev/null +++ b/lib/towerops/snmp/rf_health.ex @@ -0,0 +1,144 @@ +defmodule Towerops.Snmp.RfHealth do + @moduledoc """ + Cross-source RF health snapshot for an access point. + + Combines: + + * **Noise floor** (dBm) from the most recent `noise-floor` / `noise` + sensor on the AP's snmp_device. + * **Preseem scores** (`rf_score`, `qoe_score`) from the linked + `preseem_access_points` row, if any. + * **Average CPE SNR** and **client count** from the wireless + clients currently associated with the AP. + + Used by RF-aware recommendation rules to enrich evidence and modulate + urgency. Returns a struct-shaped map with explicit `nil` fields when + data is missing, so callers can treat the absence of a signal as + separate from "signal observed but healthy". + """ + + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Sensor + alias Towerops.Snmp.WirelessClient + + @cpe_lookback_seconds 2 * 3600 + + @noise_critical_dbm -80.0 + @rf_score_critical 4.0 + @cpe_snr_warning_db 15.0 + + @type t :: %{ + noise_floor_dbm: float() | nil, + rf_score: float() | nil, + qoe_score: float() | nil, + avg_cpe_snr_db: float() | nil, + client_count: non_neg_integer() + } + + @spec for_device(Ecto.UUID.t()) :: t() + def for_device(device_id) when is_binary(device_id) do + %{ + noise_floor_dbm: latest_noise_floor(device_id), + rf_score: preseem_field(device_id, :rf_score), + qoe_score: preseem_field(device_id, :qoe_score), + avg_cpe_snr_db: avg_cpe_snr(device_id), + client_count: cpe_count(device_id) + } + end + + @doc """ + Classify the RF health snapshot as :critical, :warning, or :ok. + + Tolerates missing fields — if no signals are present, returns :ok. + """ + @spec interference_severity(map()) :: :critical | :warning | :ok + def interference_severity(%{} = h) do + cond do + noise_floor_critical?(Map.get(h, :noise_floor_dbm)) -> :critical + rf_score_critical?(Map.get(h, :rf_score)) -> :critical + cpe_snr_warning?(Map.get(h, :avg_cpe_snr_db), Map.get(h, :client_count, 0)) -> :warning + true -> :ok + end + end + + defp noise_floor_critical?(nil), do: false + defp noise_floor_critical?(value) when is_number(value), do: value >= @noise_critical_dbm + + defp rf_score_critical?(nil), do: false + defp rf_score_critical?(value) when is_number(value), do: value < @rf_score_critical + + defp cpe_snr_warning?(_snr, count) when count == 0, do: false + defp cpe_snr_warning?(nil, _count), do: false + + defp cpe_snr_warning?(snr, _count) when is_number(snr) do + snr < @cpe_snr_warning_db + end + + defp latest_noise_floor(device_id) do + sensor = + Repo.one( + from(s in Sensor, + join: d in SnmpDevice, + on: d.id == s.snmp_device_id, + where: + d.device_id == ^device_id and s.sensor_type in ^["noise-floor", "noise", "noise_floor"] and + not is_nil(s.last_value), + order_by: [desc: s.updated_at], + limit: 1, + select: s.last_value + ) + ) + + sensor + end + + defp preseem_field(device_id, field) do + Repo.one( + from(ap in AccessPoint, + where: ap.device_id == ^device_id, + order_by: [desc: ap.updated_at], + limit: 1, + select: field(ap, ^field) + ) + ) + end + + defp avg_cpe_snr(device_id) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@cpe_lookback_seconds, :second) + |> DateTime.truncate(:second) + + from(c in WirelessClient, + where: + c.device_id == ^device_id and + not is_nil(c.snr) and + c.last_seen_at >= ^cutoff, + select: avg(c.snr) + ) + |> Repo.one() + |> decimal_to_float() + end + + defp cpe_count(device_id) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@cpe_lookback_seconds, :second) + |> DateTime.truncate(:second) + + from(c in WirelessClient, + where: c.device_id == ^device_id and c.last_seen_at >= ^cutoff, + select: count(c.id) + ) + |> Repo.one() + |> Kernel.||(0) + end + + defp decimal_to_float(nil), do: nil + defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d) + defp decimal_to_float(n) when is_number(n), do: n * 1.0 +end diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 87c7c40c..c83768bc 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -504,34 +504,67 @@ {insight.metadata["ap_count"]} + <%= if insight.metadata["has_critical_rf"] do %> + + {t("Critical RF environment detected")} + + <% end %> <%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %> -
{t("Conflicting APs")}
-| {t("AP")} | +{t("Width")} | +{t("Noise")} | +{t("RF score")} | +{t("CPE SNR")} | +{t("CPEs")} | +
|---|---|---|---|---|---|
| + <%= if ap["device_id"] do %> + <.link + navigate={~p"/devices/#{ap["device_id"]}"} + class="underline hover:no-underline" + > + {ap["name"] || "Unnamed"} + + <% else %> + {ap["name"] || "Unnamed"} + <% end %> + <%= if ap["rf_severity"] == "critical" do %> + + crit + + <% end %> + | ++ {ap["channel_width_mhz"] && "#{ap["channel_width_mhz"]} MHz"} + | ++ {ap["noise_floor_dbm"] && "#{ap["noise_floor_dbm"]} dBm"} + | ++ {ap["rf_score"] && Float.round(ap["rf_score"] / 1.0, 1)} + | ++ {ap["avg_cpe_snr_db"] && + "#{Float.round(ap["avg_cpe_snr_db"] / 1.0, 1)} dB"} + | +{ap["cpe_count"]} | +