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.
This commit is contained in:
Graham McIntire 2026-05-06 13:44:14 -05:00
parent 7e2fa438f7
commit 5a9381f91a
24 changed files with 3746 additions and 2 deletions

View file

@ -1,3 +1,28 @@
2026-05-06
feat: /coverage feature scaffold (RF coverage prediction)
Schema + migration:
- priv/repo/migrations/20260506181711_create_coverages.exs (binary_id, FK to sites/orgs, RF params, status enum, bbox columns, raster/png paths, unique [site_id, name])
- lib/towerops/coverages/coverage.ex: full validation incl. pixel-budget cap (≤8000/axis), antenna_slug existence check; organization_id excluded from cast (programmatic-only)
Context (lib/towerops/coverages.ex):
- org-scoped CRUD: create_coverage/2 (org_id explicit), update_coverage/2, list_for_organization/1, list_for_site/2, get_coverage/!2
- validate_site_in_organization/2 rejects cross-tenant site_id at insert/update
- mark_status/3 + queue_compute/1 with PubSub broadcast on per-coverage and per-org topics
Antenna registry (lib/towerops/coverages/antenna.ex):
- MSI Planet .ant parser, persistent_term registry loaded at boot from priv/antennas/
- bilinear attenuation_db/3, dBd→dBi conversion, two synthetic .ant fixtures bundled
Worker (lib/towerops/workers/coverage_worker.ex):
- Oban worker on new :coverage queue (concurrency 2); job args include organization_id, refuses to run on org mismatch
- currently a stub: flips status to "failed" with "compute not yet implemented" so UI flow is exercisable
LiveViews:
- CoverageLive.Index (org-wide list with status badges, recompute/delete actions, live PubSub updates)
- CoverageLive.Form (new/edit with antenna picker grouped by manufacturer)
- CoverageLive.Show (status banners, parameter panel, map placeholder, delete/recompute)
- sidebar nav link added (hero-signal)
Routes: /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit under :require_authenticated_user_with_default_org
Oban: :coverage queue added to dev.exs and runtime.exs (concurrency 2)
Tests: 41 new tests covering schema validation, .ant parsing, context CRUD with cross-org guards, worker job enqueue
Deferred to follow-ups: NTIA ITM C NIF, Microsoft Building Footprints importer, real compute pipeline (DSM build + per-pixel ITM + GeoTIFF/PNG writers), Leaflet imageOverlay hook, curated bundled antenna pack
2026-04-28
perf+refactor: codebase-wide query/antipattern audit and fixes
Performance:

View file

