add preseem device baselines and fleet profiles schemas

This commit is contained in:
Graham McIntire 2026-02-13 08:31:15 -06:00
parent b9db1d2224
commit df2ca42404
No known key found for this signature in database
6 changed files with 588 additions and 0 deletions

View file

@ -0,0 +1,57 @@
defmodule Towerops.Preseem.DeviceBaseline do
@moduledoc """
Rolling 14-day baseline for individual Preseem access points.
Computed nightly to detect deviations from normal behavior.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_periods ~w(busy offpeak all)
@valid_metrics ~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization)
schema "preseem_device_baselines" do
field :metric_name, :string
field :period, :string
field :mean, :float
field :stddev, :float
field :p5, :float
field :p95, :float
field :sample_count, :integer
field :computed_at, :utc_datetime
belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint
end
def changeset(baseline, attrs) do
baseline
|> cast(attrs, [
:preseem_access_point_id,
:metric_name,
:period,
:mean,
:stddev,
:p5,
:p95,
:sample_count,
:computed_at
])
|> validate_required([
:preseem_access_point_id,
:metric_name,
:period,
:mean,
:sample_count,
:computed_at
])
|> validate_inclusion(:period, @valid_periods)
|> validate_inclusion(:metric_name, @valid_metrics)
|> unique_constraint([:preseem_access_point_id, :metric_name, :period],
name: :preseem_device_baselines_ap_metric_period_idx
)
|> foreign_key_constraint(:preseem_access_point_id)
end
end

View file

@ -0,0 +1,51 @@
defmodule Towerops.Preseem.FleetProfile do
@moduledoc """
Fleet-wide model performance profiles. Groups devices by manufacturer+model
to identify patterns, capacity ceilings, and firmware correlations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "preseem_fleet_profiles" do
field :manufacturer, :string
field :model, :string
field :firmware_version, :string
field :device_count, :integer
field :avg_subscribers, :float
field :avg_qoe_score, :float
field :avg_capacity_score, :float
field :capacity_ceiling, :integer
field :avg_busy_hours, :float
field :performance_data, :map
field :computed_at, :utc_datetime
belongs_to :organization, Towerops.Organizations.Organization
end
def changeset(profile, attrs) do
profile
|> cast(attrs, [
:organization_id,
:manufacturer,
:model,
:firmware_version,
:device_count,
:avg_subscribers,
:avg_qoe_score,
:avg_capacity_score,
:capacity_ceiling,
:avg_busy_hours,
:performance_data,
:computed_at
])
|> validate_required([:organization_id, :manufacturer, :model, :device_count, :computed_at])
|> unique_constraint([:organization_id, :manufacturer, :model, :firmware_version],
name: :preseem_fleet_profiles_org_model_fw_idx
)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -0,0 +1,30 @@
defmodule Towerops.Repo.Migrations.CreatePreseemDeviceBaselines do
use Ecto.Migration
def change do
create table(:preseem_device_baselines, primary_key: false) do
add :id, :binary_id, primary_key: true
add :preseem_access_point_id,
references(:preseem_access_points, type: :binary_id, on_delete: :delete_all),
null: false
add :metric_name, :string, null: false
add :period, :string, null: false
add :mean, :float
add :stddev, :float
add :p5, :float
add :p95, :float
add :sample_count, :integer
add :computed_at, :utc_datetime, null: false
end
create index(:preseem_device_baselines, [:preseem_access_point_id])
create unique_index(
:preseem_device_baselines,
[:preseem_access_point_id, :metric_name, :period],
name: :preseem_device_baselines_ap_metric_period_idx
)
end
end

View file

@ -0,0 +1,33 @@
defmodule Towerops.Repo.Migrations.CreatePreseemFleetProfiles do
use Ecto.Migration
def change do
create table(:preseem_fleet_profiles, primary_key: false) do
add :id, :binary_id, primary_key: true
add :organization_id,
references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :manufacturer, :string, null: false
add :model, :string, null: false
add :firmware_version, :string
add :device_count, :integer, null: false
add :avg_subscribers, :float
add :avg_qoe_score, :float
add :avg_capacity_score, :float
add :capacity_ceiling, :integer
add :avg_busy_hours, :float
add :performance_data, :map
add :computed_at, :utc_datetime, null: false
end
create index(:preseem_fleet_profiles, [:organization_id])
create unique_index(
:preseem_fleet_profiles,
[:organization_id, :manufacturer, :model, :firmware_version],
name: :preseem_fleet_profiles_org_model_fw_idx
)
end
end

View file

