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:
parent
7e2fa438f7
commit
5a9381f91a
24 changed files with 3746 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
208
lib/towerops/coverages.ex
Normal 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
|
||||
340
lib/towerops/coverages/antenna.ex
Normal file
340
lib/towerops/coverages/antenna.ex
Normal 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
|
||||
207
lib/towerops/coverages/coverage.ex
Normal file
207
lib/towerops/coverages/coverage.ex
Normal 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
|
||||
76
lib/towerops/workers/coverage_worker.ex
Normal file
76
lib/towerops/workers/coverage_worker.ex
Normal 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
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
106
lib/towerops_web/live/coverage_live/form.ex
Normal file
106
lib/towerops_web/live/coverage_live/form.ex
Normal 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
|
||||
180
lib/towerops_web/live/coverage_live/form.html.heex
Normal file
180
lib/towerops_web/live/coverage_live/form.html.heex
Normal 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>
|
||||
81
lib/towerops_web/live/coverage_live/index.ex
Normal file
81
lib/towerops_web/live/coverage_live/index.ex
Normal 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
|
||||
166
lib/towerops_web/live/coverage_live/index.html.heex
Normal file
166
lib/towerops_web/live/coverage_live/index.html.heex
Normal 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>
|
||||
89
lib/towerops_web/live/coverage_live/show.ex
Normal file
89
lib/towerops_web/live/coverage_live/show.ex
Normal 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
|
||||
159
lib/towerops_web/live/coverage_live/show.html.heex
Normal file
159
lib/towerops_web/live/coverage_live/show.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
732
priv/antennas/test-omni-5ghz.ant
Normal file
732
priv/antennas/test-omni-5ghz.ant
Normal 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
|
||||
732
priv/antennas/test-sector-90-5ghz.ant
Normal file
732
priv/antennas/test-sector-90-5ghz.ant
Normal 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
|
||||
48
priv/repo/migrations/20260506181711_create_coverages.exs
Normal file
48
priv/repo/migrations/20260506181711_create_coverages.exs
Normal 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
|
||||
33
test/support/fixtures/coverages_fixtures.ex
Normal file
33
test/support/fixtures/coverages_fixtures.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
116
test/towerops/coverages/antenna_test.exs
Normal file
116
test/towerops/coverages/antenna_test.exs
Normal 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
|
||||
222
test/towerops/coverages/coverage_test.exs
Normal file
222
test/towerops/coverages/coverage_test.exs
Normal 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
|
||||
200
test/towerops/coverages_test.exs
Normal file
200
test/towerops/coverages_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue