diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index d7d557d2..b86474dd 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -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:
diff --git a/config/dev.exs b/config/dev.exs
index 95cb41db..c39b3361 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -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
diff --git a/config/runtime.exs b/config/runtime.exs
index c23978ce..f879b1a6 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -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
diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex
index 669030d0..3dbf1e59 100644
--- a/lib/towerops/application.ex
+++ b/lib/towerops/application.ex
@@ -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.
diff --git a/lib/towerops/coverages.ex b/lib/towerops/coverages.ex
new file mode 100644
index 00000000..c66a42aa
--- /dev/null
+++ b/lib/towerops/coverages.ex
@@ -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
diff --git a/lib/towerops/coverages/antenna.ex b/lib/towerops/coverages/antenna.ex
new file mode 100644
index 00000000..8bb31793
--- /dev/null
+++ b/lib/towerops/coverages/antenna.ex
@@ -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
diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex
new file mode 100644
index 00000000..37c97348
--- /dev/null
+++ b/lib/towerops/coverages/coverage.ex
@@ -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
diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex
new file mode 100644
index 00000000..b29b8680
--- /dev/null
+++ b/lib/towerops/workers/coverage_worker.ex
@@ -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
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index 0cd81fbd..ccb7fc8a 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -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"}
diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex
new file mode 100644
index 00000000..1ccd25db
--- /dev/null
+++ b/lib/towerops_web/live/coverage_live/form.ex
@@ -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
diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex
new file mode 100644
index 00000000..10c2fe2b
--- /dev/null
+++ b/lib/towerops_web/live/coverage_live/form.html.heex
@@ -0,0 +1,180 @@
+
+ {t(
+ "Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
+ )}
+
+ {t("Create your first coverage to predict where a tower's antenna reaches.")}
+
+ {t("Identity")}
+
+
+ <.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
+ />
+
+ {t("Mounting")}
+
+
+ <.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"
+ />
+
+ {t("RF parameters")}
+
+
+ <.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
+ />
+
+ {t("Coverage extent")}
+
+
+ <.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"
+ />
+ {t("Add a site first")}
+ {t("No coverages yet")}
+
+
+
+
+
+
+
+ <%= for coverage <- @coverages do %>
+
+ {t("Name")}
+
+
+ {t("Site")}
+
+
+ {t("Antenna")}
+
+
+ {t("Freq (MHz)")}
+
+
+ {t("EIRP (dBm)")}
+
+
+ {t("Radius (m)")}
+
+
+ {t("Status")}
+
+
+ {t("Actions")}
+
+
+
+ <% end %>
+
+
+ <.link
+ navigate={~p"/coverage/#{coverage.id}"}
+ class="font-medium text-blue-600 hover:underline dark:text-blue-400"
+ >
+ {coverage.name}
+
+
+
+ <.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline">
+ {coverage.site && coverage.site.name}
+
+
+
+ {coverage.antenna_slug}
+
+
+ {coverage.frequency_mhz}
+
+
+ {coverage.eirp_dbm}
+
+
+ {coverage.radius_m}
+
+
+
+ {coverage.status}
+
+
+
+
+
+ <.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} + +
++ {t("Computing coverage…")} +
++ {t("Compute failed")} +
++ {@coverage.error_message} +
++ <.icon name="hero-clock" class="h-4 w-4 inline" /> + {t("Coverage is queued. Compute will start shortly.")} +
++ {t("No heatmap available yet. Run compute to generate the coverage prediction.")} +
+