@ -72,7 +72,9 @@ config :towerops, Oban,
# Alert notifications (PagerDuty, email, etc.)
notifications: 10,
maintenance: 5,
weather: 2
weather: 2,
# RF coverage prediction (CPU + GDAL/HTTP-range I/O heavy, slow)
coverage: 2
],
plugins: [
# Cron jobs for periodic maintenance tasks

View file

@ -188,7 +188,9 @@ if config_env() == :prod do
notifications: max(1, div(25, oban_scale)),
maintenance: max(1, div(5, oban_scale)),
weather: max(1, div(2, oban_scale)),
lidar: max(1, div(2, oban_scale))
lidar: max(1, div(2, oban_scale)),
# RF coverage prediction (CPU + GDAL/HTTP-range I/O heavy, slow)
coverage: max(1, div(2, oban_scale))
],
plugins: [
# Cron jobs for periodic maintenance tasks

View file

@ -7,6 +7,7 @@ defmodule Towerops.Application do
use Application
alias Towerops.Coverages.Antenna
alias Towerops.Workers.JobCleanupTask
require Logger
@ -81,6 +82,9 @@ defmodule Towerops.Application do
Logger.warning("Unexpected MIB library init result: #{inspect(other)}")
end
# Load antenna pattern registry (priv/antennas/*.ant) into :persistent_term
Antenna.load_registry()
topologies = Application.get_env(:libcluster, :topologies, [])
# Supervision tree order matters for shutdown: children shut down in reverse start order.

208
lib/towerops/coverages.ex Normal file
View file

@ -0,0 +1,208 @@
defmodule Towerops.Coverages do
@moduledoc """
Context for RF coverage prediction records.
Coverages are scoped per organization (multi-tenant) and hang off a site
one coverage per transmit antenna. The actual heatmap compute is handled
by `Towerops.Workers.CoverageWorker`; this module is the CRUD and
state-transition surface used by LiveViews and the worker itself.
## Organization scoping
Every read takes `organization_id` as the first argument and filters at
the query level there is no unscoped `get/1` or `list/0`.
Every write (create / update) verifies that the supplied `site_id`
belongs to the caller's organization, so a malicious payload that pairs
one org's `organization_id` with another org's `site_id` is rejected
before insert. `organization_id` itself is set by the context from the
caller's scope, not from user-supplied attrs.
"""
import Ecto.Query
alias Towerops.Coverages.Coverage
alias Towerops.Repo
alias Towerops.Sites.Site
alias Towerops.Workers.CoverageWorker
@doc """
Creates a coverage owned by the given organization.
`organization_id` is set programmatically. The supplied `site_id` (in
attrs) must belong to the same organization or the changeset is rejected.
"""
@spec create_coverage(Ecto.UUID.t(), map()) ::
{:ok, Coverage.t()} | {:error, Ecto.Changeset.t()}
def create_coverage(organization_id, attrs) do
%Coverage{organization_id: organization_id}
|> Coverage.changeset(attrs)
|> validate_site_in_organization(organization_id)
|> Repo.insert()
end
@doc """
Updates a coverage. The coverage's existing `organization_id` is
preserved (callers cannot change ownership). If `site_id` is changing,
the new site must belong to the same organization.
"""
@spec update_coverage(Coverage.t(), map()) ::
{:ok, Coverage.t()} | {:error, Ecto.Changeset.t()}
def update_coverage(%Coverage{organization_id: org_id} = coverage, attrs) do
coverage
|> Coverage.changeset(attrs)
|> validate_site_in_organization(org_id)
|> Repo.update()
end
@doc "Deletes a coverage."
@spec delete_coverage(Coverage.t()) :: {:ok, Coverage.t()} | {:error, Ecto.Changeset.t()}
def delete_coverage(%Coverage{} = coverage), do: Repo.delete(coverage)
@doc "Returns a changeset for the form helpers."
@spec change_coverage(Coverage.t(), map()) :: Ecto.Changeset.t()
def change_coverage(%Coverage{} = coverage, attrs \\ %{}) do
Coverage.changeset(coverage, attrs)
end
@doc "Lists all coverages for an organization, newest first."
@spec list_for_organization(Ecto.UUID.t()) :: [Coverage.t()]
def list_for_organization(organization_id) do
Coverage
|> where(organization_id: ^organization_id)
|> order_by(desc: :inserted_at)
|> preload(:site)
|> Repo.all()
end
@doc "Lists all coverages for a single site, newest first. Scoped by org."
@spec list_for_site(Ecto.UUID.t(), Ecto.UUID.t()) :: [Coverage.t()]
def list_for_site(organization_id, site_id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(site_id: ^site_id)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
@doc "Fetches a coverage scoped to an organization. Raises if not found."
@spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
def get_coverage!(organization_id, id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(id: ^id)
|> preload(:site)
|> Repo.one!()
end
@doc "Fetches a coverage scoped to an organization. Returns nil if not found."
@spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil
def get_coverage(organization_id, id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(id: ^id)
|> preload(:site)
|> Repo.one()
end
@doc """
Updates transient state (status, progress, error, paths, computed bbox)
without re-running form-field validations. Used by the worker.
"""
@spec mark_status(Coverage.t(), String.t(), map()) ::
{:ok, Coverage.t()} | {:error, Ecto.Changeset.t()}
def mark_status(%Coverage{} = coverage, status, extra \\ %{}) do
attrs =
extra
|> stringify_keys()
|> Map.put("status", status)
coverage
|> Coverage.status_changeset(attrs)
|> Repo.update()
end
@doc """
Transitions a draft/failed/ready coverage to `queued` and enqueues a
CoverageWorker job. The job payload includes `organization_id` so the
worker can verify ownership before doing any work.
"""
@spec queue_compute(Coverage.t()) ::
{:ok, Coverage.t()} | {:error, Ecto.Changeset.t() | :compute_in_progress}
def queue_compute(%Coverage{status: status} = _coverage) when status in ["queued", "computing"] do
{:error, :compute_in_progress}
end
def queue_compute(%Coverage{} = coverage) do
with {:ok, queued} <-
mark_status(coverage, "queued", %{progress_pct: 0, error_message: nil}),
{:ok, _job} <-
%{coverage_id: queued.id, organization_id: queued.organization_id}
|> CoverageWorker.new()
|> Oban.insert() do
{:ok, queued}
end
end
@doc "PubSub topic for live status updates of a single coverage."
@spec topic(Ecto.UUID.t()) :: String.t()
def topic(coverage_id), do: "coverage:#{coverage_id}"
@doc "PubSub topic for any-coverage-changed updates within an organization."
@spec org_topic(Ecto.UUID.t()) :: String.t()
def org_topic(organization_id), do: "coverage:org:#{organization_id}"
@doc "Subscribe the calling process to a coverage's status updates."
@spec subscribe(Ecto.UUID.t()) :: :ok | {:error, term()}
def subscribe(coverage_id) do
Phoenix.PubSub.subscribe(Towerops.PubSub, topic(coverage_id))
end
@doc """
Subscribe the calling process to all coverage status updates for one
organization. Used by the index page to live-refresh status badges.
"""
@spec subscribe_organization(Ecto.UUID.t()) :: :ok | {:error, term()}
def subscribe_organization(organization_id) do
Phoenix.PubSub.subscribe(Towerops.PubSub, org_topic(organization_id))
end
@doc false
@spec broadcast(Coverage.t(), term()) :: :ok | {:error, term()}
def broadcast(%Coverage{id: id, organization_id: org_id}, message) do
Phoenix.PubSub.broadcast(Towerops.PubSub, topic(id), message)
Phoenix.PubSub.broadcast(Towerops.PubSub, org_topic(org_id), message)
end
defp validate_site_in_organization(changeset, organization_id) do
case Ecto.Changeset.get_field(changeset, :site_id) do
nil ->
changeset
site_id ->
if site_belongs_to_organization?(site_id, organization_id) do
changeset
else
Ecto.Changeset.add_error(
changeset,
:site_id,
"does not belong to your organization"
)
end
end
end
defp site_belongs_to_organization?(site_id, organization_id) do
Site
|> where(id: ^site_id)
|> where(organization_id: ^organization_id)
|> Repo.exists?()
end
defp stringify_keys(map) when is_map(map) do
Map.new(map, fn
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
{k, v} -> {k, v}
end)
end
end

View file

@ -0,0 +1,340 @@
defmodule Towerops.Coverages.Antenna do
@moduledoc """
Antenna pattern record + MSI Planet `.ant` parser.
Antennas are loaded once at boot from `priv/antennas/*.ant` and stored in
`:persistent_term` keyed by slug. They have no DB representation
the catalog ships with the application.
The MSI Planet `.ant` format is the de facto standard for WISP antenna
vendors. Each file has a header section with metadata (NAME, MAKE,
FREQUENCY, GAIN, ) followed by HORIZONTAL N / VERTICAL N pattern
blocks listing N angle/attenuation pairs in degrees and decibels.
"""
@enforce_keys [:slug, :model, :h_pattern, :v_pattern, :gain_dbi]
defstruct [
:slug,
:manufacturer,
:model,
:frequency_mhz,
:h_width_deg,
:v_width_deg,
:front_to_back_db,
:gain_dbi,
:polarization,
:tilt,
:comment,
:source_file,
:h_pattern,
:v_pattern
]
@type pattern :: [{integer(), float()}]
@type t :: %__MODULE__{
slug: String.t(),
manufacturer: String.t() | nil,
model: String.t(),
frequency_mhz: integer() | nil,
h_width_deg: float() | nil,
v_width_deg: float() | nil,
front_to_back_db: float() | nil,
gain_dbi: float(),
polarization: String.t() | nil,
tilt: String.t() | nil,
comment: String.t() | nil,
source_file: String.t() | nil,
h_pattern: pattern(),
v_pattern: pattern()
}
@persistent_term_key {__MODULE__, :registry}
@doc """
Reads and parses a `.ant` file from disk. Slug is derived from the
filename (without extension) unless overridden.
"""
@spec parse_file(Path.t(), keyword()) :: {:ok, t()} | {:error, term()}
def parse_file(path, opts \\ []) do
slug = opts[:slug] || derive_slug(path)
case File.read(path) do
{:ok, content} -> parse(content, slug: slug, source_file: Path.basename(path))
{:error, reason} -> {:error, {:read_error, reason}}
end
end
@doc "Parses a `.ant` file from a binary."
@spec parse(binary(), keyword()) :: {:ok, t()} | {:error, term()}
def parse(content, opts \\ []) when is_binary(content) do
slug = opts[:slug] || raise ArgumentError, "slug is required when parsing a binary"
source_file = opts[:source_file]
lines =
content
|> String.split(~r/\R/)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
with {header, body} <- split_at_pattern_block(lines),
{:ok, h_pattern, after_h} <- parse_pattern_block(body, "HORIZONTAL"),
{:ok, v_pattern, _after_v} <- parse_pattern_block(after_h, "VERTICAL") do
meta = parse_header(header)
{:ok,
%__MODULE__{
slug: slug,
manufacturer: Map.get(meta, "MAKE"),
model: Map.get(meta, "NAME", slug),
frequency_mhz: parse_int(Map.get(meta, "FREQUENCY")),
h_width_deg: parse_float(Map.get(meta, "H_WIDTH")),
v_width_deg: parse_float(Map.get(meta, "V_WIDTH")),
front_to_back_db: parse_float(Map.get(meta, "FRONT_TO_BACK")),
gain_dbi: parse_gain(Map.get(meta, "GAIN")),
polarization: Map.get(meta, "POLARIZATION"),
tilt: Map.get(meta, "TILT"),
comment: Map.get(meta, "COMMENT"),
source_file: source_file,
h_pattern: h_pattern,
v_pattern: v_pattern
}}
end
end
@doc """
Returns total antenna attenuation in dB for a given azimuth and
elevation offset (relative to boresight).
Uses the standard MSI Planet "additive" interpretation:
`total = h_attenuation(az_offset) + v_attenuation(el_offset)`.
Both inputs are wrapped to 0..360 degrees.
"""
@spec attenuation_db(t(), float(), float()) :: float()
def attenuation_db(%__MODULE__{} = antenna, az_offset_deg, el_offset_deg) do
h = sample_pattern(antenna.h_pattern, az_offset_deg)
v = sample_pattern(antenna.v_pattern, el_offset_deg)
h + v
end
@doc """
Lists all antennas currently loaded into the registry. Empty list if
the registry has not been loaded yet.
"""
@spec list() :: [t()]
def list do
case :persistent_term.get(@persistent_term_key, nil) do
nil -> []
registry -> registry |> Map.values() |> Enum.sort_by(&{&1.manufacturer, &1.model})
end
end
@doc "Fetches a single antenna by slug. Returns nil if not present."
@spec get(String.t()) :: t() | nil
def get(slug) when is_binary(slug) do
case :persistent_term.get(@persistent_term_key, nil) do
nil -> nil
registry -> Map.get(registry, slug)
end
end
@doc """
Returns true if the registry contains an antenna with the given slug.
"""
@spec exists?(String.t()) :: boolean()
def exists?(slug), do: get(slug) != nil
@doc """
Loads all `.ant` files from `priv/antennas/` into the registry.
Logs a warning and skips files that fail to parse. Should be called
once during application boot.
"""
@spec load_registry() :: :ok
def load_registry do
dir = Application.app_dir(:towerops, "priv/antennas")
registry =
case File.ls(dir) do
{:ok, files} -> build_registry(dir, files)
{:error, _} -> %{}
end
:persistent_term.put(@persistent_term_key, registry)
:ok
end
defp build_registry(dir, files) do
files
|> Enum.filter(&String.ends_with?(&1, ".ant"))
|> Enum.reduce(%{}, &add_antenna_to_registry(&1, &2, dir))
end
defp add_antenna_to_registry(filename, acc, dir) do
path = Path.join(dir, filename)
slug = derive_slug(path)
case parse_file(path, slug: slug) do
{:ok, antenna} ->
Map.put(acc, slug, antenna)
{:error, reason} ->
require Logger
Logger.warning("Failed to parse antenna #{filename}: #{inspect(reason)}")
acc
end
end
defp derive_slug(path) do
path |> Path.basename() |> Path.rootname() |> String.downcase()
end
defp split_at_pattern_block(lines) do
case Enum.split_while(lines, fn line ->
not String.starts_with?(line, "HORIZONTAL")
end) do
{header, [_ | _] = rest} -> {header, rest}
{header, []} -> {header, []}
end
end
defp parse_header(lines) do
Map.new(lines, fn line ->
case String.split(line, " ", parts: 2) do
[key, value] -> {String.upcase(key), String.trim(value)}
[key] -> {String.upcase(key), ""}
end
end)
end
defp parse_pattern_block([], keyword) do
{:error, {:missing_block, block_atom(keyword)}}
end
defp parse_pattern_block([header | rest], keyword) do
with {:ok, count} <- parse_block_header(header, keyword),
{entries, remaining} <- Enum.split(rest, count),
:ok <- check_block_count(entries, count, keyword),
{:ok, parsed} <- parse_pattern_lines(entries, keyword) do
{:ok, parsed, remaining}
end
end
defp parse_block_header(header, keyword) do
case String.split(header, " ", trim: true) do
[^keyword, count_str] ->
case Integer.parse(count_str) do
{count, ""} -> {:ok, count}
_ -> {:error, {:invalid_pattern_header, keyword}}
end
_ ->
{:error, {:expected_pattern_header, keyword}}
end
end
defp check_block_count(entries, count, keyword) do
if length(entries) == count do
:ok
else
{:error, {:pattern_count_mismatch, block_atom(keyword), %{expected: count, got: length(entries)}}}
end
end
defp parse_pattern_lines(entries, keyword) do
parsed = Enum.map(entries, &parse_pattern_line/1)
if Enum.any?(parsed, &(&1 == :error)) do
{:error, {:invalid_pattern_line, keyword}}
else
{:ok, parsed}
end
end
defp block_atom("HORIZONTAL"), do: :horizontal
defp block_atom("VERTICAL"), do: :vertical
defp block_atom(other), do: String.to_existing_atom(String.downcase(other))
defp parse_pattern_line(line) do
case String.split(line, ~r/\s+/, trim: true) do
[angle_str, atten_str] ->
with {angle, _} <- Integer.parse(angle_str),
{atten, _} <- Float.parse(atten_str) do
{angle, atten}
else
_ -> :error
end
_ ->
:error
end
end
defp parse_int(nil), do: nil
defp parse_int(s) do
case Integer.parse(s) do
{n, _} -> n
:error -> nil
end
end
defp parse_float(nil), do: nil
defp parse_float(s) do
case Float.parse(s) do
{n, _} -> n
:error -> nil
end
end
defp parse_gain(nil), do: 0.0
defp parse_gain(s) do
case Float.parse(s) do
{value, rest} ->
rest_lower = rest |> String.trim() |> String.downcase()
if String.starts_with?(rest_lower, "dbd") do
# Convert dipole reference to isotropic.
value + 2.15
else
value
end
:error ->
0.0
end
end
# Linearly interpolate the pattern at the given (possibly fractional, possibly
# out-of-range) angle. Pattern is a sorted list of {integer_angle, attenuation_db}.
defp sample_pattern(pattern, angle_deg) do
pattern_size = length(pattern)
pattern_array = pattern |> Enum.map(&elem(&1, 1)) |> List.to_tuple()
# Wrap angle into 0..360 (using full pattern size as period — usually 360).
period = pattern_size
wrapped = wrap_to_period(angle_deg, period)
lower_idx = wrapped |> floor() |> rem_positive(period)
upper_idx = rem_positive(lower_idx + 1, period)
frac = wrapped - floor(wrapped)
lo = elem(pattern_array, lower_idx)
hi = elem(pattern_array, upper_idx)
lo + (hi - lo) * frac
end
defp wrap_to_period(angle, period) do
period_f = period * 1.0
wrapped = :math.fmod(angle, period_f)
if wrapped < 0, do: wrapped + period_f, else: wrapped
end
defp rem_positive(n, period) do
case rem(n, period) do
r when r < 0 -> r + period
r -> r
end
end
end

View file

@ -0,0 +1,207 @@
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

View file

@ -0,0 +1,76 @@
defmodule Towerops.Workers.CoverageWorker do
@moduledoc """
Computes an RF coverage prediction for a single coverage record.
This worker is currently a scaffold: it walks a coverage through the
status state machine (queued computing failed) and broadcasts
progress on `Towerops.Coverages.topic/1` and `org_topic/1`. The real
terrain + ITM compute pipeline is implemented in follow-up changes;
for now the worker reports a clear "compute not yet implemented"
failure so the end-to-end UI flow is exercisable.
## Org scoping
Job args carry both `coverage_id` and `organization_id`. The worker
refuses to run if the loaded coverage's `organization_id` does not
match the job payload defence in depth in case a malicious or
buggy producer ever enqueues a job with a swapped pair.
"""
use Oban.Worker,
queue: :coverage,
max_attempts: 3,
unique: [
fields: [:args],
keys: [:coverage_id],
states: [:available, :scheduled, :executing, :retryable]
]
alias Towerops.Coverages
alias Towerops.Coverages.Coverage
alias Towerops.Repo
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do
case Repo.get(Coverage, coverage_id) do
nil ->
Logger.warning("CoverageWorker: coverage #{coverage_id} not found, skipping")
:ok
%Coverage{organization_id: ^organization_id} = coverage ->
run(coverage)
%Coverage{} ->
Logger.error(
"CoverageWorker: organization mismatch for #{coverage_id} " <>
"(expected #{organization_id}); refusing to run"
)
{:cancel, :organization_mismatch}
end
end
defp run(coverage) do
{:ok, coverage} = Coverages.mark_status(coverage, "computing", %{progress_pct: 1})
Coverages.broadcast(coverage, {:coverage_status, :computing, 1})
Logger.info("CoverageWorker: compute pipeline not yet implemented for #{coverage.id}")
error =
"Coverage compute is not yet implemented. The pipeline (LIDAR + buildings + ITM) " <>
"is under development."
{:ok, coverage} =
Coverages.mark_status(coverage, "failed", %{
error_message: error,
progress_pct: 0
})
Coverages.broadcast(coverage, {:coverage_status, :failed, error})
# Return :ok so Oban marks the job complete — the failure is on
# the coverage record, not Oban-retryable, while the stub is in place.
:ok
end
end

View file

@ -382,6 +382,12 @@ defmodule ToweropsWeb.Layouts do
icon="hero-map"
label={t("Network Map")}
/>
<.sidebar_link
navigate={~p"/coverage"}
active={@active_page == "coverage"}
icon="hero-signal"
label={t("Coverage")}
/>
<%!-- <.sidebar_link
navigate={~p"/weathermap"}
active={@active_page == "weathermap"}

View file

@ -0,0 +1,106 @@
defmodule ToweropsWeb.CoverageLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Coverage
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
antennas = Antenna.list()
{:ok,
socket
|> assign(:organization, organization)
|> assign(:sites, sites)
|> assign(:antennas, antennas)
|> assign(:antenna_options, antenna_options(antennas))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
coverage = %Coverage{
organization_id: socket.assigns.organization.id,
downtilt_deg: 0.0,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0,
cell_size_m: 10,
radius_m: 5_000
}
socket
|> assign(:page_title, t("New Coverage"))
|> assign(:coverage, coverage)
|> assign(:form, to_form(Coverages.change_coverage(coverage)))
end
defp apply_action(socket, :edit, %{"id" => id}) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
socket
|> assign(:page_title, t("Edit Coverage"))
|> assign(:coverage, coverage)
|> assign(:form, to_form(Coverages.change_coverage(coverage)))
end
@impl true
def handle_event("validate", %{"coverage" => attrs}, socket) do
changeset =
socket.assigns.coverage
|> Coverages.change_coverage(attrs)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"coverage" => attrs}, socket) do
save(socket, socket.assigns.live_action, attrs)
end
defp save(socket, :new, attrs) do
case Coverages.create_coverage(socket.assigns.organization.id, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage created"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp save(socket, :edit, attrs) do
case Coverages.update_coverage(socket.assigns.coverage, attrs) do
{:ok, coverage} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage updated"))
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp antenna_options(antennas) do
antennas
|> Enum.group_by(& &1.manufacturer)
|> Enum.sort_by(fn {mfr, _} -> mfr || "" end)
|> Enum.map(fn {mfr, ants} ->
{mfr || t("Other"),
Enum.map(ants, fn ant ->
{"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug}
end)}
end)
end
end

View file

@ -0,0 +1,180 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @page_title}
]} />
<.header>
{@page_title}
<:subtitle>
{t(
"Configure the antenna and RF parameters. The compute pipeline will produce a heatmap on save."
)}
</:subtitle>
</.header>
<.form
for={@form}
id="coverage-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-8"
>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<%!-- Identity --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Identity")}
</h3>
<.input
field={@form[:name]}
type="text"
label={t("Name")}
placeholder={t("e.g. North sector 5 GHz")}
required
/>
<.input
field={@form[:site_id]}
type="select"
label={t("Site")}
prompt={t("Select a site")}
options={Enum.map(@sites, &{&1.name, &1.id})}
required
/>
<.input
field={@form[:antenna_slug]}
type="select"
label={t("Antenna")}
prompt={t("Select an antenna")}
options={@antenna_options}
required
/>
</div>
<%!-- Mounting --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Mounting")}
</h3>
<.input
field={@form[:height_agl_m]}
type="number"
label={t("Height above ground (m)")}
step="0.1"
min="1"
max="200"
required
/>
<.input
field={@form[:azimuth_deg]}
type="number"
label={t("Azimuth (°, true north)")}
step="0.1"
min="0"
max="360"
required
/>
<.input
field={@form[:downtilt_deg]}
type="number"
label={t("Downtilt (°, positive = down)")}
step="0.1"
min="-10"
max="30"
/>
</div>
<%!-- RF parameters --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("RF parameters")}
</h3>
<.input
field={@form[:frequency_mhz]}
type="number"
label={t("Frequency (MHz)")}
step="1"
min="700"
max="90000"
required
/>
<.input
field={@form[:eirp_dbm]}
type="number"
label={t("EIRP (dBm)")}
step="0.1"
min="0"
max="60"
required
/>
</div>
<%!-- Coverage extent --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Coverage extent")}
</h3>
<.input
field={@form[:radius_m]}
type="number"
label={t("Radius (m)")}
step="100"
min="500"
max="40000"
required
/>
<.input
field={@form[:cell_size_m]}
type="number"
label={t("Cell size (m)")}
step="1"
min="1"
max="50"
required
/>
<.input
field={@form[:receiver_height_m]}
type="number"
label={t("Receiver height (m)")}
step="0.1"
min="0.5"
max="100"
/>
<.input
field={@form[:rx_threshold_dbm]}
type="number"
label={t("RX threshold (dBm)")}
step="0.5"
min="-130"
max="0"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<.link navigate={~p"/coverage"} class="btn btn-outline">
{t("Cancel")}
</.link>
<.button type="submit" variant="primary">
{if @live_action == :new, do: t("Create coverage"), else: t("Save changes")}
</.button>
</div>
</.form>
</Layouts.authenticated>

View file

@ -0,0 +1,81 @@
defmodule ToweropsWeb.CoverageLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
if connected?(socket), do: Coverages.subscribe_organization(organization.id)
{:ok,
socket
|> assign(:page_title, t("Coverage"))
|> assign(:organization, organization)
|> assign(:sites, Sites.list_organization_sites(organization.id))
|> load_coverages()}
end
@impl true
def handle_params(_params, _url, socket), do: {:noreply, socket}
@impl true
def handle_event("delete", %{"id" => id}, socket) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
case Coverages.delete_coverage(coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage deleted"))
|> load_coverages()}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete coverage"))}
end
end
@impl true
def handle_event("recompute", %{"id" => id}, socket) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
case Coverages.queue_compute(coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage queued for recompute"))
|> load_coverages()}
{:error, :compute_in_progress} ->
{:noreply, put_flash(socket, :error, t("Coverage is already being computed"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to queue coverage"))}
end
end
@impl true
def handle_info({:coverage_status, _status, _arg}, socket) do
{:noreply, load_coverages(socket)}
end
defp load_coverages(socket) do
assign(socket, :coverages, Coverages.list_for_organization(socket.assigns.organization.id))
end
@doc false
def status_badge_class("draft"), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
def status_badge_class("queued"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
def status_badge_class("computing"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
def status_badge_class("ready"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
def status_badge_class("failed"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
def status_badge_class(_), do: "bg-gray-100 text-gray-700"
end

View file

@ -0,0 +1,166 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.header>
{@page_title}
<:subtitle>
{t(
"Predict signal coverage for each transmit antenna using LIDAR terrain and building data."
)}
</:subtitle>
<:actions>
<.button :if={@sites != []} navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
</.button>
</:actions>
</.header>
<%= cond do %>
<% @sites == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("Add a site first")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t(
"Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
)}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")}
</.button>
</div>
</div>
</div>
</div>
<% @coverages == [] -> %>
<div class="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 class="card-title text-gray-900 dark:text-white">{t("No coverages yet")}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("Create your first coverage to predict where a tower's antenna reaches.")}
</p>
<div class="card-actions mt-2">
<.button navigate={~p"/coverage/new"} variant="primary">
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
</.button>
</div>
</div>
</div>
</div>
<% true -> %>
<div class="mt-6 overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
<table
id="coverages-table"
class="min-w-full divide-y divide-gray-200 dark:divide-white/10"
>
<thead class="bg-gray-50 dark:bg-gray-800/80">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Site")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Antenna")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Freq (MHz)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("EIRP (dBm)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Radius (m)")}
</th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Status")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-900 dark:divide-white/10">
<%= for coverage <- @coverages do %>
<tr id={"coverage-#{coverage.id}"}>
<td class="px-4 py-2 text-sm">
<.link
navigate={~p"/coverage/#{coverage.id}"}
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{coverage.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline">
{coverage.site && coverage.site.name}
</.link>
</td>
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
<span class="font-mono text-xs">{coverage.antenna_slug}</span>
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.frequency_mhz}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.eirp_dbm}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.radius_m}
</td>
<td class="px-4 py-2 text-sm">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
status_badge_class(coverage.status)
]}>
{coverage.status}
</span>
</td>
<td class="px-4 py-2 text-sm text-right">
<div class="flex justify-end gap-2">
<.link
navigate={~p"/coverage/#{coverage.id}/edit"}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<.icon name="hero-pencil-square" class="h-4 w-4" />
</.link>
<button
type="button"
phx-click="recompute"
phx-value-id={coverage.id}
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
title={t("Recompute")}
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
</button>
<button
type="button"
phx-click="delete"
phx-value-id={coverage.id}
data-confirm={t("Delete this coverage?")}
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
title={t("Delete")}
>
<.icon name="hero-trash" class="h-4 w-4" />
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -0,0 +1,89 @@
defmodule ToweropsWeb.CoverageLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_scope.organization
coverage = Coverages.get_coverage!(organization.id, id)
if connected?(socket), do: Coverages.subscribe(coverage.id)
{:ok,
socket
|> assign(:page_title, coverage.name)
|> assign(:organization, organization)
|> assign(:coverage, coverage)
|> assign(:antenna, Antenna.get(coverage.antenna_slug))}
end
@impl true
def handle_params(_params, _url, socket), do: {:noreply, socket}
@impl true
def handle_event("recompute", _params, socket) do
case Coverages.queue_compute(socket.assigns.coverage) do
{:ok, coverage} ->
{:noreply,
socket
|> assign(:coverage, coverage)
|> put_flash(:info, t("Coverage queued for recompute"))}
{:error, :compute_in_progress} ->
{:noreply, put_flash(socket, :error, t("Coverage is already being computed"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to queue coverage"))}
end
end
@impl true
def handle_event("delete", _params, socket) do
case Coverages.delete_coverage(socket.assigns.coverage) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, t("Coverage deleted"))
|> push_navigate(to: ~p"/coverage")}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Unable to delete coverage"))}
end
end
@impl true
def handle_info({:coverage_status, _status, _extra}, socket) do
coverage =
Coverages.get_coverage!(socket.assigns.organization.id, socket.assigns.coverage.id)
{:noreply, assign(socket, :coverage, coverage)}
end
@doc false
def status_badge_class("draft"), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
def status_badge_class("queued"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
def status_badge_class("computing"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
def status_badge_class("ready"), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
def status_badge_class("failed"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
def status_badge_class(_), do: "bg-gray-100 text-gray-700"
attr :label, :string, required: true
attr :value, :string, required: true
def param_row(assigns) do
~H"""
<div class="flex justify-between gap-3">
<dt class="text-gray-600 dark:text-gray-400">{@label}</dt>
<dd class="text-gray-900 dark:text-gray-100 font-medium text-right">{@value}</dd>
</div>
"""
end
end

View file

@ -0,0 +1,159 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="coverage"
>
<.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
%{label: t("Coverage"), navigate: ~p"/coverage"},
%{label: @coverage.name}
]} />
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@coverage.name}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<.icon name="hero-map-pin" class="h-4 w-4 inline" />
<.link navigate={~p"/sites/#{@coverage.site_id}"} class="hover:underline">
{@coverage.site && @coverage.site.name}
</.link>
</p>
</div>
<div class="flex items-center gap-2">
<span class={[
"inline-flex items-center px-2.5 py-1 rounded text-sm font-medium",
status_badge_class(@coverage.status)
]}>
{@coverage.status}
</span>
<.link navigate={~p"/coverage/#{@coverage.id}/edit"} class="btn btn-outline btn-sm gap-1.5">
<.icon name="hero-pencil-square" class="h-4 w-4" /> {t("Edit")}
</.link>
<.button phx-click="recompute" variant="primary">
<.icon name="hero-arrow-path" class="h-4 w-4" /> {t("Recompute")}
</.button>
</div>
</div>
<%= if @coverage.status == "computing" do %>
<div class="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 dark:border-yellow-900/50 dark:bg-yellow-900/20 p-4">
<div class="flex items-center gap-3">
<.icon
name="hero-arrow-path"
class="h-5 w-5 text-yellow-700 dark:text-yellow-400 animate-spin"
/>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-200">
{t("Computing coverage…")}
</p>
<div class="mt-1 h-2 w-full bg-yellow-200 dark:bg-yellow-900 rounded-full overflow-hidden">
<div
class="h-full bg-yellow-600 dark:bg-yellow-500 transition-all"
style={"width: #{@coverage.progress_pct}%"}
>
</div>
</div>
</div>
<span class="text-sm text-yellow-800 dark:text-yellow-300 font-mono">
{@coverage.progress_pct}%
</span>
</div>
</div>
<% end %>
<%= if @coverage.status == "failed" do %>
<div class="mb-6 rounded-lg border border-red-200 bg-red-50 dark:border-red-900/50 dark:bg-red-900/20 p-4">
<div class="flex items-start gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-red-700 dark:text-red-400 flex-shrink-0 mt-0.5"
/>
<div>
<p class="text-sm font-medium text-red-900 dark:text-red-200">
{t("Compute failed")}
</p>
<p :if={@coverage.error_message} class="mt-1 text-sm text-red-800 dark:text-red-300">
{@coverage.error_message}
</p>
</div>
</div>
</div>
<% end %>
<%= if @coverage.status == "queued" do %>
<div class="mb-6 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-900 dark:text-blue-200">
<.icon name="hero-clock" class="h-4 w-4 inline" />
{t("Coverage is queued. Compute will start shortly.")}
</p>
</div>
<% end %>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<%!-- Map area --%>
<div class="lg:col-span-2">
<div class="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<%= if @coverage.png_path && @coverage.status == "ready" do %>
<%!-- Real heatmap rendering is implemented in the compute follow-up.
For now this only renders when png_path is set. --%>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-update="ignore"
>
</div>
<% else %>
<div class="h-[500px] w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
<div>
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
</p>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Parameters panel --%>
<div>
<div class="rounded-lg border border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Parameters")}
</h3>
<dl class="space-y-2 text-sm">
<.param_row
label={t("Antenna")}
value={(@antenna && @antenna.model) || @coverage.antenna_slug}
/>
<.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} />
<.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} />
<.param_row label={t("Height AGL")} value={"#{@coverage.height_agl_m} m"} />
<.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} />
<.param_row label={t("Downtilt")} value={"#{@coverage.downtilt_deg}°"} />
<.param_row label={t("Frequency")} value={"#{@coverage.frequency_mhz} MHz"} />
<.param_row label={t("EIRP")} value={"#{@coverage.eirp_dbm} dBm"} />
<.param_row label={t("Radius")} value={"#{@coverage.radius_m} m"} />
<.param_row label={t("Cell size")} value={"#{@coverage.cell_size_m} m"} />
<.param_row label={t("Receiver height")} value={"#{@coverage.receiver_height_m} m"} />
<.param_row label={t("RX threshold")} value={"#{@coverage.rx_threshold_dbm} dBm"} />
<.param_row
:if={@coverage.computed_at}
label={t("Computed at")}
value={Calendar.strftime(@coverage.computed_at, "%Y-%m-%d %H:%M UTC")}
/>
</dl>
</div>
<button
type="button"
phx-click="delete"
data-confirm={t("Delete this coverage?")}
class="mt-4 w-full text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
<.icon name="hero-trash" class="h-4 w-4 inline" /> {t("Delete coverage")}
</button>
</div>
</div>
</Layouts.authenticated>

