From df2ca42404b14ffa965c3feb855b158d324d19cf Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 08:31:15 -0600 Subject: [PATCH] add preseem device baselines and fleet profiles schemas --- lib/towerops/preseem/device_baseline.ex | 57 +++++ lib/towerops/preseem/fleet_profile.ex | 51 ++++ ...001542_create_preseem_device_baselines.exs | 30 +++ ...13001547_create_preseem_fleet_profiles.exs | 33 +++ .../towerops/preseem/device_baseline_test.exs | 238 ++++++++++++++++++ test/towerops/preseem/fleet_profile_test.exs | 179 +++++++++++++ 6 files changed, 588 insertions(+) create mode 100644 lib/towerops/preseem/device_baseline.ex create mode 100644 lib/towerops/preseem/fleet_profile.ex create mode 100644 priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs create mode 100644 priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs create mode 100644 test/towerops/preseem/device_baseline_test.exs create mode 100644 test/towerops/preseem/fleet_profile_test.exs diff --git a/lib/towerops/preseem/device_baseline.ex b/lib/towerops/preseem/device_baseline.ex new file mode 100644 index 00000000..62d001ab --- /dev/null +++ b/lib/towerops/preseem/device_baseline.ex @@ -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 diff --git a/lib/towerops/preseem/fleet_profile.ex b/lib/towerops/preseem/fleet_profile.ex new file mode 100644 index 00000000..fe6c1a81 --- /dev/null +++ b/lib/towerops/preseem/fleet_profile.ex @@ -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 diff --git a/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs b/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs new file mode 100644 index 00000000..f5ca6147 --- /dev/null +++ b/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs @@ -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 diff --git a/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs b/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs new file mode 100644 index 00000000..3cb80cf6 --- /dev/null +++ b/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs @@ -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 diff --git a/test/towerops/preseem/device_baseline_test.exs b/test/towerops/preseem/device_baseline_test.exs new file mode 100644 index 00000000..df7e1438 --- /dev/null +++ b/test/towerops/preseem/device_baseline_test.exs @@ -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 diff --git a/test/towerops/preseem/fleet_profile_test.exs b/test/towerops/preseem/fleet_profile_test.exs new file mode 100644 index 00000000..4c8a6749 --- /dev/null +++ b/test/towerops/preseem/fleet_profile_test.exs @@ -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