towerops/lib/towerops/preseem/fleet_intelligence.ex

110 lines
3.5 KiB
Elixir

defmodule Towerops.Preseem.FleetIntelligence do
@moduledoc """
Computes fleet-wide model performance profiles by grouping matched devices
by manufacturer and model.
"""
import Ecto.Query
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.FleetProfile
alias Towerops.Repo
@doc "Compute fleet profiles for all matched APs in an organization."
def compute_fleet_profiles(organization_id) do
now = DateTime.truncate(DateTime.utc_now(), :second)
matched_aps =
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], not is_nil(ap.device_id))
|> where([ap], not is_nil(ap.model))
|> Repo.all()
groups =
Enum.group_by(matched_aps, fn ap ->
{extract_manufacturer(ap.model), ap.model}
end)
count =
Enum.reduce(groups, 0, fn {{manufacturer, model}, aps}, acc ->
profile_attrs = compute_model_profile(manufacturer, model, aps, now)
upsert_fleet_profile(organization_id, profile_attrs)
acc + 1
end)
{:ok, count}
end
defp extract_manufacturer(model) when is_binary(model) do
case String.split(model, " ", parts: 2) do
[manufacturer, _rest] -> manufacturer
[single] -> single
end
end
defp compute_model_profile(manufacturer, model, aps, now) do
subscriber_counts = aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1)
qoe_scores = aps |> Enum.map(& &1.qoe_score) |> Enum.reject(&is_nil/1)
capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1)
busy_hours = aps |> Enum.map(& &1.busy_hours) |> Enum.reject(&is_nil/1)
%{
manufacturer: manufacturer,
model: model,
firmware_version: nil,
device_count: length(aps),
avg_subscribers: safe_avg(subscriber_counts),
avg_qoe_score: safe_avg(qoe_scores),
avg_capacity_score: safe_avg(capacity_scores),
capacity_ceiling: compute_capacity_ceiling(aps),
avg_busy_hours: safe_avg(busy_hours),
performance_data: %{
"subscriber_range" => %{
"min" => Enum.min(subscriber_counts, fn -> nil end),
"max" => Enum.max(subscriber_counts, fn -> nil end)
},
"firmware_distribution" => aps |> Enum.map(& &1.firmware) |> Enum.reject(&is_nil/1) |> Enum.frequencies()
},
computed_at: now
}
end
defp safe_avg([]), do: nil
defp safe_avg(values), do: Float.round(Enum.sum(values) / length(values), 2)
defp compute_capacity_ceiling(aps) do
aps
|> Enum.filter(fn ap ->
ap.qoe_score != nil and ap.subscriber_count != nil and ap.qoe_score >= 70
end)
|> Enum.map(& &1.subscriber_count)
|> Enum.max(fn -> nil end)
end
# Use a manual query + update/insert since the unique index includes nullable firmware_version.
# PostgreSQL treats NULLs as distinct in unique indexes, so ON CONFLICT won't match
# rows with NULL firmware_version. Ecto's get_by also rejects nil values.
defp upsert_fleet_profile(organization_id, attrs) do
full_attrs = Map.put(attrs, :organization_id, organization_id)
existing =
FleetProfile
|> where(organization_id: ^organization_id)
|> where(manufacturer: ^attrs.manufacturer)
|> where(model: ^attrs.model)
|> where([fp], is_nil(fp.firmware_version))
|> Repo.one()
case existing do
nil ->
%FleetProfile{}
|> FleetProfile.changeset(full_attrs)
|> Repo.insert!()
profile ->
profile
|> FleetProfile.changeset(full_attrs)
|> Repo.update!()
end
end
end