View file

@ -510,6 +510,12 @@ defmodule ToweropsWeb.Router do
live "/network-map", NetworkMapLive, :index
live "/weathermap", WeathermapLive, :index
live "/sites-map", MapLive.Index, :index
# RF coverage prediction
live "/coverage", CoverageLive.Index, :index
live "/coverage/new", CoverageLive.Form, :new
live "/coverage/:id", CoverageLive.Show, :show
live "/coverage/:id/edit", CoverageLive.Form, :edit
end
end
end

View file

@ -0,0 +1,732 @@
NAME Test Omni 5 GHz
MAKE Test Manufacturer
FREQUENCY 5500
H_WIDTH 360
V_WIDTH 15
FRONT_TO_BACK 0
GAIN 8.0 dBi
TILT MECHANICAL
POLARIZATION VERTICAL
COMMENT Synthetic omni antenna for tests; flat horizontal plane, slight vertical taper.
HORIZONTAL 360
0 0.00
1 0.00
2 0.00
3 0.00
4 0.00
5 0.00
6 0.00
7 0.00
8 0.00
9 0.00
10 0.00
11 0.00
12 0.00
13 0.00
14 0.00
15 0.00
16 0.00
17 0.00
18 0.00
19 0.00
20 0.00
21 0.00
22 0.00
23 0.00
24 0.00
25 0.00
26 0.00
27 0.00
28 0.00
29 0.00
30 0.00
31 0.00
32 0.00
33 0.00
34 0.00
35 0.00
36 0.00
37 0.00
38 0.00
39 0.00
40 0.00
41 0.00
42 0.00
43 0.00
44 0.00
45 0.00
46 0.00
47 0.00
48 0.00
49 0.00
50 0.00
51 0.00
52 0.00
53 0.00
54 0.00
55 0.00
56 0.00
57 0.00
58 0.00
59 0.00
60 0.00
61 0.00
62 0.00
63 0.00
64 0.00
65 0.00
66 0.00
67 0.00
68 0.00
69 0.00
70 0.00
71 0.00
72 0.00
73 0.00
74 0.00
75 0.00
76 0.00
77 0.00
78 0.00
79 0.00
80 0.00
81 0.00
82 0.00
83 0.00
84 0.00
85 0.00
86 0.00
87 0.00
88 0.00
89 0.00
90 0.00
91 0.00
92 0.00
93 0.00
94 0.00
95 0.00
96 0.00
97 0.00
98 0.00
99 0.00
100 0.00
101 0.00
102 0.00
103 0.00
104 0.00
105 0.00
106 0.00
107 0.00
108 0.00
109 0.00
110 0.00
111 0.00
112 0.00
113 0.00
114 0.00
115 0.00
116 0.00
117 0.00
118 0.00
119 0.00
120 0.00
121 0.00
122 0.00
123 0.00
124 0.00
125 0.00
126 0.00
127 0.00
128 0.00
129 0.00
130 0.00
131 0.00
132 0.00
133 0.00
134 0.00
135 0.00
136 0.00
137 0.00
138 0.00
139 0.00
140 0.00
141 0.00
142 0.00
143 0.00
144 0.00
145 0.00
146 0.00
147 0.00
148 0.00
149 0.00
150 0.00
151 0.00
152 0.00
153 0.00
154 0.00
155 0.00
156 0.00
157 0.00
158 0.00
159 0.00
160 0.00
161 0.00
162 0.00
163 0.00
164 0.00
165 0.00
166 0.00
167 0.00
168 0.00
169 0.00
170 0.00
171 0.00
172 0.00
173 0.00
174 0.00
175 0.00
176 0.00
177 0.00
178 0.00
179 0.00
180 0.00
181 0.00
182 0.00
183 0.00
184 0.00
185 0.00
186 0.00
187 0.00
188 0.00
189 0.00
190 0.00
191 0.00
192 0.00
193 0.00
194 0.00
195 0.00
196 0.00
197 0.00
198 0.00
199 0.00
200 0.00
201 0.00
202 0.00
203 0.00
204 0.00
205 0.00
206 0.00
207 0.00
208 0.00
209 0.00
210 0.00
211 0.00
212 0.00
213 0.00
214 0.00
215 0.00
216 0.00
217 0.00
218 0.00
219 0.00
220 0.00
221 0.00
222 0.00
223 0.00
224 0.00
225 0.00
226 0.00
227 0.00
228 0.00
229 0.00
230 0.00
231 0.00
232 0.00
233 0.00
234 0.00
235 0.00
236 0.00
237 0.00
238 0.00
239 0.00
240 0.00
241 0.00
242 0.00
243 0.00
244 0.00
245 0.00
246 0.00
247 0.00
248 0.00
249 0.00
250 0.00
251 0.00
252 0.00
253 0.00
254 0.00
255 0.00
256 0.00
257 0.00
258 0.00
259 0.00
260 0.00
261 0.00
262 0.00
263 0.00
264 0.00
265 0.00
266 0.00
267 0.00
268 0.00
269 0.00
270 0.00
271 0.00
272 0.00
273 0.00
274 0.00
275 0.00
276 0.00
277 0.00
278 0.00
279 0.00
280 0.00
281 0.00
282 0.00
283 0.00
284 0.00
285 0.00
286 0.00
287 0.00
288 0.00
289 0.00
290 0.00
291 0.00
292 0.00
293 0.00
294 0.00
295 0.00
296 0.00
297 0.00
298 0.00
299 0.00
300 0.00
301 0.00
302 0.00
303 0.00
304 0.00
305 0.00
306 0.00
307 0.00
308 0.00
309 0.00
310 0.00
311 0.00
312 0.00
313 0.00
314 0.00
315 0.00
316 0.00
317 0.00
318 0.00
319 0.00
320 0.00
321 0.00
322 0.00
323 0.00
324 0.00
325 0.00
326 0.00
327 0.00
328 0.00
329 0.00
330 0.00
331 0.00
332 0.00
333 0.00
334 0.00
335 0.00
336 0.00
337 0.00
338 0.00
339 0.00
340 0.00
341 0.00
342 0.00
343 0.00
344 0.00
345 0.00
346 0.00
347 0.00
348 0.00
349 0.00
350 0.00
351 0.00
352 0.00
353 0.00
354 0.00
355 0.00
356 0.00
357 0.00
358 0.00
359 0.00
VERTICAL 360
0 0.00
1 0.05
2 0.10
3 0.15
4 0.20
5 0.30
6 0.45
7 0.60
8 0.80
9 1.00
10 1.30
11 1.65
12 2.00
13 2.40
14 2.85
15 3.30
16 3.80
17 4.35
18 4.95
19 5.60
20 6.30
21 7.05
22 7.85
23 8.70
24 9.60
25 10.55
26 11.55
27 12.60
28 13.70
29 14.85
30 16.05
31 17.30
32 18.60
33 19.95
34 21.35
35 22.80
36 24.30
37 25.85
38 27.45
39 29.10
40 30.80
41 32.55
42 34.35
43 36.20
44 38.10
45 40.05
46 42.05
47 44.10
48 46.20
49 48.35
50 50.55
51 52.80
52 55.10
53 57.45
54 59.85
55 62.30
56 64.80
57 67.35
58 69.95
59 72.60
60 75.30
61 78.05
62 80.85
63 83.70
64 86.60
65 89.55
66 92.55
67 95.60
68 98.70
69 101.85
70 105.05
71 108.30
72 111.60
73 114.95
74 118.35
75 121.80
76 125.30
77 128.85
78 132.45
79 136.10
80 139.80
81 143.55
82 147.35
83 151.20
84 155.10
85 159.05
86 163.05
87 167.10
88 171.20
89 175.35
90 179.55
91 183.80
92 188.10
93 192.45
94 196.85
95 201.30
96 205.80
97 210.35
98 214.95
99 219.60
100 224.30
101 229.05
102 233.85
103 238.70
104 243.60
105 248.55
106 253.55
107 258.60
108 263.70
109 268.85
110 274.05
111 279.30
112 284.60
113 289.95
114 295.35
115 300.80
116 306.30
117 311.85
118 317.45
119 323.10
120 328.80
121 334.55
122 340.35
123 346.20
124 352.10
125 358.05
126 364.05
127 370.10
128 376.20
129 382.35
130 388.55
131 394.80
132 401.10
133 407.45
134 413.85
135 420.30
136 426.80
137 433.35
138 439.95
139 446.60
140 453.30
141 460.05
142 466.85
143 473.70
144 480.60
145 487.55
146 494.55
147 501.60
148 508.70
149 515.85
150 523.05
151 530.30
152 537.60
153 544.95
154 552.35
155 559.80
156 567.30
157 574.85
158 582.45
159 590.10
160 597.80
161 605.55
162 613.35
163 621.20
164 629.10
165 637.05
166 645.05
167 653.10
168 661.20
169 669.35
170 677.55
171 685.80
172 694.10
173 702.45
174 710.85
175 719.30
176 727.80
177 736.35
178 744.95
179 753.60
180 762.30
181 753.60
182 744.95
183 736.35
184 727.80
185 719.30
186 710.85
187 702.45
188 694.10
189 685.80
190 677.55
191 669.35
192 661.20
193 653.10
194 645.05
195 637.05
196 629.10
197 621.20
198 613.35
199 605.55
200 597.80
201 590.10
202 582.45
203 574.85
204 567.30
205 559.80
206 552.35
207 544.95
208 537.60
209 530.30
210 523.05
211 515.85
212 508.70
213 501.60
214 494.55
215 487.55
216 480.60
217 473.70
218 466.85
219 460.05
220 453.30
221 446.60
222 439.95
223 433.35
224 426.80
225 420.30
226 413.85
227 407.45
228 401.10
229 394.80
230 388.55
231 382.35
232 376.20
233 370.10
234 364.05
235 358.05
236 352.10
237 346.20
238 340.35
239 334.55
240 328.80
241 323.10
242 317.45
243 311.85
244 306.30
245 300.80
246 295.35
247 289.95
248 284.60
249 279.30
250 274.05
251 268.85
252 263.70
253 258.60
254 253.55
255 248.55
256 243.60
257 238.70
258 233.85
259 229.05
260 224.30
261 219.60
262 214.95
263 210.35
264 205.80
265 201.30
266 196.85
267 192.45
268 188.10
269 183.80
270 179.55
271 175.35
272 171.20
273 167.10
274 163.05
275 159.05
276 155.10
277 151.20
278 147.35
279 143.55
280 139.80
281 136.10
282 132.45
283 128.85
284 125.30
285 121.80
286 118.35
287 114.95
288 111.60
289 108.30
290 105.05
291 101.85
292 98.70
293 95.60
294 92.55
295 89.55
296 86.60
297 83.70
298 80.85
299 78.05
300 75.30
301 72.60
302 69.95
303 67.35
304 64.80
305 62.30
306 59.85
307 57.45
308 55.10
309 52.80
310 50.55
311 48.35
312 46.20
313 44.10
314 42.05
315 40.05
316 38.10
317 36.20
318 34.35
319 32.55
320 30.80
321 29.10
322 27.45
323 25.85
324 24.30
325 22.80
326 21.35
327 19.95
328 18.60
329 17.30
330 16.05
331 14.85
332 13.70
333 12.60
334 11.55
335 10.55
336 9.60
337 8.70
338 7.85
339 7.05
340 6.30
341 5.60
342 4.95
343 4.35
344 3.80
345 3.30
346 2.85
347 2.40
348 2.00
349 1.65
350 1.30
351 1.00
352 0.80
353 0.60
354 0.45
355 0.30
356 0.20
357 0.15
358 0.10
359 0.05