@ -0,0 +1,238 @@
defmodule Towerops.Preseem.DeviceBaselineTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.DeviceBaseline
setup do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, ap} =
%AccessPoint{}
|> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-baseline-1", name: "Baseline AP"})
|> Repo.insert()
%{access_point: ap}
end
describe "changeset/2" do
test "valid changeset with all fields", %{access_point: ap} do
attrs = %{
preseem_access_point_id: ap.id,
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
stddev: 4.2,
p5: 72.0,
p95: 95.3,
sample_count: 336,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
assert changeset.valid?
end
test "requires preseem_access_point_id" do
attrs = %{
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).preseem_access_point_id
end
test "requires metric_name" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
period: "busy",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).metric_name
end
test "requires period" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).period
end
test "requires mean" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
period: "busy",
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).mean
end
test "requires sample_count" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).sample_count
end
test "requires computed_at" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
sample_count: 100
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).computed_at
end
test "validates period inclusion" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
period: "invalid_period",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).period
end
test "accepts all valid periods" do
for period <- ~w(busy offpeak all) do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "qoe_score",
period: period,
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
assert changeset.valid?, "Expected period '#{period}' to be valid"
end
end
test "validates metric_name inclusion" do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: "invalid_metric",
period: "busy",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).metric_name
end
test "accepts all valid metric names" do
valid_metrics =
~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization)
for metric <- valid_metrics do
attrs = %{
preseem_access_point_id: Ecto.UUID.generate(),
metric_name: metric,
period: "busy",
mean: 85.5,
sample_count: 100,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs)
assert changeset.valid?, "Expected metric '#{metric}' to be valid"
end
end
test "persists and reloads from database", %{access_point: ap} do
now = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
preseem_access_point_id: ap.id,
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
stddev: 4.2,
p5: 72.0,
p95: 95.3,
sample_count: 336,
computed_at: now
}
{:ok, baseline} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(DeviceBaseline, baseline.id)
assert reloaded.preseem_access_point_id == ap.id
assert reloaded.metric_name == "qoe_score"
assert reloaded.period == "busy"
assert reloaded.mean == 85.5
assert reloaded.stddev == 4.2
assert reloaded.p5 == 72.0
assert reloaded.p95 == 95.3
assert reloaded.sample_count == 336
assert reloaded.computed_at == now
end
test "enforces unique constraint on [access_point_id, metric_name, period]", %{access_point: ap} do
now = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
preseem_access_point_id: ap.id,
metric_name: "qoe_score",
period: "busy",
mean: 85.5,
sample_count: 336,
computed_at: now
}
{:ok, _} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
assert {:error, changeset} =
%DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert()
assert errors_on(changeset).preseem_access_point_id != []
end
end
end

View file

@ -0,0 +1,179 @@
defmodule Towerops.Preseem.FleetProfileTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.FleetProfile
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{organization: org}
end
describe "changeset/2" do
test "valid changeset with all fields", %{organization: org} do
attrs = %{
organization_id: org.id,
manufacturer: "Cambium",
model: "ePMP 3000",
firmware_version: "4.7.1",
device_count: 25,
avg_subscribers: 18.5,
avg_qoe_score: 82.3,
avg_capacity_score: 71.0,
capacity_ceiling: 35,
avg_busy_hours: 6.2,
performance_data: %{"p50_qoe" => 84.0, "p10_qoe" => 65.2},
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
assert changeset.valid?
end
test "requires organization_id" do
attrs = %{
manufacturer: "Cambium",
model: "ePMP 3000",
device_count: 25,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
test "requires manufacturer" do
attrs = %{
organization_id: Ecto.UUID.generate(),
model: "ePMP 3000",
device_count: 25,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).manufacturer
end
test "requires model" do
attrs = %{
organization_id: Ecto.UUID.generate(),
manufacturer: "Cambium",
device_count: 25,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).model
end
test "requires device_count" do
attrs = %{
organization_id: Ecto.UUID.generate(),
manufacturer: "Cambium",
model: "ePMP 3000",
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).device_count
end
test "requires computed_at" do
attrs = %{
organization_id: Ecto.UUID.generate(),
manufacturer: "Cambium",
model: "ePMP 3000",
device_count: 25
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).computed_at
end
test "persists and reloads from database", %{organization: org} do
now = DateTime.truncate(DateTime.utc_now(), :second)
attrs = %{
organization_id: org.id,
manufacturer: "Ubiquiti",
model: "LiteAP AC",
firmware_version: "8.7.11",
device_count: 12,
avg_subscribers: 22.7,
avg_qoe_score: 88.1,
avg_capacity_score: 76.5,
capacity_ceiling: 40,
avg_busy_hours: 5.0,
performance_data: %{"percentiles" => %{"p50" => 85, "p90" => 92}},
computed_at: now
}
{:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(FleetProfile, profile.id)
assert reloaded.organization_id == org.id
assert reloaded.manufacturer == "Ubiquiti"
assert reloaded.model == "LiteAP AC"
assert reloaded.firmware_version == "8.7.11"
assert reloaded.device_count == 12
assert reloaded.avg_subscribers == 22.7
assert reloaded.avg_qoe_score == 88.1
assert reloaded.avg_capacity_score == 76.5
assert reloaded.capacity_ceiling == 40
assert reloaded.avg_busy_hours == 5.0
assert reloaded.computed_at == now
end
test "stores and retrieves performance_data map", %{organization: org} do
performance_data = %{
"by_firmware" => %{
"4.7.1" => %{"count" => 10, "avg_qoe" => 85.0},
"4.6.0" => %{"count" => 5, "avg_qoe" => 78.2}
},
"capacity_curve" => [
%{"subscribers" => 10, "avg_qoe" => 92.0},
%{"subscribers" => 20, "avg_qoe" => 85.0},
%{"subscribers" => 30, "avg_qoe" => 72.0}
]
}
attrs = %{
organization_id: org.id,
manufacturer: "Cambium",
model: "ePMP 3000",
device_count: 15,
performance_data: performance_data,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
{:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(FleetProfile, profile.id)
assert reloaded.performance_data == performance_data
end
test "firmware_version is optional", %{organization: org} do
attrs = %{
organization_id: org.id,
manufacturer: "Cambium",
model: "ePMP 3000",
device_count: 25,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = FleetProfile.changeset(%FleetProfile{}, attrs)
assert changeset.valid?
{:ok, profile} = Repo.insert(changeset)
assert is_nil(profile.firmware_version)
end
end
end