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.
207 lines
6.7 KiB
Elixir
207 lines
6.7 KiB
Elixir
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
|