View file

@ -0,0 +1,732 @@
NAME Test Sector 90deg 5 GHz
MAKE Test Manufacturer
FREQUENCY 5500
H_WIDTH 90
V_WIDTH 10
FRONT_TO_BACK 28
GAIN 16.0 dBi
TILT MECHANICAL
POLARIZATION DUAL
COMMENT Synthetic 90 degree sector antenna for tests.
HORIZONTAL 360
0 0.00
1 0.00
2 0.01
3 0.01
4 0.02
5 0.04
6 0.05
7 0.07
8 0.09
9 0.12
10 0.15
11 0.18
12 0.21
13 0.25
14 0.29
15 0.33
16 0.38
17 0.43
18 0.48
19 0.53
20 0.59
21 0.65
22 0.72
23 0.78
24 0.85
25 0.93
26 1.00
27 1.08
28 1.16
29 1.25
30 1.33
31 1.42
32 1.52
33 1.61
34 1.71
35 1.81
36 1.92
37 2.03
38 2.14
39 2.25
40 2.37
41 2.49
42 2.61
43 2.74
44 2.87
45 3.00
46 3.13
47 3.27
48 3.41
49 3.56
50 3.70
51 3.85
52 4.01
53 4.16
54 4.32
55 4.48
56 4.65
57 4.81
58 4.98
59 5.16
60 5.33
61 6.47
62 6.93
63 7.40
64 7.87
65 8.33
66 8.80
67 9.27
68 9.73
69 10.20
70 10.67
71 11.13
72 11.60
73 12.07
74 12.53
75 13.00
76 13.47
77 13.93
78 14.40
79 14.87
80 15.33
81 15.80
82 16.27
83 16.73
84 17.20
85 17.67
86 18.13
87 18.60
88 19.07
89 19.53
90 20.00
91 30.00
92 29.99
93 29.97
94 29.95
95 29.92
96 29.89
97 29.85
98 29.81
99 29.76
100 29.70
101 29.64
102 29.57
103 29.49
104 29.41
105 29.33
106 29.24
107 29.15
108 29.05
109 28.94
110 28.83
111 28.72
112 28.60
113 28.47
114 28.35
115 28.21
116 28.08
117 27.94
118 27.80
119 27.65
120 27.50
121 27.35
122 27.19
123 27.03
124 26.87
125 26.71
126 26.55
127 26.38
128 26.21
129 26.04
130 25.87
131 25.70
132 25.52
133 25.35
134 25.17
135 25.00
136 24.83
137 24.65
138 24.48
139 24.30
140 24.13
141 23.96
142 23.79
143 23.62
144 23.45
145 23.29
146 23.13
147 22.97
148 22.81
149 22.65
150 22.50
151 22.35
152 22.20
153 22.06
154 21.92
155 21.79
156 21.65
157 21.53
158 21.40
159 21.28
160 21.17
161 21.06
162 20.95
163 20.85
164 20.76
165 20.67
166 20.59
167 20.51
168 20.43
169 20.36
170 20.30
171 20.24
172 20.19
173 20.15
174 20.11
175 20.08
176 20.05
177 20.03
178 20.01
179 20.00
180 20.00
181 20.00
182 20.01
183 20.03
184 20.05
185 20.08
186 20.11
187 20.15
188 20.19
189 20.24
190 20.30
191 20.36
192 20.43
193 20.51
194 20.59
195 20.67
196 20.76
197 20.85
198 20.95
199 21.06
200 21.17
201 21.28
202 21.40
203 21.53
204 21.65
205 21.79
206 21.92
207 22.06
208 22.20
209 22.35
210 22.50
211 22.65
212 22.81
213 22.97
214 23.13
215 23.29
216 23.45
217 23.62
218 23.79
219 23.96
220 24.13
221 24.30
222 24.48
223 24.65
224 24.83
225 25.00
226 25.17
227 25.35
228 25.52
229 25.70
230 25.87
231 26.04
232 26.21
233 26.38
234 26.55
235 26.71
236 26.87
237 27.03
238 27.19
239 27.35
240 27.50
241 27.65
242 27.80
243 27.94
244 28.08
245 28.21
246 28.35
247 28.47
248 28.60
249 28.72
250 28.83
251 28.94
252 29.05
253 29.15
254 29.24
255 29.33
256 29.41
257 29.49
258 29.57
259 29.64
260 29.70
261 29.76
262 29.81
263 29.85
264 29.89
265 29.92
266 29.95
267 29.97
268 29.99
269 30.00
270 20.00
271 19.53
272 19.07
273 18.60
274 18.13
275 17.67
276 17.20
277 16.73
278 16.27
279 15.80
280 15.33
281 14.87
282 14.40
283 13.93
284 13.47
285 13.00
286 12.53
287 12.07
288 11.60
289 11.13
290 10.67
291 10.20
292 9.73
293 9.27
294 8.80
295 8.33
296 7.87
297 7.40
298 6.93
299 6.47
300 5.33
301 5.16
302 4.98
303 4.81
304 4.65
305 4.48
306 4.32
307 4.16
308 4.01
309 3.85
310 3.70
311 3.56
312 3.41
313 3.27
314 3.13
315 3.00
316 2.87
317 2.74
318 2.61
319 2.49
320 2.37
321 2.25
322 2.14
323 2.03
324 1.92
325 1.81
326 1.71
327 1.61
328 1.52
329 1.42
330 1.33
331 1.25
332 1.16
333 1.08
334 1.00
335 0.93
336 0.85
337 0.78
338 0.72
339 0.65
340 0.59
341 0.53
342 0.48
343 0.43
344 0.38
345 0.33
346 0.29
347 0.25
348 0.21
349 0.18
350 0.15
351 0.12
352 0.09
353 0.07
354 0.05
355 0.04
356 0.02
357 0.01
358 0.01
359 0.00
VERTICAL 360
0 0.00
1 0.12
2 0.48
3 1.08
4 1.92
5 3.00
6 4.40
7 5.80
8 7.20
9 8.60
10 10.00
11 10.75
12 11.50
13 12.25
14 13.00
15 13.75
16 14.50
17 15.25
18 16.00
19 16.75
20 17.50
21 18.25
22 19.00
23 19.75
24 20.50
25 21.25
26 22.00
27 22.75
28 23.50
29 24.25
30 25.00
31 30.00
32 30.00
33 29.99
34 29.98
35 29.97
36 29.96
37 29.95
38 29.93
39 29.91
40 29.89
41 29.87
42 29.84
43 29.82
44 29.79
45 29.76
46 29.72
47 29.69
48 29.65
49 29.61
50 29.57
51 29.52
52 29.48
53 29.43
54 29.38
55 29.33
56 29.28
57 29.22
58 29.16
59 29.11
60 29.05
61 28.98
62 28.92
63 28.85
64 28.78
65 28.72
66 28.64
67 28.57
68 28.50
69 28.42
70 28.35
71 28.27
72 28.19
73 28.11
74 28.02
75 27.94
76 27.85
77 27.77
78 27.68
79 27.59
80 27.50
81 27.41
82 27.32
83 27.22
84 27.13
85 27.03
86 26.94
87 26.84
88 26.74
89 26.64
90 26.55
91 26.45
92 26.34
93 26.24
94 26.14
95 26.04
96 25.94
97 25.83
98 25.73
99 25.63
100 25.52
101 25.42
102 25.31
103 25.21
104 25.10
105 25.00
106 24.90
107 24.79
108 24.69
109 24.58
110 24.48
111 24.37
112 24.27
113 24.17
114 24.06
115 23.96
116 23.86
117 23.76
118 23.66
119 23.55
120 23.45
121 23.36
122 23.26
123 23.16
124 23.06
125 22.97
126 22.87
127 22.78
128 22.68
129 22.59
130 22.50
131 22.41
132 22.32
133 22.23
134 22.15
135 22.06
136 21.98
137 21.89
138 21.81
139 21.73
140 21.65
141 21.58
142 21.50
143 21.43
144 21.36
145 21.28
146 21.22
147 21.15
148 21.08
149 21.02
150 20.95
151 20.89
152 20.84
153 20.78
154 20.72
155 20.67
156 20.62
157 20.57
158 20.52
159 20.48
160 20.43
161 20.39
162 20.35
163 20.31
164 20.28
165 20.24
166 20.21
167 20.18
168 20.16
169 20.13
170 20.11
171 20.09
172 20.07
173 20.05
174 20.04
175 20.03
176 20.02
177 20.01
178 20.00
179 20.00
180 20.00
181 20.00
182 20.00
183 20.01
184 20.02
185 20.03
186 20.04
187 20.05
188 20.07
189 20.09
190 20.11
191 20.13
192 20.16
193 20.18
194 20.21
195 20.24
196 20.28
197 20.31
198 20.35
199 20.39
200 20.43
201 20.48
202 20.52
203 20.57
204 20.62
205 20.67
206 20.72
207 20.78
208 20.84
209 20.89
210 20.95
211 21.02
212 21.08
213 21.15
214 21.22
215 21.28
216 21.36
217 21.43
218 21.50
219 21.58
220 21.65
221 21.73
222 21.81
223 21.89
224 21.98
225 22.06
226 22.15
227 22.23
228 22.32
229 22.41
230 22.50
231 22.59
232 22.68
233 22.78
234 22.87
235 22.97
236 23.06
237 23.16
238 23.26
239 23.36
240 23.45
241 23.55
242 23.66
243 23.76
244 23.86
245 23.96
246 24.06
247 24.17
248 24.27
249 24.37
250 24.48
251 24.58
252 24.69
253 24.79
254 24.90
255 25.00
256 25.10
257 25.21
258 25.31
259 25.42
260 25.52
261 25.63
262 25.73
263 25.83
264 25.94
265 26.04
266 26.14
267 26.24
268 26.34
269 26.45
270 26.55
271 26.64
272 26.74
273 26.84
274 26.94
275 27.03
276 27.13
277 27.22
278 27.32
279 27.41
280 27.50
281 27.59
282 27.68
283 27.77
284 27.85
285 27.94
286 28.02
287 28.11
288 28.19
289 28.27
290 28.35
291 28.42
292 28.50
293 28.57
294 28.64
295 28.72
296 28.78
297 28.85
298 28.92
299 28.98
300 29.05
301 29.11
302 29.16
303 29.22
304 29.28
305 29.33
306 29.38
307 29.43
308 29.48
309 29.52
310 29.57
311 29.61
312 29.65
313 29.69
314 29.72
315 29.76
316 29.79
317 29.82
318 29.84
319 29.87
320 29.89
321 29.91
322 29.93
323 29.95
324 29.96
325 29.97
326 29.98
327 29.99
328 30.00
329 30.00
330 25.00
331 24.25
332 23.50
333 22.75
334 22.00
335 21.25
336 20.50
337 19.75
338 19.00
339 18.25
340 17.50
341 16.75
342 16.00
343 15.25
344 14.50
345 13.75
346 13.00
347 12.25
348 11.50
349 10.75
350 10.00
351 8.60
352 7.20
353 5.80
354 4.40
355 3.00
356 1.92
357 1.08
358 0.48
359 0.12

