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%.
274 lines
9.1 KiB
Elixir
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
|