134 lines
4.1 KiB
Elixir
134 lines
4.1 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
|
|
require Logger
|
|
|
|
now = Towerops.Time.now()
|
|
|
|
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)
|
|
|
|
{success_count, error_count} =
|
|
Enum.reduce(groups, {0, 0}, fn {{manufacturer, model}, aps}, {success, errors} ->
|
|
profile_attrs = compute_model_profile(manufacturer, model, aps, now)
|
|
|
|
case upsert_fleet_profile(organization_id, profile_attrs) do
|
|
{:ok, _profile} -> {success + 1, errors}
|
|
{:error, _changeset} -> {success, errors + 1}
|
|
end
|
|
end)
|
|
|
|
if error_count > 0 do
|
|
Logger.warning("Fleet profiles: #{success_count} succeeded, #{error_count} failed for org #{organization_id}")
|
|
end
|
|
|
|
{:ok, success_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
|
|
require Logger
|
|
|
|
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()
|
|
|
|
result =
|
|
case existing do
|
|
nil ->
|
|
%FleetProfile{}
|
|
|> FleetProfile.changeset(full_attrs)
|
|
|> Repo.insert()
|
|
|
|
profile ->
|
|
profile
|
|
|> FleetProfile.changeset(full_attrs)
|
|
|> Repo.update()
|
|
end
|
|
|
|
case result do
|
|
{:ok, profile} ->
|
|
{:ok, profile}
|
|
|
|
{:error, changeset} ->
|
|
Logger.error(
|
|
"Failed to upsert fleet profile for #{attrs.manufacturer} #{attrs.model}: #{inspect(changeset.errors)}"
|
|
)
|
|
|
|
{:error, changeset}
|
|
end
|
|
end
|
|
end
|