View file

@ -0,0 +1,48 @@
defmodule Towerops.Repo.Migrations.CreateCoverages do
use Ecto.Migration
def change do
create table(:coverages, primary_key: false) do
add :id, :binary_id, primary_key: true
add :site_id, references(:sites, type: :binary_id, on_delete: :delete_all), null: false
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
add :name, :string, null: false
add :antenna_slug, :string, null: false
add :height_agl_m, :float, null: false
add :azimuth_deg, :float, null: false
add :downtilt_deg, :float, null: false, default: 0.0
add :frequency_mhz, :integer, null: false
add :eirp_dbm, :float, null: false
add :radius_m, :integer, null: false
add :cell_size_m, :integer, null: false
add :receiver_height_m, :float, null: false, default: 3.0
add :rx_threshold_dbm, :float, null: false, default: -90.0
add :status, :string, null: false, default: "draft"
add :progress_pct, :integer, null: false, default: 0
add :error_message, :text
add :computed_at, :utc_datetime
add :bbox_min_lat, :float
add :bbox_max_lat, :float
add :bbox_min_lon, :float
add :bbox_max_lon, :float
add :raster_path, :string
add :png_path, :string
timestamps(type: :utc_datetime)
end
create unique_index(:coverages, [:site_id, :name], name: :coverages_site_id_name_index)
create index(:coverages, [:organization_id])
create index(:coverages, [:status])
end
end

