diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 31afbdf3..de8446d2 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,20 @@ +2026-05-09 +feat(insights): device-overheating recommendation + Towerops.Recommendations.Rules.DeviceOverheating — flags devices + whose hottest temperature sensor reads >=65 °C (warning) or + >=75 °C (critical). Picks the hottest sensor per device so + chassis vs CPU vs PHY all roll up to one actionable insight. + When the device is linked to a Preseem AccessPoint with + qoe_score < 60, the description and LLM prompt hint at thermal + throttling — operators frequently misdiagnose thermal as RF and + waste truck rolls. + Insight.@valid_types extended with device_overheating. + Wired into RecommendationsRunWorker. + LLM prompt for device_overheating tells the model to recommend + ventilation/fan/shading inspection BEFORE RF action. + UI: red card with hottest sensor temp, sensor name, linked QoE + score, and "Above manufacturer spec" badge above 75 °C. + 2026-05-09 feat(llm): per-insight-type prompt hints Towerops.LLM.InsightPrompt now appends a type-specific expert hint diff --git a/lib/towerops/llm/insight_prompt.ex b/lib/towerops/llm/insight_prompt.ex index 6e1afac7..43535ddc 100644 --- a/lib/towerops/llm/insight_prompt.ex +++ b/lib/towerops/llm/insight_prompt.ex @@ -98,6 +98,17 @@ defmodule Towerops.LLM.InsightPrompt do Rule context: a Preseem AP has more subscribers than its baseline p95. Metadata includes current_count and baseline_p95. Recommend rebalancing or capacity management. + """, + "device_overheating" => """ + Rule context: a device has at least one temperature sensor above + 65 °C (warning) or 75 °C (critical). Metadata fields: + temperature_c, sensor_descr, qoe_score, warning_threshold_c, + critical_threshold_c. + If qoe_score is also low (<60), highlight that the QoE drop is + likely thermal — operators frequently misdiagnose thermal + throttling as RF and waste truck rolls. Recommend ventilation, + fan, or shading inspection BEFORE any RF action. Mention the + specific sensor name when available. """ } diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index bb02e197..3316a2b9 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change sector_overload cpe_realign own_fleet_channel_conflict) + @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change sector_overload cpe_realign own_fleet_channel_conflict device_overheating) @valid_urgencies ~w(critical warning info) @valid_statuses ~w(active dismissed resolved) @valid_channels ~w(proactive contextual passive) diff --git a/lib/towerops/recommendations/rules/device_overheating.ex b/lib/towerops/recommendations/rules/device_overheating.ex new file mode 100644 index 00000000..df5af171 --- /dev/null +++ b/lib/towerops/recommendations/rules/device_overheating.ex @@ -0,0 +1,102 @@ +defmodule Towerops.Recommendations.Rules.DeviceOverheating do + @moduledoc """ + Flags devices whose hottest temperature sensor crosses the + manufacturer-typical "warm" or "hot" thresholds. When the device is + linked to a Preseem AccessPoint with a degraded QoE score, the + insight description hints at thermal-throttling correlation so the + operator can avoid an unnecessary RF truck-roll. + + Thresholds are intentionally band-agnostic: + * 65 °C → warning (warm enclosure / chassis) + * 75 °C → critical (most outdoor radio specs cap at 70-80 °C) + + Pulls the *hottest* current temperature sensor per device — chassis + vs CPU vs PHY don't matter for the operator's first-pass action; + whichever is hottest is the actionable one. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Sensor + + @warning_threshold_c 65.0 + @critical_threshold_c 75.0 + @qoe_concern_threshold 60.0 + + @spec evaluate(Ecto.UUID.t()) :: [map()] + def evaluate(organization_id) when is_binary(organization_id) do + organization_id + |> load_hottest_temperatures() + |> Enum.map(&build_insight(&1, organization_id)) + end + + defp load_hottest_temperatures(organization_id) do + # Fetch device + the hottest temperature sensor per device above the + # warning threshold. Done as a single query to avoid N+1. + from(d in Device, + join: sd in SnmpDevice, + on: sd.device_id == d.id, + join: s in Sensor, + on: s.snmp_device_id == sd.id, + where: + d.organization_id == ^organization_id and + s.sensor_type == "temperature" and + not is_nil(s.last_value) and + s.last_value >= ^@warning_threshold_c, + order_by: [asc: d.id, desc: s.last_value], + select: {d, s} + ) + |> Repo.all() + |> Enum.uniq_by(fn {d, _s} -> d.id end) + end + + defp build_insight({device, sensor}, organization_id) do + temp = sensor.last_value + qoe = preseem_qoe(device.id) + + %{ + organization_id: organization_id, + device_id: device.id, + type: "device_overheating", + urgency: urgency_for(temp), + channel: "proactive", + source: "snmp", + title: "Device #{device.name || sensor.sensor_descr} running hot: #{format_temp(temp)}", + description: description_for(temp, qoe), + metadata: %{ + "temperature_c" => temp, + "sensor_descr" => sensor.sensor_descr, + "warning_threshold_c" => @warning_threshold_c, + "critical_threshold_c" => @critical_threshold_c, + "qoe_score" => qoe, + "qoe_concern_threshold" => @qoe_concern_threshold + } + } + end + + defp preseem_qoe(device_id) do + Repo.one(from(ap in AccessPoint, where: ap.device_id == ^device_id, select: ap.qoe_score, limit: 1)) + end + + defp urgency_for(temp) when is_number(temp) and temp >= @critical_threshold_c, do: "critical" + defp urgency_for(_), do: "warning" + + defp description_for(temp, qoe) when is_number(qoe) and qoe < @qoe_concern_threshold do + "Hottest temperature sensor reads #{format_temp(temp)}. Linked Preseem QoE " <> + "score is #{qoe} — possible thermal-throttling correlation. Inspect " <> + "enclosure ventilation, fan, and shading before assuming an RF cause." + end + + defp description_for(temp, _qoe) do + "Hottest temperature sensor reads #{format_temp(temp)}. Above the " <> + "manufacturer-typical warning threshold; check enclosure ventilation, " <> + "fan, and direct-sun exposure." + end + + defp format_temp(temp) when is_number(temp), do: "#{Float.round(temp / 1.0, 1)} °C" + defp format_temp(_), do: "?°C" +end diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex index 85bf8b24..de607069 100644 --- a/lib/towerops/workers/recommendations_run_worker.ex +++ b/lib/towerops/workers/recommendations_run_worker.ex @@ -18,6 +18,7 @@ defmodule Towerops.Workers.RecommendationsRunWorker do alias Towerops.Organizations.Organization alias Towerops.Preseem.Insights alias Towerops.Recommendations.Rules.CpeRealign + alias Towerops.Recommendations.Rules.DeviceOverheating alias Towerops.Recommendations.Rules.FrequencyChange alias Towerops.Recommendations.Rules.OwnFleetChannelConflict alias Towerops.Recommendations.Rules.SectorOverload @@ -25,7 +26,7 @@ defmodule Towerops.Workers.RecommendationsRunWorker do require Logger - @rules [FrequencyChange, OwnFleetChannelConflict, SectorOverload, CpeRealign] + @rules [FrequencyChange, OwnFleetChannelConflict, SectorOverload, CpeRealign, DeviceOverheating] @impl Oban.Worker def perform(%Oban.Job{}) do diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index c83768bc..0140a955 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -480,6 +480,41 @@ <% end %> + <%= if insight.type == "device_overheating" do %> +