towerops/lib/towerops/coverages/coverage.ex
Graham McIntire 84d4fccb29 feat: optionally attach a coverage to a device at the chosen site
After picking a site in the coverage form, a new optional dropdown
lets the user attach the coverage to one of the devices at that site.
The dropdown reloads on every site change. If the site has no devices
yet, a hint replaces it.

Schema: nullable device_id FK on coverages (on_delete: nilify_all so
deleting a device leaves the coverage standing). Filtered by both
site_id and organization_id when loading the dropdown options.

Show page lists the attached device by name (or IP) when set.
2026-05-06 14:49:32 -05:00

335 lines
12 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.Devices.Device
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 :height_above_rooftop_m, :float, default: 0.0
field :azimuth_deg, :float
field :downtilt_deg, :float, default: 0.0
field :frequency_mhz, :integer
field :tx_power_dbm, :float
field :cable_loss_db, :float, default: 0.0
field :sm_gain_dbi, :float, default: 0.0
field :tx_clearance_m, :float
field :foliage_tuning, :integer, default: 0
# Optional per-coverage location override; defaults to the parent site.
field :latitude_override, :float
field :longitude_override, :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
# Virtual imperial inputs — populated by the form; the changeset
# converts these to their SI counterparts before validation.
field :height_agl_ft, :float, virtual: true
field :height_above_rooftop_ft, :float, virtual: true
field :receiver_height_ft, :float, virtual: true
field :tx_clearance_ft, :float, virtual: true
field :radius_mi, :float, virtual: true
field :frequency_ghz, :float, virtual: true
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
belongs_to :device, Device
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,
height_above_rooftop_m: float() | nil,
azimuth_deg: float() | nil,
downtilt_deg: float() | nil,
frequency_mhz: integer() | nil,
tx_power_dbm: float() | nil,
cable_loss_db: float() | nil,
sm_gain_dbi: float() | nil,
tx_clearance_m: float() | nil,
foliage_tuning: integer() | nil,
latitude_override: float() | nil,
longitude_override: 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,
device_id: Ecto.UUID.t() | nil,
device: NotLoaded.t() | Device.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 height_above_rooftop_m azimuth_deg downtilt_deg
frequency_mhz tx_power_dbm cable_loss_db sm_gain_dbi
tx_clearance_m foliage_tuning
latitude_override longitude_override
radius_m cell_size_m receiver_height_m rx_threshold_dbm
status progress_pct site_id device_id
height_agl_ft height_above_rooftop_ft receiver_height_ft tx_clearance_ft
radius_mi frequency_ghz
)a
@required_fields ~w(
name antenna_slug height_agl_m azimuth_deg
frequency_mhz tx_power_dbm radius_m cell_size_m
site_id organization_id
)a
@doc """
Changeset for creating or updating a coverage from user input.
Accepts either SI fields (used by tests/workers/API) or imperial
virtual fields (used by the LiveView form). Imperial values are
converted to their SI counterparts before validation.
"""
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(coverage, attrs) do
coverage
|> cast(attrs, @cast_fields)
|> apply_imperial_conversions()
|> ensure_cell_size_m()
|> 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(:tx_power_dbm, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 50.0)
|> validate_number(:cable_loss_db, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 20.0)
|> validate_number(:sm_gain_dbi, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 40.0)
|> validate_number(:height_above_rooftop_m,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 100.0
)
|> validate_number(:tx_clearance_m,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 1000.0
)
|> validate_number(:foliage_tuning, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
|> validate_number(:latitude_override,
greater_than_or_equal_to: -90.0,
less_than_or_equal_to: 90.0
)
|> validate_number(:longitude_override,
greater_than_or_equal_to: -180.0,
less_than_or_equal_to: 180.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(:device_id)
|> 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
@doc """
Returns the effective transmit EIRP in dBm given the configured TX
power, cable loss, and the chosen antenna's gain.
`eirp = tx_power_dbm + antenna_gain_dbi - cable_loss_db`
"""
@spec eirp_dbm(t(), float()) :: float()
def eirp_dbm(%__MODULE__{} = coverage, antenna_gain_dbi) when is_number(antenna_gain_dbi) do
(coverage.tx_power_dbm || 0.0) + antenna_gain_dbi * 1.0 - (coverage.cable_loss_db || 0.0)
end
@doc """
Returns the `{lat, lon}` to use for the coverage centre — the
per-coverage override when set, otherwise the parent site's
coordinates. Returns `nil` if neither has both coordinates.
"""
@spec location(t()) :: {float(), float()} | nil
def location(%__MODULE__{latitude_override: lat, longitude_override: lon}) when is_number(lat) and is_number(lon),
do: {lat * 1.0, lon * 1.0}
def location(%__MODULE__{site: %{latitude: lat, longitude: lon}}) when is_number(lat) and is_number(lon),
do: {lat * 1.0, lon * 1.0}
def location(_), do: nil
# Convert imperial virtual inputs to their SI canonical fields when
# provided. Form submissions arrive imperial; tests/workers send SI.
defp apply_imperial_conversions(changeset) do
changeset
|> convert_imperial(:height_agl_ft, :height_agl_m, &ft_to_m/1)
|> convert_imperial(:height_above_rooftop_ft, :height_above_rooftop_m, &ft_to_m/1)
|> convert_imperial(:receiver_height_ft, :receiver_height_m, &ft_to_m/1)
|> convert_imperial(:tx_clearance_ft, :tx_clearance_m, &ft_to_m/1)
|> convert_imperial(:radius_mi, :radius_m, &mi_to_m/1)
|> convert_imperial(:frequency_ghz, :frequency_mhz, &ghz_to_mhz/1)
end
defp convert_imperial(changeset, virtual_field, real_field, fun) do
case get_change(changeset, virtual_field) do
nil -> changeset
value when is_number(value) -> put_change(changeset, real_field, fun.(value))
_ -> changeset
end
end
defp ft_to_m(ft), do: ft * 0.3048
defp mi_to_m(mi), do: round(mi * 1609.344)
defp ghz_to_mhz(ghz), do: round(ghz * 1000)
# If the caller didn't supply a cell size, pick one that produces a
# reasonable raster: ~200 cells per axis, clamped to 550 m. The form
# hides this knob; advanced callers (API, worker tests) can still set
# it explicitly.
defp ensure_cell_size_m(changeset) do
if get_field(changeset, :cell_size_m) do
changeset
else
case get_field(changeset, :radius_m) do
radius when is_integer(radius) and radius > 0 ->
cell = radius |> div(200) |> max(5) |> min(50)
put_change(changeset, :cell_size_m, cell)
_ ->
changeset
end
end
end
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