View file

@ -0,0 +1,33 @@
defmodule Towerops.CoveragesFixtures do
@moduledoc """
Test helpers for creating Coverage records.
"""
alias Towerops.Coverages
def valid_coverage_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
name: "Coverage #{System.unique_integer([:positive])}",
antenna_slug: "test-omni-5ghz",
height_agl_m: 30.0,
azimuth_deg: 0.0,
downtilt_deg: 2.0,
frequency_mhz: 5500,
eirp_dbm: 36.0,
radius_m: 5_000,
cell_size_m: 10,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0
})
end
def coverage_fixture(organization_id, site_id, attrs \\ %{}) do
attrs =
attrs
|> valid_coverage_attrs()
|> Map.put(:site_id, site_id)
{:ok, coverage} = Coverages.create_coverage(organization_id, attrs)
coverage
end
end

View file

@ -37,6 +37,10 @@ if File.dir?(mib_dir) do
end
end
# Load antenna pattern registry for tests that exercise the Coverages
# context (the schema validates antenna_slug against this in-memory registry).
Towerops.Coverages.Antenna.load_registry()
ExUnit.start()
# Exclude tests by default

View file

@ -0,0 +1,116 @@
defmodule Towerops.Coverages.AntennaTest do
use ExUnit.Case, async: true
alias Towerops.Coverages.Antenna
describe "parse/2" do
test "parses the bundled omni fixture" do
path = Application.app_dir(:towerops, "priv/antennas/test-omni-5ghz.ant")
assert {:ok, %Antenna{} = ant} = Antenna.parse_file(path)
assert ant.slug == "test-omni-5ghz"
assert ant.manufacturer == "Test Manufacturer"
assert ant.model == "Test Omni 5 GHz"
assert ant.gain_dbi == 8.0
assert ant.frequency_mhz == 5500
assert length(ant.h_pattern) == 360
assert length(ant.v_pattern) == 360
# First entry of horizontal should be 0 attenuation at boresight
assert {0, atten} = hd(ant.h_pattern)
assert atten == +0.0
end
test "parses the bundled sector fixture and converts dBd → dBi correctly" do
path = Application.app_dir(:towerops, "priv/antennas/test-sector-90-5ghz.ant")
assert {:ok, %Antenna{} = ant} = Antenna.parse_file(path)
assert ant.slug == "test-sector-90-5ghz"
assert ant.gain_dbi == 16.0
# 90 degree sector should attenuate sharply outside main lobe
back_lobe = Enum.find(ant.h_pattern, fn {deg, _} -> deg == 180 end)
assert {180, atten} = back_lobe
assert atten >= 20.0
end
test "parse/2 with binary content works without filesystem" do
content = """
NAME Inline Test
MAKE Inline
FREQUENCY 2400
GAIN 5.0 dBi
HORIZONTAL 4
0 0.0
90 3.0
180 25.0
270 3.0
VERTICAL 4
0 0.0
90 3.0
180 25.0
270 3.0
"""
assert {:ok, %Antenna{} = ant} = Antenna.parse(content, slug: "inline-test")
assert ant.slug == "inline-test"
assert ant.frequency_mhz == 2400
assert ant.gain_dbi == 5.0
assert length(ant.h_pattern) == 4
assert length(ant.v_pattern) == 4
end
test "converts GAIN in dBd to dBi (adds 2.15)" do
content = """
NAME Dbd Test
FREQUENCY 900
GAIN 6.85 dBd
HORIZONTAL 1
0 0.0
VERTICAL 1
0 0.0
"""
assert {:ok, ant} = Antenna.parse(content, slug: "dbd-test")
assert_in_delta ant.gain_dbi, 9.0, 0.001
end
test "rejects file with mismatched HORIZONTAL count" do
content = """
NAME Bad
FREQUENCY 1
GAIN 0 dBi
HORIZONTAL 4
0 0.0
1 0.0
"""
assert {:error, {:pattern_count_mismatch, :horizontal, _}} =
Antenna.parse(content, slug: "bad")
end
end
describe "lookup/3" do
setup do
path = Application.app_dir(:towerops, "priv/antennas/test-sector-90-5ghz.ant")
{:ok, ant} = Antenna.parse_file(path)
%{antenna: ant}
end
test "returns 0 attenuation at boresight (0° az, 0° el)", %{antenna: ant} do
assert_in_delta Antenna.attenuation_db(ant, 0.0, 0.0), 0.0, 0.5
end
test "returns higher attenuation at the back (180° az, 0° el)", %{antenna: ant} do
assert Antenna.attenuation_db(ant, 180.0, 0.0) >= 20.0
end
test "returns higher attenuation off-vertical (0° az, 30° el)", %{antenna: ant} do
assert Antenna.attenuation_db(ant, 0.0, 30.0) > 20.0
end
test "wraps azimuth values outside 0..360", %{antenna: ant} do
a = Antenna.attenuation_db(ant, 350.0, 0.0)
b = Antenna.attenuation_db(ant, -10.0, 0.0)
assert_in_delta a, b, 0.001
end
end
end

