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.
335 lines
12 KiB
Elixir
335 lines
12 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.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 5–50 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
|