towerops/lib/towerops/coverages/coverage.ex
Graham McIntire 5a9381f91a feat: add /coverage RF prediction feature scaffold
End-to-end CRUD scaffold for per-site, per-antenna RF coverage
prediction. Schema, context, antenna registry, Oban worker stub, and
three LiveViews are wired up; the actual ITM + LIDAR + buildings
compute pipeline lands in a follow-up.

- Migration + Coverage schema with full validation (RF params, pixel-
  budget cap, antenna_slug existence). organization_id excluded from
  cast (programmatic-only).
- Coverages context: org-scoped CRUD, validate_site_in_organization
  rejects cross-tenant site_id refs at insert/update, queue_compute
  with PubSub broadcast on per-coverage and per-org topics.
- MSI Planet .ant parser + persistent_term registry loaded at boot
  from priv/antennas/. Two synthetic .ant fixtures (omni + 90deg
  sector) bundled.
- CoverageWorker on new :coverage Oban queue (concurrency 2). Job
  args carry organization_id; worker refuses to run on org mismatch.
  Stub flips status to "failed" with "compute not yet implemented"
  so the UI flow is exercisable.
- LiveViews: index (org-wide, live status updates), form (antenna
  picker grouped by manufacturer), show (status banners, params
  panel, map placeholder). Sidebar nav link added.
- Routes /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit
  under :require_authenticated_user_with_default_org.
- 41 new tests covering schema validation, .ant parsing, context
  CRUD with cross-org guards, Oban job enqueue.
2026-05-06 13:44:14 -05:00

207 lines
6.7 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Towerops.Coverages.Coverage do
@moduledoc """
RF coverage prediction for a single antenna mounted at a site.
A coverage describes one transmit antenna and the parameters needed to
predict its signal-strength heatmap: model, height AGL, azimuth, downtilt,
frequency, EIRP, plus the spatial extent (`radius_m`, `cell_size_m`) and
receiver assumptions used by the `Towerops.Workers.CoverageWorker`.
Multiple coverages can hang off a single site — typically one per sector
on a multi-sector tower.
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Coverages.Antenna
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
@statuses ~w(draft queued computing ready failed)
@max_axis_pixels 8_000
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "coverages" do
field :name, :string
field :antenna_slug, :string
field :height_agl_m, :float
field :azimuth_deg, :float
field :downtilt_deg, :float, default: 0.0
field :frequency_mhz, :integer
field :eirp_dbm, :float
field :radius_m, :integer
field :cell_size_m, :integer
field :receiver_height_m, :float, default: 3.0
field :rx_threshold_dbm, :float, default: -90.0
field :status, :string, default: "draft"
field :progress_pct, :integer, default: 0
field :error_message, :string
field :computed_at, :utc_datetime
field :bbox_min_lat, :float
field :bbox_max_lat, :float
field :bbox_min_lon, :float
field :bbox_max_lon, :float
field :raster_path, :string
field :png_path, :string
belongs_to :site, Site
belongs_to :organization, Organization
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t() | nil,
name: String.t() | nil,
antenna_slug: String.t() | nil,
height_agl_m: float() | nil,
azimuth_deg: float() | nil,
downtilt_deg: float() | nil,
frequency_mhz: integer() | nil,
eirp_dbm: float() | nil,
radius_m: integer() | nil,
cell_size_m: integer() | nil,
receiver_height_m: float() | nil,
rx_threshold_dbm: float() | nil,
status: String.t(),
progress_pct: integer(),
error_message: String.t() | nil,
computed_at: DateTime.t() | nil,
bbox_min_lat: float() | nil,
bbox_max_lat: float() | nil,
bbox_min_lon: float() | nil,
bbox_max_lon: float() | nil,
raster_path: String.t() | nil,
png_path: String.t() | nil,
site_id: Ecto.UUID.t() | nil,
site: NotLoaded.t() | Site.t() | nil,
organization_id: Ecto.UUID.t() | nil,
organization: NotLoaded.t() | Organization.t() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
# `organization_id` is intentionally NOT castable — it must be set
# programmatically by the context from the current scope, never from
# user-supplied attrs (per AGENTS.md security guideline).
@cast_fields ~w(
name antenna_slug height_agl_m azimuth_deg downtilt_deg
frequency_mhz eirp_dbm radius_m cell_size_m
receiver_height_m rx_threshold_dbm
status progress_pct site_id
)a
@required_fields ~w(
name antenna_slug height_agl_m azimuth_deg
frequency_mhz eirp_dbm radius_m cell_size_m
site_id organization_id
)a
@doc """
Changeset for creating or updating a coverage from user input.
"""
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(coverage, attrs) do
coverage
|> cast(attrs, @cast_fields)
|> validate_required(@required_fields)
|> validate_length(:name, min: 2, max: 100)
|> validate_number(:height_agl_m, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 200.0)
|> validate_number(:azimuth_deg, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 360.0)
|> validate_number(:downtilt_deg, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 30.0)
|> validate_number(:frequency_mhz, greater_than_or_equal_to: 700, less_than_or_equal_to: 90_000)
|> validate_number(:eirp_dbm, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 60.0)
|> validate_number(:radius_m, greater_than_or_equal_to: 500, less_than_or_equal_to: 40_000)
|> validate_number(:cell_size_m, greater_than_or_equal_to: 1, less_than_or_equal_to: 50)
|> validate_number(:receiver_height_m,
greater_than_or_equal_to: 0.5,
less_than_or_equal_to: 100.0
)
|> validate_number(:rx_threshold_dbm,
greater_than_or_equal_to: -130.0,
less_than_or_equal_to: 0.0
)
|> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|> validate_inclusion(:status, @statuses)
|> validate_pixel_budget()
|> validate_antenna_exists()
|> foreign_key_constraint(:site_id)
|> foreign_key_constraint(:organization_id)
|> unique_constraint(:name,
name: :coverages_site_id_name_index,
message: "has already been taken for this site"
)
end
@doc """
Changeset for the worker to update transient state (status, progress,
error, paths, computed bbox, computed_at) without re-running user-input
validations on the form fields.
"""
@spec status_changeset(t(), map()) :: Ecto.Changeset.t()
def status_changeset(coverage, attrs) do
coverage
|> cast(attrs, [
:status,
:progress_pct,
:error_message,
:computed_at,
:bbox_min_lat,
:bbox_max_lat,
:bbox_min_lon,
:bbox_max_lon,
:raster_path,
:png_path
])
|> validate_inclusion(:status, @statuses)
|> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
end
@doc "List of valid status values."
@spec statuses() :: [String.t()]
def statuses, do: @statuses
defp validate_antenna_exists(changeset) do
case get_field(changeset, :antenna_slug) do
nil ->
changeset
slug ->
if Antenna.exists?(slug) do
changeset
else
add_error(changeset, :antenna_slug, "is not a known antenna")
end
end
end
defp validate_pixel_budget(changeset) do
radius = get_field(changeset, :radius_m)
cell = get_field(changeset, :cell_size_m)
if is_integer(radius) and is_integer(cell) and cell > 0 do
pixels_per_axis = div(radius * 2, cell)
if pixels_per_axis > @max_axis_pixels do
add_error(
changeset,
:cell_size_m,
"is too fine for this radius (would produce #{pixels_per_axis}×#{pixels_per_axis} pixels; cap is #{@max_axis_pixels} per axis)"
)
else
changeset
end
else
changeset
end
end
end