View file

@ -0,0 +1,222 @@
defmodule Towerops.Coverages.CoverageTest do
use Towerops.DataCase, async: true
alias Ecto.Changeset
alias Towerops.Coverages.Coverage
@valid_attrs %{
name: "North sector",
antenna_slug: "test-omni-5ghz",
height_agl_m: 30.0,
azimuth_deg: 0.0,
downtilt_deg: 2.0,
frequency_mhz: 5500,
eirp_dbm: 36.0,
radius_m: 5000,
cell_size_m: 10,
receiver_height_m: 3.0,
rx_threshold_dbm: -90.0
}
describe "changeset/2" do
test "is valid with all required fields plus org and site" do
attrs = Map.put(@valid_attrs, :site_id, Ecto.UUID.generate())
seed = %Coverage{organization_id: Ecto.UUID.generate()}
changeset = Coverage.changeset(seed, attrs)
assert changeset.valid?
end
test "requires name, antenna_slug, height, azimuth, frequency, eirp, radius, cell_size" do
changeset = Coverage.changeset(%Coverage{}, %{})
refute changeset.valid?
required = [
:name,
:antenna_slug,
:height_agl_m,
:azimuth_deg,
:frequency_mhz,
:eirp_dbm,
:radius_m,
:cell_size_m,
:organization_id,
:site_id
]
for field <- required do
assert {"can't be blank", _} = changeset.errors[field],
"expected #{field} to be required"
end
end
test "ignores organization_id supplied in attrs (programmatic field)" do
attrs =
Map.merge(@valid_attrs, %{
organization_id: Ecto.UUID.generate(),
site_id: Ecto.UUID.generate()
})
changeset = Coverage.changeset(%Coverage{}, attrs)
# organization_id stays nil because it isn't in @cast_fields, so
# the required-fields check fires.
assert {"can't be blank", _} = changeset.errors[:organization_id]
end
test "rejects name shorter than 2 characters" do
changeset = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :name, "x"))
assert changeset_error?(changeset, :name)
end
test "rejects azimuth outside 0..360" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :azimuth_deg, -1.0))
refute bad.valid?
assert changeset_error?(bad, :azimuth_deg)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :azimuth_deg, 361.0))
refute bad.valid?
assert changeset_error?(bad, :azimuth_deg)
end
test "rejects downtilt outside -10..30" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :downtilt_deg, -11.0))
refute bad.valid?
assert changeset_error?(bad, :downtilt_deg)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :downtilt_deg, 31.0))
refute bad.valid?
assert changeset_error?(bad, :downtilt_deg)
end
test "rejects height_agl_m outside 1..200" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :height_agl_m, 0.5))
refute bad.valid?
assert changeset_error?(bad, :height_agl_m)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :height_agl_m, 250.0))
refute bad.valid?
assert changeset_error?(bad, :height_agl_m)
end
test "rejects frequency_mhz outside 700..90_000" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :frequency_mhz, 100))
refute bad.valid?
assert changeset_error?(bad, :frequency_mhz)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :frequency_mhz, 100_000))
refute bad.valid?
assert changeset_error?(bad, :frequency_mhz)
end
test "rejects eirp_dbm outside 0..60" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :eirp_dbm, -1.0))
refute bad.valid?
assert changeset_error?(bad, :eirp_dbm)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :eirp_dbm, 100.0))
refute bad.valid?
assert changeset_error?(bad, :eirp_dbm)
end
test "rejects radius_m outside 500..40_000" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :radius_m, 100))
refute bad.valid?
assert changeset_error?(bad, :radius_m)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :radius_m, 50_000))
refute bad.valid?
assert changeset_error?(bad, :radius_m)
end
test "rejects cell_size_m outside 1..50" do
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :cell_size_m, 0))
refute bad.valid?
assert changeset_error?(bad, :cell_size_m)
bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :cell_size_m, 100))
refute bad.valid?
assert changeset_error?(bad, :cell_size_m)
end
test "rejects pixel grid larger than 8000 cells per axis" do
# 40km radius with 1m cells = 80_000 pixels per axis - way too big
attrs =
Map.merge(@valid_attrs, %{
radius_m: 40_000,
cell_size_m: 1,
organization_id: Ecto.UUID.generate(),
site_id: Ecto.UUID.generate()
})
changeset = Coverage.changeset(%Coverage{}, attrs)
refute changeset.valid?
{message, _} = changeset.errors[:cell_size_m]
assert message =~ "too fine"
end
test "accepts pixel grid at exactly the cap" do
# diameter / cell == 8000 pixels per axis: 20km radius, 5m cell
attrs =
Map.merge(@valid_attrs, %{
radius_m: 20_000,
cell_size_m: 5,
site_id: Ecto.UUID.generate()
})
seed = %Coverage{organization_id: Ecto.UUID.generate()}
assert Coverage.changeset(seed, attrs).valid?
end
test "status defaults to draft and only allows known values" do
attrs =
Map.merge(@valid_attrs, %{
organization_id: Ecto.UUID.generate(),
site_id: Ecto.UUID.generate(),
status: "bogus"
})
changeset = Coverage.changeset(%Coverage{}, attrs)
refute changeset.valid?
assert changeset_error?(changeset, :status)
end
test "progress_pct must be 0..100" do
attrs =
Map.merge(@valid_attrs, %{
organization_id: Ecto.UUID.generate(),
site_id: Ecto.UUID.generate(),
progress_pct: 150
})
changeset = Coverage.changeset(%Coverage{}, attrs)
refute changeset.valid?
assert changeset_error?(changeset, :progress_pct)
end
end
describe "status_changeset/2" do
test "allows updating status, progress, error_message, computed_at without re-validating other fields" do
coverage = %Coverage{
name: "x",
antenna_slug: "y",
status: "draft",
progress_pct: 0
}
changeset =
Coverage.status_changeset(coverage, %{
status: "computing",
progress_pct: 50
})
assert changeset.valid?
assert Changeset.get_change(changeset, :status) == "computing"
assert Changeset.get_change(changeset, :progress_pct) == 50
end
end
defp changeset_error?(changeset, field) do
Keyword.has_key?(changeset.errors, field)
end
end

