towerops/lib/towerops/coverages.ex
Graham McIntire 0e2cc6b7ce chore(coverage,types): clean dialyzer + new tests + ETH canopy provider
Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
  prefix discarded function returns with `_ =`, fix the broken
  File.stream! call in the ms-buildings worker (it was causing dialyzer
  to mark every private fun unreachable), broaden Antenna.spec key
  types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
  PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).

Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
  Canopy Height 10 m product. ETH publishes proper COGs with
  overviews on libdrive.ethz.ch — confirmed working with
  /vsicurl/ byte-range reads. The module now picks the 3°×3° tile
  containing the bbox centroid, with both a `:metres` decoder
  (ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
  operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.

Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
  LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
  lines, MultiPolygon, idempotency, hash fallback) — also fixed
  the partial-index ON CONFLICT bug that prevented imports from
  ever working: the unique index has WHERE ms_footprint_id IS
  NOT NULL, so the worker now uses an :unsafe_fragment conflict
  target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).

Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
2026-05-07 09:11:17 -05:00

274 lines
9.1 KiB
Elixir

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.Coverages.Raster
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 """
Lists ready coverages — those with a computed PNG/GeoTIFF and bbox —
for the unified map view. Site is preloaded for the toggle list.
"""
@spec list_ready_for_organization(Ecto.UUID.t()) :: [Coverage.t()]
def list_ready_for_organization(organization_id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(status: "ready")
|> where([c], not is_nil(c.png_path) and not is_nil(c.bbox_min_lat))
|> order_by(asc: :name)
|> preload(:site)
|> Repo.all()
end
@doc """
For each ready coverage in the organization, returns the
great-circle distance (m) and predicted RSSI (dBm) at `(lat, lon)`.
Coverages whose bbox does not contain the point or that have no
raster on disk return `:no_coverage` for the rssi.
"""
@spec query_point(Ecto.UUID.t(), float(), float()) :: [
%{
coverage: Coverage.t(),
distance_m: float(),
rssi: float() | :no_coverage | nil
}
]
def query_point(organization_id, lat, lon) when is_number(lat) and is_number(lon) do
organization_id
|> list_ready_for_organization()
|> Enum.map(&summarize_point(&1, lat, lon))
end
defp summarize_point(coverage, lat, lon) do
{tx_lat, tx_lon} = Coverage.location(coverage) || {coverage.bbox_min_lat, coverage.bbox_min_lon}
distance = haversine_m({tx_lat, tx_lon}, {lat, lon})
rssi =
case Raster.query_rssi(coverage, lat, lon) do
{:ok, :no_coverage} -> :no_coverage
{:ok, dbm} when is_number(dbm) -> dbm
{:error, _} -> nil
end
%{coverage: coverage, distance_m: distance, rssi: rssi}
end
defp haversine_m({lat1, lon1}, {lat2, lon2}) do
r = 6_371_000.0
p1 = lat1 * :math.pi() / 180.0
p2 = lat2 * :math.pi() / 180.0
dp = (lat2 - lat1) * :math.pi() / 180.0
dl = (lon2 - lon1) * :math.pi() / 180.0
a =
:math.sin(dp / 2) * :math.sin(dp / 2) +
:math.cos(p1) * :math.cos(p2) * :math.sin(dl / 2) * :math.sin(dl / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
r * c
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
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
def subscribe_organization(organization_id) do
Phoenix.PubSub.subscribe(Towerops.PubSub, org_topic(organization_id))
end
@doc false
@spec broadcast(Coverage.t(), term()) :: :ok
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)
:ok
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