View file

@ -0,0 +1,200 @@
defmodule Towerops.CoveragesTest do
use Towerops.DataCase, async: true
use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Coverages
alias Towerops.Coverages.Coverage
setup do
user = user_fixture()
org = organization_fixture(user.id)
site = site_fixture(org.id)
%{user: user, organization: org, site: site}
end
describe "create_coverage/2" do
test "inserts with valid attrs and starts in draft", %{organization: org, site: site} do
attrs = Map.put(valid_coverage_attrs(), :site_id, site.id)
assert {:ok, %Coverage{} = coverage} = Coverages.create_coverage(org.id, attrs)
assert coverage.status == "draft"
assert coverage.progress_pct == 0
assert coverage.organization_id == org.id
assert coverage.site_id == site.id
end
test "ignores any organization_id supplied in attrs (set from arg)",
%{organization: org, site: site} do
other_org_id = Ecto.UUID.generate()
attrs =
valid_coverage_attrs()
|> Map.put(:site_id, site.id)
|> Map.put(:organization_id, other_org_id)
assert {:ok, coverage} = Coverages.create_coverage(org.id, attrs)
assert coverage.organization_id == org.id
end
test "rejects a site that belongs to another organization",
%{organization: org} do
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
foreign_site = site_fixture(other_org.id)
attrs = Map.put(valid_coverage_attrs(), :site_id, foreign_site.id)
assert {:error, changeset} = Coverages.create_coverage(org.id, attrs)
assert "does not belong to your organization" in errors_on(changeset).site_id
end
test "rejects duplicate name within the same site", %{organization: org, site: site} do
attrs =
%{name: "duplicate"}
|> valid_coverage_attrs()
|> Map.put(:site_id, site.id)
assert {:ok, _} = Coverages.create_coverage(org.id, attrs)
assert {:error, changeset} = Coverages.create_coverage(org.id, attrs)
assert "has already been taken for this site" in errors_on(changeset).name
end
test "allows the same name on different sites", %{organization: org, site: site} do
site2 = site_fixture(org.id)
attrs1 = %{name: "same"} |> valid_coverage_attrs() |> Map.put(:site_id, site.id)
attrs2 = Map.put(attrs1, :site_id, site2.id)
assert {:ok, _} = Coverages.create_coverage(org.id, attrs1)
assert {:ok, _} = Coverages.create_coverage(org.id, attrs2)
end
test "returns error changeset on invalid attrs", %{organization: org, site: site} do
attrs = %{name: ""} |> valid_coverage_attrs() |> Map.put(:site_id, site.id)
assert {:error, %Ecto.Changeset{}} = Coverages.create_coverage(org.id, attrs)
end
end
describe "list_for_site/2 and list_for_organization/1" do
test "scopes results by org and site", %{user: _user, organization: org, site: site} do
_ = coverage_fixture(org.id, site.id, %{name: "alpha"})
_ = coverage_fixture(org.id, site.id, %{name: "beta"})
site2 = site_fixture(org.id)
_ = coverage_fixture(org.id, site2.id, %{name: "gamma"})
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
other_site = site_fixture(other_org.id)
_ = coverage_fixture(other_org.id, other_site.id, %{name: "delta"})
assert org.id |> Coverages.list_for_site(site.id) |> Enum.map(& &1.name) |> Enum.sort() ==
~w(alpha beta)
org_names = org.id |> Coverages.list_for_organization() |> Enum.map(& &1.name) |> Enum.sort()
assert org_names == ~w(alpha beta gamma)
end
test "list_for_site does not leak across organizations", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id, %{name: "mine"})
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
assert Coverages.list_for_site(other_org.id, site.id) == []
assert [%{id: id}] = Coverages.list_for_site(org.id, site.id)
assert id == coverage.id
end
end
describe "get_coverage!/2" do
test "returns the coverage when scoped to the right org", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert %Coverage{id: id} = Coverages.get_coverage!(org.id, coverage.id)
assert id == coverage.id
end
test "raises when scoped to a different org", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
assert_raise Ecto.NoResultsError, fn ->
Coverages.get_coverage!(other_org.id, coverage.id)
end
end
end
describe "update_coverage/2" do
test "updates valid fields", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert {:ok, updated} = Coverages.update_coverage(coverage, %{name: "renamed"})
assert updated.name == "renamed"
end
test "rejects switching to a site in another organization",
%{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
foreign_site = site_fixture(other_org.id)
assert {:error, changeset} =
Coverages.update_coverage(coverage, %{site_id: foreign_site.id})
assert "does not belong to your organization" in errors_on(changeset).site_id
end
end
describe "delete_coverage/1" do
test "removes the record", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert {:ok, _} = Coverages.delete_coverage(coverage)
assert_raise Ecto.NoResultsError, fn -> Coverages.get_coverage!(org.id, coverage.id) end
end
end
describe "mark_status/3" do
test "updates status, progress, error, computed_at without re-validating form fields",
%{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert {:ok, updated} =
Coverages.mark_status(coverage, "computing", %{progress_pct: 25})
assert updated.status == "computing"
assert updated.progress_pct == 25
assert {:ok, failed} =
Coverages.mark_status(coverage, "failed", %{error_message: "ITM crashed"})
assert failed.status == "failed"
assert failed.error_message == "ITM crashed"
end
test "rejects unknown status", %{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert {:error, %Ecto.Changeset{}} = Coverages.mark_status(coverage, "bogus", %{})
end
end
describe "queue_compute/1" do
test "transitions draft → queued and schedules an Oban job",
%{organization: org, site: site} do
coverage = coverage_fixture(org.id, site.id)
assert {:ok, queued} = Coverages.queue_compute(coverage)
assert queued.status == "queued"
assert_enqueued(
worker: Towerops.Workers.CoverageWorker,
args: %{"coverage_id" => coverage.id, "organization_id" => coverage.organization_id}
)
end
end
end