From 5a9381f91a3f01df49e754ec9f11ae9815eb434f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 6 May 2026 13:44:14 -0500 Subject: [PATCH] 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. --- CHANGELOG.txt | 25 + config/dev.exs | 4 +- config/runtime.exs | 4 +- lib/towerops/application.ex | 4 + lib/towerops/coverages.ex | 208 +++++ lib/towerops/coverages/antenna.ex | 340 ++++++++ lib/towerops/coverages/coverage.ex | 207 +++++ lib/towerops/workers/coverage_worker.ex | 76 ++ lib/towerops_web/components/layouts.ex | 6 + lib/towerops_web/live/coverage_live/form.ex | 106 +++ .../live/coverage_live/form.html.heex | 180 +++++ lib/towerops_web/live/coverage_live/index.ex | 81 ++ .../live/coverage_live/index.html.heex | 166 ++++ lib/towerops_web/live/coverage_live/show.ex | 89 +++ .../live/coverage_live/show.html.heex | 159 ++++ lib/towerops_web/router.ex | 6 + priv/antennas/test-omni-5ghz.ant | 732 ++++++++++++++++++ priv/antennas/test-sector-90-5ghz.ant | 732 ++++++++++++++++++ .../20260506181711_create_coverages.exs | 48 ++ test/support/fixtures/coverages_fixtures.ex | 33 + test/test_helper.exs | 4 + test/towerops/coverages/antenna_test.exs | 116 +++ test/towerops/coverages/coverage_test.exs | 222 ++++++ test/towerops/coverages_test.exs | 200 +++++ 24 files changed, 3746 insertions(+), 2 deletions(-) create mode 100644 lib/towerops/coverages.ex create mode 100644 lib/towerops/coverages/antenna.ex create mode 100644 lib/towerops/coverages/coverage.ex create mode 100644 lib/towerops/workers/coverage_worker.ex create mode 100644 lib/towerops_web/live/coverage_live/form.ex create mode 100644 lib/towerops_web/live/coverage_live/form.html.heex create mode 100644 lib/towerops_web/live/coverage_live/index.ex create mode 100644 lib/towerops_web/live/coverage_live/index.html.heex create mode 100644 lib/towerops_web/live/coverage_live/show.ex create mode 100644 lib/towerops_web/live/coverage_live/show.html.heex create mode 100644 priv/antennas/test-omni-5ghz.ant create mode 100644 priv/antennas/test-sector-90-5ghz.ant create mode 100644 priv/repo/migrations/20260506181711_create_coverages.exs create mode 100644 test/support/fixtures/coverages_fixtures.ex create mode 100644 test/towerops/coverages/antenna_test.exs create mode 100644 test/towerops/coverages/coverage_test.exs create mode 100644 test/towerops/coverages_test.exs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index d7d557d2..b86474dd 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,28 @@ +2026-05-06 +feat: /coverage feature scaffold (RF coverage prediction) + Schema + migration: + - priv/repo/migrations/20260506181711_create_coverages.exs (binary_id, FK to sites/orgs, RF params, status enum, bbox columns, raster/png paths, unique [site_id, name]) + - lib/towerops/coverages/coverage.ex: full validation incl. pixel-budget cap (≤8000/axis), antenna_slug existence check; organization_id excluded from cast (programmatic-only) + Context (lib/towerops/coverages.ex): + - org-scoped CRUD: create_coverage/2 (org_id explicit), update_coverage/2, list_for_organization/1, list_for_site/2, get_coverage/!2 + - validate_site_in_organization/2 rejects cross-tenant site_id at insert/update + - mark_status/3 + queue_compute/1 with PubSub broadcast on per-coverage and per-org topics + Antenna registry (lib/towerops/coverages/antenna.ex): + - MSI Planet .ant parser, persistent_term registry loaded at boot from priv/antennas/ + - bilinear attenuation_db/3, dBd→dBi conversion, two synthetic .ant fixtures bundled + Worker (lib/towerops/workers/coverage_worker.ex): + - Oban worker on new :coverage queue (concurrency 2); job args include organization_id, refuses to run on org mismatch + - currently a stub: flips status to "failed" with "compute not yet implemented" so UI flow is exercisable + LiveViews: + - CoverageLive.Index (org-wide list with status badges, recompute/delete actions, live PubSub updates) + - CoverageLive.Form (new/edit with antenna picker grouped by manufacturer) + - CoverageLive.Show (status banners, parameter panel, map placeholder, delete/recompute) + - sidebar nav link added (hero-signal) + Routes: /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit under :require_authenticated_user_with_default_org + Oban: :coverage queue added to dev.exs and runtime.exs (concurrency 2) + Tests: 41 new tests covering schema validation, .ant parsing, context CRUD with cross-org guards, worker job enqueue + Deferred to follow-ups: NTIA ITM C NIF, Microsoft Building Footprints importer, real compute pipeline (DSM build + per-pixel ITM + GeoTIFF/PNG writers), Leaflet imageOverlay hook, curated bundled antenna pack + 2026-04-28 perf+refactor: codebase-wide query/antipattern audit and fixes Performance: diff --git a/config/dev.exs b/config/dev.exs index 95cb41db..c39b3361 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -72,7 +72,9 @@ config :towerops, Oban, # Alert notifications (PagerDuty, email, etc.) notifications: 10, maintenance: 5, - weather: 2 + weather: 2, + # RF coverage prediction (CPU + GDAL/HTTP-range I/O heavy, slow) + coverage: 2 ], plugins: [ # Cron jobs for periodic maintenance tasks diff --git a/config/runtime.exs b/config/runtime.exs index c23978ce..f879b1a6 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -188,7 +188,9 @@ if config_env() == :prod do notifications: max(1, div(25, oban_scale)), maintenance: max(1, div(5, oban_scale)), weather: max(1, div(2, oban_scale)), - lidar: max(1, div(2, oban_scale)) + lidar: max(1, div(2, oban_scale)), + # RF coverage prediction (CPU + GDAL/HTTP-range I/O heavy, slow) + coverage: max(1, div(2, oban_scale)) ], plugins: [ # Cron jobs for periodic maintenance tasks diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index 669030d0..3dbf1e59 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -7,6 +7,7 @@ defmodule Towerops.Application do use Application + alias Towerops.Coverages.Antenna alias Towerops.Workers.JobCleanupTask require Logger @@ -81,6 +82,9 @@ defmodule Towerops.Application do Logger.warning("Unexpected MIB library init result: #{inspect(other)}") end + # Load antenna pattern registry (priv/antennas/*.ant) into :persistent_term + Antenna.load_registry() + topologies = Application.get_env(:libcluster, :topologies, []) # Supervision tree order matters for shutdown: children shut down in reverse start order. diff --git a/lib/towerops/coverages.ex b/lib/towerops/coverages.ex new file mode 100644 index 00000000..c66a42aa --- /dev/null +++ b/lib/towerops/coverages.ex @@ -0,0 +1,208 @@ +defmodule Towerops.Coverages do + @moduledoc """ + Context for RF coverage prediction records. + + Coverages are scoped per organization (multi-tenant) and hang off a site — + one coverage per transmit antenna. The actual heatmap compute is handled + by `Towerops.Workers.CoverageWorker`; this module is the CRUD and + state-transition surface used by LiveViews and the worker itself. + + ## Organization scoping + + Every read takes `organization_id` as the first argument and filters at + the query level — there is no unscoped `get/1` or `list/0`. + + Every write (create / update) verifies that the supplied `site_id` + belongs to the caller's organization, so a malicious payload that pairs + one org's `organization_id` with another org's `site_id` is rejected + before insert. `organization_id` itself is set by the context from the + caller's scope, not from user-supplied attrs. + """ + + import Ecto.Query + + alias Towerops.Coverages.Coverage + alias Towerops.Repo + alias Towerops.Sites.Site + alias Towerops.Workers.CoverageWorker + + @doc """ + Creates a coverage owned by the given organization. + + `organization_id` is set programmatically. The supplied `site_id` (in + attrs) must belong to the same organization or the changeset is rejected. + """ + @spec create_coverage(Ecto.UUID.t(), map()) :: + {:ok, Coverage.t()} | {:error, Ecto.Changeset.t()} + def create_coverage(organization_id, attrs) do + %Coverage{organization_id: organization_id} + |> Coverage.changeset(attrs) + |> validate_site_in_organization(organization_id) + |> Repo.insert() + end + + @doc """ + Updates a coverage. The coverage's existing `organization_id` is + preserved (callers cannot change ownership). If `site_id` is changing, + the new site must belong to the same organization. + """ + @spec update_coverage(Coverage.t(), map()) :: + {:ok, Coverage.t()} | {:error, Ecto.Changeset.t()} + def update_coverage(%Coverage{organization_id: org_id} = coverage, attrs) do + coverage + |> Coverage.changeset(attrs) + |> validate_site_in_organization(org_id) + |> Repo.update() + end + + @doc "Deletes a coverage." + @spec delete_coverage(Coverage.t()) :: {:ok, Coverage.t()} | {:error, Ecto.Changeset.t()} + def delete_coverage(%Coverage{} = coverage), do: Repo.delete(coverage) + + @doc "Returns a changeset for the form helpers." + @spec change_coverage(Coverage.t(), map()) :: Ecto.Changeset.t() + def change_coverage(%Coverage{} = coverage, attrs \\ %{}) do + Coverage.changeset(coverage, attrs) + end + + @doc "Lists all coverages for an organization, newest first." + @spec list_for_organization(Ecto.UUID.t()) :: [Coverage.t()] + def list_for_organization(organization_id) do + Coverage + |> where(organization_id: ^organization_id) + |> order_by(desc: :inserted_at) + |> preload(:site) + |> Repo.all() + end + + @doc "Lists all coverages for a single site, newest first. Scoped by org." + @spec list_for_site(Ecto.UUID.t(), Ecto.UUID.t()) :: [Coverage.t()] + def list_for_site(organization_id, site_id) do + Coverage + |> where(organization_id: ^organization_id) + |> where(site_id: ^site_id) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc "Fetches a coverage scoped to an organization. Raises if not found." + @spec get_coverage!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() + def get_coverage!(organization_id, id) do + Coverage + |> where(organization_id: ^organization_id) + |> where(id: ^id) + |> preload(:site) + |> Repo.one!() + end + + @doc "Fetches a coverage scoped to an organization. Returns nil if not found." + @spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil + def get_coverage(organization_id, id) do + Coverage + |> where(organization_id: ^organization_id) + |> where(id: ^id) + |> preload(:site) + |> Repo.one() + end + + @doc """ + Updates transient state (status, progress, error, paths, computed bbox) + without re-running form-field validations. Used by the worker. + """ + @spec mark_status(Coverage.t(), String.t(), map()) :: + {:ok, Coverage.t()} | {:error, Ecto.Changeset.t()} + def mark_status(%Coverage{} = coverage, status, extra \\ %{}) do + attrs = + extra + |> stringify_keys() + |> Map.put("status", status) + + coverage + |> Coverage.status_changeset(attrs) + |> Repo.update() + end + + @doc """ + Transitions a draft/failed/ready coverage to `queued` and enqueues a + CoverageWorker job. The job payload includes `organization_id` so the + worker can verify ownership before doing any work. + """ + @spec queue_compute(Coverage.t()) :: + {:ok, Coverage.t()} | {:error, Ecto.Changeset.t() | :compute_in_progress} + def queue_compute(%Coverage{status: status} = _coverage) when status in ["queued", "computing"] do + {:error, :compute_in_progress} + end + + def queue_compute(%Coverage{} = coverage) do + with {:ok, queued} <- + mark_status(coverage, "queued", %{progress_pct: 0, error_message: nil}), + {:ok, _job} <- + %{coverage_id: queued.id, organization_id: queued.organization_id} + |> CoverageWorker.new() + |> Oban.insert() do + {:ok, queued} + end + end + + @doc "PubSub topic for live status updates of a single coverage." + @spec topic(Ecto.UUID.t()) :: String.t() + def topic(coverage_id), do: "coverage:#{coverage_id}" + + @doc "PubSub topic for any-coverage-changed updates within an organization." + @spec org_topic(Ecto.UUID.t()) :: String.t() + def org_topic(organization_id), do: "coverage:org:#{organization_id}" + + @doc "Subscribe the calling process to a coverage's status updates." + @spec subscribe(Ecto.UUID.t()) :: :ok | {:error, term()} + def subscribe(coverage_id) do + Phoenix.PubSub.subscribe(Towerops.PubSub, topic(coverage_id)) + end + + @doc """ + Subscribe the calling process to all coverage status updates for one + organization. Used by the index page to live-refresh status badges. + """ + @spec subscribe_organization(Ecto.UUID.t()) :: :ok | {:error, term()} + def subscribe_organization(organization_id) do + Phoenix.PubSub.subscribe(Towerops.PubSub, org_topic(organization_id)) + end + + @doc false + @spec broadcast(Coverage.t(), term()) :: :ok | {:error, term()} + def broadcast(%Coverage{id: id, organization_id: org_id}, message) do + Phoenix.PubSub.broadcast(Towerops.PubSub, topic(id), message) + Phoenix.PubSub.broadcast(Towerops.PubSub, org_topic(org_id), message) + end + + defp validate_site_in_organization(changeset, organization_id) do + case Ecto.Changeset.get_field(changeset, :site_id) do + nil -> + changeset + + site_id -> + if site_belongs_to_organization?(site_id, organization_id) do + changeset + else + Ecto.Changeset.add_error( + changeset, + :site_id, + "does not belong to your organization" + ) + end + end + end + + defp site_belongs_to_organization?(site_id, organization_id) do + Site + |> where(id: ^site_id) + |> where(organization_id: ^organization_id) + |> Repo.exists?() + end + + defp stringify_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_atom(k) -> {Atom.to_string(k), v} + {k, v} -> {k, v} + end) + end +end diff --git a/lib/towerops/coverages/antenna.ex b/lib/towerops/coverages/antenna.ex new file mode 100644 index 00000000..8bb31793 --- /dev/null +++ b/lib/towerops/coverages/antenna.ex @@ -0,0 +1,340 @@ +defmodule Towerops.Coverages.Antenna do + @moduledoc """ + Antenna pattern record + MSI Planet `.ant` parser. + + Antennas are loaded once at boot from `priv/antennas/*.ant` and stored in + `:persistent_term` keyed by slug. They have no DB representation — + the catalog ships with the application. + + The MSI Planet `.ant` format is the de facto standard for WISP antenna + vendors. Each file has a header section with metadata (NAME, MAKE, + FREQUENCY, GAIN, …) followed by HORIZONTAL N / VERTICAL N pattern + blocks listing N angle/attenuation pairs in degrees and decibels. + """ + + @enforce_keys [:slug, :model, :h_pattern, :v_pattern, :gain_dbi] + defstruct [ + :slug, + :manufacturer, + :model, + :frequency_mhz, + :h_width_deg, + :v_width_deg, + :front_to_back_db, + :gain_dbi, + :polarization, + :tilt, + :comment, + :source_file, + :h_pattern, + :v_pattern + ] + + @type pattern :: [{integer(), float()}] + @type t :: %__MODULE__{ + slug: String.t(), + manufacturer: String.t() | nil, + model: String.t(), + frequency_mhz: integer() | nil, + h_width_deg: float() | nil, + v_width_deg: float() | nil, + front_to_back_db: float() | nil, + gain_dbi: float(), + polarization: String.t() | nil, + tilt: String.t() | nil, + comment: String.t() | nil, + source_file: String.t() | nil, + h_pattern: pattern(), + v_pattern: pattern() + } + + @persistent_term_key {__MODULE__, :registry} + + @doc """ + Reads and parses a `.ant` file from disk. Slug is derived from the + filename (without extension) unless overridden. + """ + @spec parse_file(Path.t(), keyword()) :: {:ok, t()} | {:error, term()} + def parse_file(path, opts \\ []) do + slug = opts[:slug] || derive_slug(path) + + case File.read(path) do + {:ok, content} -> parse(content, slug: slug, source_file: Path.basename(path)) + {:error, reason} -> {:error, {:read_error, reason}} + end + end + + @doc "Parses a `.ant` file from a binary." + @spec parse(binary(), keyword()) :: {:ok, t()} | {:error, term()} + def parse(content, opts \\ []) when is_binary(content) do + slug = opts[:slug] || raise ArgumentError, "slug is required when parsing a binary" + source_file = opts[:source_file] + + lines = + content + |> String.split(~r/\R/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + + with {header, body} <- split_at_pattern_block(lines), + {:ok, h_pattern, after_h} <- parse_pattern_block(body, "HORIZONTAL"), + {:ok, v_pattern, _after_v} <- parse_pattern_block(after_h, "VERTICAL") do + meta = parse_header(header) + + {:ok, + %__MODULE__{ + slug: slug, + manufacturer: Map.get(meta, "MAKE"), + model: Map.get(meta, "NAME", slug), + frequency_mhz: parse_int(Map.get(meta, "FREQUENCY")), + h_width_deg: parse_float(Map.get(meta, "H_WIDTH")), + v_width_deg: parse_float(Map.get(meta, "V_WIDTH")), + front_to_back_db: parse_float(Map.get(meta, "FRONT_TO_BACK")), + gain_dbi: parse_gain(Map.get(meta, "GAIN")), + polarization: Map.get(meta, "POLARIZATION"), + tilt: Map.get(meta, "TILT"), + comment: Map.get(meta, "COMMENT"), + source_file: source_file, + h_pattern: h_pattern, + v_pattern: v_pattern + }} + end + end + + @doc """ + Returns total antenna attenuation in dB for a given azimuth and + elevation offset (relative to boresight). + + Uses the standard MSI Planet "additive" interpretation: + `total = h_attenuation(az_offset) + v_attenuation(el_offset)`. + Both inputs are wrapped to 0..360 degrees. + """ + @spec attenuation_db(t(), float(), float()) :: float() + def attenuation_db(%__MODULE__{} = antenna, az_offset_deg, el_offset_deg) do + h = sample_pattern(antenna.h_pattern, az_offset_deg) + v = sample_pattern(antenna.v_pattern, el_offset_deg) + h + v + end + + @doc """ + Lists all antennas currently loaded into the registry. Empty list if + the registry has not been loaded yet. + """ + @spec list() :: [t()] + def list do + case :persistent_term.get(@persistent_term_key, nil) do + nil -> [] + registry -> registry |> Map.values() |> Enum.sort_by(&{&1.manufacturer, &1.model}) + end + end + + @doc "Fetches a single antenna by slug. Returns nil if not present." + @spec get(String.t()) :: t() | nil + def get(slug) when is_binary(slug) do + case :persistent_term.get(@persistent_term_key, nil) do + nil -> nil + registry -> Map.get(registry, slug) + end + end + + @doc """ + Returns true if the registry contains an antenna with the given slug. + """ + @spec exists?(String.t()) :: boolean() + def exists?(slug), do: get(slug) != nil + + @doc """ + Loads all `.ant` files from `priv/antennas/` into the registry. + Logs a warning and skips files that fail to parse. Should be called + once during application boot. + """ + @spec load_registry() :: :ok + def load_registry do + dir = Application.app_dir(:towerops, "priv/antennas") + + registry = + case File.ls(dir) do + {:ok, files} -> build_registry(dir, files) + {:error, _} -> %{} + end + + :persistent_term.put(@persistent_term_key, registry) + :ok + end + + defp build_registry(dir, files) do + files + |> Enum.filter(&String.ends_with?(&1, ".ant")) + |> Enum.reduce(%{}, &add_antenna_to_registry(&1, &2, dir)) + end + + defp add_antenna_to_registry(filename, acc, dir) do + path = Path.join(dir, filename) + slug = derive_slug(path) + + case parse_file(path, slug: slug) do + {:ok, antenna} -> + Map.put(acc, slug, antenna) + + {:error, reason} -> + require Logger + + Logger.warning("Failed to parse antenna #{filename}: #{inspect(reason)}") + acc + end + end + + defp derive_slug(path) do + path |> Path.basename() |> Path.rootname() |> String.downcase() + end + + defp split_at_pattern_block(lines) do + case Enum.split_while(lines, fn line -> + not String.starts_with?(line, "HORIZONTAL") + end) do + {header, [_ | _] = rest} -> {header, rest} + {header, []} -> {header, []} + end + end + + defp parse_header(lines) do + Map.new(lines, fn line -> + case String.split(line, " ", parts: 2) do + [key, value] -> {String.upcase(key), String.trim(value)} + [key] -> {String.upcase(key), ""} + end + end) + end + + defp parse_pattern_block([], keyword) do + {:error, {:missing_block, block_atom(keyword)}} + end + + defp parse_pattern_block([header | rest], keyword) do + with {:ok, count} <- parse_block_header(header, keyword), + {entries, remaining} <- Enum.split(rest, count), + :ok <- check_block_count(entries, count, keyword), + {:ok, parsed} <- parse_pattern_lines(entries, keyword) do + {:ok, parsed, remaining} + end + end + + defp parse_block_header(header, keyword) do + case String.split(header, " ", trim: true) do + [^keyword, count_str] -> + case Integer.parse(count_str) do + {count, ""} -> {:ok, count} + _ -> {:error, {:invalid_pattern_header, keyword}} + end + + _ -> + {:error, {:expected_pattern_header, keyword}} + end + end + + defp check_block_count(entries, count, keyword) do + if length(entries) == count do + :ok + else + {:error, {:pattern_count_mismatch, block_atom(keyword), %{expected: count, got: length(entries)}}} + end + end + + defp parse_pattern_lines(entries, keyword) do + parsed = Enum.map(entries, &parse_pattern_line/1) + + if Enum.any?(parsed, &(&1 == :error)) do + {:error, {:invalid_pattern_line, keyword}} + else + {:ok, parsed} + end + end + + defp block_atom("HORIZONTAL"), do: :horizontal + defp block_atom("VERTICAL"), do: :vertical + defp block_atom(other), do: String.to_existing_atom(String.downcase(other)) + + defp parse_pattern_line(line) do + case String.split(line, ~r/\s+/, trim: true) do + [angle_str, atten_str] -> + with {angle, _} <- Integer.parse(angle_str), + {atten, _} <- Float.parse(atten_str) do + {angle, atten} + else + _ -> :error + end + + _ -> + :error + end + end + + defp parse_int(nil), do: nil + + defp parse_int(s) do + case Integer.parse(s) do + {n, _} -> n + :error -> nil + end + end + + defp parse_float(nil), do: nil + + defp parse_float(s) do + case Float.parse(s) do + {n, _} -> n + :error -> nil + end + end + + defp parse_gain(nil), do: 0.0 + + defp parse_gain(s) do + case Float.parse(s) do + {value, rest} -> + rest_lower = rest |> String.trim() |> String.downcase() + + if String.starts_with?(rest_lower, "dbd") do + # Convert dipole reference to isotropic. + value + 2.15 + else + value + end + + :error -> + 0.0 + end + end + + # Linearly interpolate the pattern at the given (possibly fractional, possibly + # out-of-range) angle. Pattern is a sorted list of {integer_angle, attenuation_db}. + defp sample_pattern(pattern, angle_deg) do + pattern_size = length(pattern) + pattern_array = pattern |> Enum.map(&elem(&1, 1)) |> List.to_tuple() + + # Wrap angle into 0..360 (using full pattern size as period — usually 360). + period = pattern_size + wrapped = wrap_to_period(angle_deg, period) + + lower_idx = wrapped |> floor() |> rem_positive(period) + upper_idx = rem_positive(lower_idx + 1, period) + frac = wrapped - floor(wrapped) + + lo = elem(pattern_array, lower_idx) + hi = elem(pattern_array, upper_idx) + lo + (hi - lo) * frac + end + + defp wrap_to_period(angle, period) do + period_f = period * 1.0 + wrapped = :math.fmod(angle, period_f) + if wrapped < 0, do: wrapped + period_f, else: wrapped + end + + defp rem_positive(n, period) do + case rem(n, period) do + r when r < 0 -> r + period + r -> r + end + end +end diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex new file mode 100644 index 00000000..37c97348 --- /dev/null +++ b/lib/towerops/coverages/coverage.ex @@ -0,0 +1,207 @@ +defmodule Towerops.Coverages.Coverage do + @moduledoc """ + RF coverage prediction for a single antenna mounted at a site. + + A coverage describes one transmit antenna and the parameters needed to + predict its signal-strength heatmap: model, height AGL, azimuth, downtilt, + frequency, EIRP, plus the spatial extent (`radius_m`, `cell_size_m`) and + receiver assumptions used by the `Towerops.Workers.CoverageWorker`. + + Multiple coverages can hang off a single site — typically one per sector + on a multi-sector tower. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Coverages.Antenna + alias Towerops.Organizations.Organization + alias Towerops.Sites.Site + + @statuses ~w(draft queued computing ready failed) + @max_axis_pixels 8_000 + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "coverages" do + field :name, :string + field :antenna_slug, :string + + field :height_agl_m, :float + field :azimuth_deg, :float + field :downtilt_deg, :float, default: 0.0 + field :frequency_mhz, :integer + field :eirp_dbm, :float + + field :radius_m, :integer + field :cell_size_m, :integer + field :receiver_height_m, :float, default: 3.0 + field :rx_threshold_dbm, :float, default: -90.0 + + field :status, :string, default: "draft" + field :progress_pct, :integer, default: 0 + field :error_message, :string + + field :computed_at, :utc_datetime + + field :bbox_min_lat, :float + field :bbox_max_lat, :float + field :bbox_min_lon, :float + field :bbox_max_lon, :float + + field :raster_path, :string + field :png_path, :string + + belongs_to :site, Site + belongs_to :organization, Organization + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t() | nil, + name: String.t() | nil, + antenna_slug: String.t() | nil, + height_agl_m: float() | nil, + azimuth_deg: float() | nil, + downtilt_deg: float() | nil, + frequency_mhz: integer() | nil, + eirp_dbm: float() | nil, + radius_m: integer() | nil, + cell_size_m: integer() | nil, + receiver_height_m: float() | nil, + rx_threshold_dbm: float() | nil, + status: String.t(), + progress_pct: integer(), + error_message: String.t() | nil, + computed_at: DateTime.t() | nil, + bbox_min_lat: float() | nil, + bbox_max_lat: float() | nil, + bbox_min_lon: float() | nil, + bbox_max_lon: float() | nil, + raster_path: String.t() | nil, + png_path: String.t() | nil, + site_id: Ecto.UUID.t() | nil, + site: NotLoaded.t() | Site.t() | nil, + organization_id: Ecto.UUID.t() | nil, + organization: NotLoaded.t() | Organization.t() | nil, + inserted_at: DateTime.t() | nil, + updated_at: DateTime.t() | nil + } + + # `organization_id` is intentionally NOT castable — it must be set + # programmatically by the context from the current scope, never from + # user-supplied attrs (per AGENTS.md security guideline). + @cast_fields ~w( + name antenna_slug height_agl_m azimuth_deg downtilt_deg + frequency_mhz eirp_dbm radius_m cell_size_m + receiver_height_m rx_threshold_dbm + status progress_pct site_id + )a + + @required_fields ~w( + name antenna_slug height_agl_m azimuth_deg + frequency_mhz eirp_dbm radius_m cell_size_m + site_id organization_id + )a + + @doc """ + Changeset for creating or updating a coverage from user input. + """ + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(coverage, attrs) do + coverage + |> cast(attrs, @cast_fields) + |> validate_required(@required_fields) + |> validate_length(:name, min: 2, max: 100) + |> validate_number(:height_agl_m, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 200.0) + |> validate_number(:azimuth_deg, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 360.0) + |> validate_number(:downtilt_deg, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 30.0) + |> validate_number(:frequency_mhz, greater_than_or_equal_to: 700, less_than_or_equal_to: 90_000) + |> validate_number(:eirp_dbm, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 60.0) + |> validate_number(:radius_m, greater_than_or_equal_to: 500, less_than_or_equal_to: 40_000) + |> validate_number(:cell_size_m, greater_than_or_equal_to: 1, less_than_or_equal_to: 50) + |> validate_number(:receiver_height_m, + greater_than_or_equal_to: 0.5, + less_than_or_equal_to: 100.0 + ) + |> validate_number(:rx_threshold_dbm, + greater_than_or_equal_to: -130.0, + less_than_or_equal_to: 0.0 + ) + |> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_inclusion(:status, @statuses) + |> validate_pixel_budget() + |> validate_antenna_exists() + |> foreign_key_constraint(:site_id) + |> foreign_key_constraint(:organization_id) + |> unique_constraint(:name, + name: :coverages_site_id_name_index, + message: "has already been taken for this site" + ) + end + + @doc """ + Changeset for the worker to update transient state (status, progress, + error, paths, computed bbox, computed_at) without re-running user-input + validations on the form fields. + """ + @spec status_changeset(t(), map()) :: Ecto.Changeset.t() + def status_changeset(coverage, attrs) do + coverage + |> cast(attrs, [ + :status, + :progress_pct, + :error_message, + :computed_at, + :bbox_min_lat, + :bbox_max_lat, + :bbox_min_lon, + :bbox_max_lon, + :raster_path, + :png_path + ]) + |> validate_inclusion(:status, @statuses) + |> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + end + + @doc "List of valid status values." + @spec statuses() :: [String.t()] + def statuses, do: @statuses + + defp validate_antenna_exists(changeset) do + case get_field(changeset, :antenna_slug) do + nil -> + changeset + + slug -> + if Antenna.exists?(slug) do + changeset + else + add_error(changeset, :antenna_slug, "is not a known antenna") + end + end + end + + defp validate_pixel_budget(changeset) do + radius = get_field(changeset, :radius_m) + cell = get_field(changeset, :cell_size_m) + + if is_integer(radius) and is_integer(cell) and cell > 0 do + pixels_per_axis = div(radius * 2, cell) + + if pixels_per_axis > @max_axis_pixels do + add_error( + changeset, + :cell_size_m, + "is too fine for this radius (would produce #{pixels_per_axis}×#{pixels_per_axis} pixels; cap is #{@max_axis_pixels} per axis)" + ) + else + changeset + end + else + changeset + end + end +end diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex new file mode 100644 index 00000000..b29b8680 --- /dev/null +++ b/lib/towerops/workers/coverage_worker.ex @@ -0,0 +1,76 @@ +defmodule Towerops.Workers.CoverageWorker do + @moduledoc """ + Computes an RF coverage prediction for a single coverage record. + + This worker is currently a scaffold: it walks a coverage through the + status state machine (queued → computing → failed) and broadcasts + progress on `Towerops.Coverages.topic/1` and `org_topic/1`. The real + terrain + ITM compute pipeline is implemented in follow-up changes; + for now the worker reports a clear "compute not yet implemented" + failure so the end-to-end UI flow is exercisable. + + ## Org scoping + + Job args carry both `coverage_id` and `organization_id`. The worker + refuses to run if the loaded coverage's `organization_id` does not + match the job payload — defence in depth in case a malicious or + buggy producer ever enqueues a job with a swapped pair. + """ + use Oban.Worker, + queue: :coverage, + max_attempts: 3, + unique: [ + fields: [:args], + keys: [:coverage_id], + states: [:available, :scheduled, :executing, :retryable] + ] + + alias Towerops.Coverages + alias Towerops.Coverages.Coverage + alias Towerops.Repo + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do + case Repo.get(Coverage, coverage_id) do + nil -> + Logger.warning("CoverageWorker: coverage #{coverage_id} not found, skipping") + :ok + + %Coverage{organization_id: ^organization_id} = coverage -> + run(coverage) + + %Coverage{} -> + Logger.error( + "CoverageWorker: organization mismatch for #{coverage_id} " <> + "(expected #{organization_id}); refusing to run" + ) + + {:cancel, :organization_mismatch} + end + end + + defp run(coverage) do + {:ok, coverage} = Coverages.mark_status(coverage, "computing", %{progress_pct: 1}) + Coverages.broadcast(coverage, {:coverage_status, :computing, 1}) + + Logger.info("CoverageWorker: compute pipeline not yet implemented for #{coverage.id}") + + error = + "Coverage compute is not yet implemented. The pipeline (LIDAR + buildings + ITM) " <> + "is under development." + + {:ok, coverage} = + Coverages.mark_status(coverage, "failed", %{ + error_message: error, + progress_pct: 0 + }) + + Coverages.broadcast(coverage, {:coverage_status, :failed, error}) + + # Return :ok so Oban marks the job complete — the failure is on + # the coverage record, not Oban-retryable, while the stub is in place. + :ok + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 0cd81fbd..ccb7fc8a 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -382,6 +382,12 @@ defmodule ToweropsWeb.Layouts do icon="hero-map" label={t("Network Map")} /> + <.sidebar_link + navigate={~p"/coverage"} + active={@active_page == "coverage"} + icon="hero-signal" + label={t("Coverage")} + /> <%!-- <.sidebar_link navigate={~p"/weathermap"} active={@active_page == "weathermap"} diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex new file mode 100644 index 00000000..1ccd25db --- /dev/null +++ b/lib/towerops_web/live/coverage_live/form.ex @@ -0,0 +1,106 @@ +defmodule ToweropsWeb.CoverageLive.Form do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Coverages + alias Towerops.Coverages.Antenna + alias Towerops.Coverages.Coverage + alias Towerops.Sites + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_scope.organization + sites = Sites.list_organization_sites(organization.id) + antennas = Antenna.list() + + {:ok, + socket + |> assign(:organization, organization) + |> assign(:sites, sites) + |> assign(:antennas, antennas) + |> assign(:antenna_options, antenna_options(antennas))} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :new, _params) do + coverage = %Coverage{ + organization_id: socket.assigns.organization.id, + downtilt_deg: 0.0, + receiver_height_m: 3.0, + rx_threshold_dbm: -90.0, + cell_size_m: 10, + radius_m: 5_000 + } + + socket + |> assign(:page_title, t("New Coverage")) + |> assign(:coverage, coverage) + |> assign(:form, to_form(Coverages.change_coverage(coverage))) + end + + defp apply_action(socket, :edit, %{"id" => id}) do + coverage = Coverages.get_coverage!(socket.assigns.organization.id, id) + + socket + |> assign(:page_title, t("Edit Coverage")) + |> assign(:coverage, coverage) + |> assign(:form, to_form(Coverages.change_coverage(coverage))) + end + + @impl true + def handle_event("validate", %{"coverage" => attrs}, socket) do + changeset = + socket.assigns.coverage + |> Coverages.change_coverage(attrs) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + @impl true + def handle_event("save", %{"coverage" => attrs}, socket) do + save(socket, socket.assigns.live_action, attrs) + end + + defp save(socket, :new, attrs) do + case Coverages.create_coverage(socket.assigns.organization.id, attrs) do + {:ok, coverage} -> + {:noreply, + socket + |> put_flash(:info, t("Coverage created")) + |> push_navigate(to: ~p"/coverage/#{coverage.id}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + defp save(socket, :edit, attrs) do + case Coverages.update_coverage(socket.assigns.coverage, attrs) do + {:ok, coverage} -> + {:noreply, + socket + |> put_flash(:info, t("Coverage updated")) + |> push_navigate(to: ~p"/coverage/#{coverage.id}")} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + defp antenna_options(antennas) do + antennas + |> Enum.group_by(& &1.manufacturer) + |> Enum.sort_by(fn {mfr, _} -> mfr || "" end) + |> Enum.map(fn {mfr, ants} -> + {mfr || t("Other"), + Enum.map(ants, fn ant -> + {"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug} + end)} + end) + end +end diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex new file mode 100644 index 00000000..10c2fe2b --- /dev/null +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -0,0 +1,180 @@ + + <.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." + )} + + + + <.form + for={@form} + id="coverage-form" + phx-change="validate" + phx-submit="save" + class="mt-6 space-y-8" + > +
+ <%!-- Identity --%> +
+

+ {t("Identity")} +

+ + <.input + field={@form[:name]} + type="text" + label={t("Name")} + placeholder={t("e.g. North sector 5 GHz")} + required + /> + + <.input + field={@form[:site_id]} + type="select" + label={t("Site")} + prompt={t("Select a site")} + options={Enum.map(@sites, &{&1.name, &1.id})} + required + /> + + <.input + field={@form[:antenna_slug]} + type="select" + label={t("Antenna")} + prompt={t("Select an antenna")} + options={@antenna_options} + required + /> +
+ + <%!-- Mounting --%> +
+

+ {t("Mounting")} +

+ + <.input + field={@form[:height_agl_m]} + type="number" + label={t("Height above ground (m)")} + step="0.1" + min="1" + max="200" + required + /> + + <.input + field={@form[:azimuth_deg]} + type="number" + label={t("Azimuth (°, true north)")} + step="0.1" + min="0" + max="360" + required + /> + + <.input + field={@form[:downtilt_deg]} + type="number" + label={t("Downtilt (°, positive = down)")} + step="0.1" + min="-10" + max="30" + /> +
+ + <%!-- RF parameters --%> +
+

+ {t("RF parameters")} +

+ + <.input + field={@form[:frequency_mhz]} + type="number" + label={t("Frequency (MHz)")} + step="1" + min="700" + max="90000" + required + /> + + <.input + field={@form[:eirp_dbm]} + type="number" + label={t("EIRP (dBm)")} + step="0.1" + min="0" + max="60" + required + /> +
+ + <%!-- Coverage extent --%> +
+

+ {t("Coverage extent")} +

+ + <.input + field={@form[:radius_m]} + type="number" + label={t("Radius (m)")} + step="100" + min="500" + max="40000" + required + /> + + <.input + field={@form[:cell_size_m]} + type="number" + label={t("Cell size (m)")} + step="1" + min="1" + max="50" + required + /> + + <.input + field={@form[:receiver_height_m]} + type="number" + label={t("Receiver height (m)")} + step="0.1" + min="0.5" + max="100" + /> + + <.input + field={@form[:rx_threshold_dbm]} + type="number" + label={t("RX threshold (dBm)")} + step="0.5" + min="-130" + max="0" + /> +
+
+ +
+ <.link navigate={~p"/coverage"} class="btn btn-outline"> + {t("Cancel")} + + <.button type="submit" variant="primary"> + {if @live_action == :new, do: t("Create coverage"), else: t("Save changes")} + +
+ +
diff --git a/lib/towerops_web/live/coverage_live/index.ex b/lib/towerops_web/live/coverage_live/index.ex new file mode 100644 index 00000000..fd642f55 --- /dev/null +++ b/lib/towerops_web/live/coverage_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/index.html.heex b/lib/towerops_web/live/coverage_live/index.html.heex new file mode 100644 index 00000000..c5048971 --- /dev/null +++ b/lib/towerops_web/live/coverage_live/index.html.heex @@ -0,0 +1,166 @@ + + <.header> + {@page_title} + <:subtitle> + {t( + "Predict signal coverage for each transmit antenna using LIDAR terrain and building data." + )} + + <:actions> + <.button :if={@sites != []} navigate={~p"/coverage/new"} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")} + + + + + <%= cond do %> + <% @sites == [] -> %> +
+
+
+
+ <.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" /> +
+

{t("Add a site first")}

+

+ {t( + "Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage." + )} +

+
+ <.button navigate={~p"/sites/new"} variant="primary"> + <.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")} + +
+
+
+
+ <% @coverages == [] -> %> +
+
+
+
+ <.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" /> +
+

{t("No coverages yet")}

+

+ {t("Create your first coverage to predict where a tower's antenna reaches.")} +

+
+ <.button navigate={~p"/coverage/new"} variant="primary"> + <.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")} + +
+
+
+
+ <% true -> %> +
+ + + + + + + + + + + + + + + <%= for coverage <- @coverages do %> + + + + + + + + + + + <% end %> + +
+ {t("Name")} + + {t("Site")} + + {t("Antenna")} + + {t("Freq (MHz)")} + + {t("EIRP (dBm)")} + + {t("Radius (m)")} + + {t("Status")} + + {t("Actions")} +
+ <.link + navigate={~p"/coverage/#{coverage.id}"} + class="font-medium text-blue-600 hover:underline dark:text-blue-400" + > + {coverage.name} + + + <.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline"> + {coverage.site && coverage.site.name} + + + {coverage.antenna_slug} + + {coverage.frequency_mhz} + + {coverage.eirp_dbm} + + {coverage.radius_m} + + + {coverage.status} + + +
+ <.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" /> + + + +
+
+
+ <% end %> +
diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex new file mode 100644 index 00000000..df2d2abc --- /dev/null +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -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""" +
+
{@label}
+
{@value}
+
+ """ + end +end diff --git a/lib/towerops_web/live/coverage_live/show.html.heex b/lib/towerops_web/live/coverage_live/show.html.heex new file mode 100644 index 00000000..2a121409 --- /dev/null +++ b/lib/towerops_web/live/coverage_live/show.html.heex @@ -0,0 +1,159 @@ + + <.breadcrumb items={[ + %{label: t("Dashboard"), navigate: ~p"/dashboard"}, + %{label: t("Coverage"), navigate: ~p"/coverage"}, + %{label: @coverage.name} + ]} /> + +
+
+

{@coverage.name}

+

+ <.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} + +

+
+ +
+ + {@coverage.status} + + <.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")} + + <.button phx-click="recompute" variant="primary"> + <.icon name="hero-arrow-path" class="h-4 w-4" /> {t("Recompute")} + +
+
+ + <%= if @coverage.status == "computing" do %> +
+
+ <.icon + name="hero-arrow-path" + class="h-5 w-5 text-yellow-700 dark:text-yellow-400 animate-spin" + /> +
+

+ {t("Computing coverage…")} +

+
+
+
+
+
+ + {@coverage.progress_pct}% + +
+
+ <% end %> + + <%= if @coverage.status == "failed" do %> +
+
+ <.icon + name="hero-exclamation-triangle" + class="h-5 w-5 text-red-700 dark:text-red-400 flex-shrink-0 mt-0.5" + /> +
+

+ {t("Compute failed")} +

+

+ {@coverage.error_message} +

+
+
+
+ <% end %> + + <%= if @coverage.status == "queued" do %> +
+

+ <.icon name="hero-clock" class="h-4 w-4 inline" /> + {t("Coverage is queued. Compute will start shortly.")} +

+
+ <% end %> + +
+ <%!-- Map area --%> +
+
+ <%= 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. --%> +
+
+ <% else %> +
+
+ <.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" /> +

+ {t("No heatmap available yet. Run compute to generate the coverage prediction.")} +

+
+
+ <% end %> +
+
+ + <%!-- Parameters panel --%> +
+
+

+ {t("Parameters")} +

+
+ <.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")} + /> +
+
+ + +
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index cf5950ea..2dcd7854 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/priv/antennas/test-omni-5ghz.ant b/priv/antennas/test-omni-5ghz.ant new file mode 100644 index 00000000..60a7183b --- /dev/null +++ b/priv/antennas/test-omni-5ghz.ant @@ -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 diff --git a/priv/antennas/test-sector-90-5ghz.ant b/priv/antennas/test-sector-90-5ghz.ant new file mode 100644 index 00000000..d52f4ead --- /dev/null +++ b/priv/antennas/test-sector-90-5ghz.ant @@ -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 diff --git a/priv/repo/migrations/20260506181711_create_coverages.exs b/priv/repo/migrations/20260506181711_create_coverages.exs new file mode 100644 index 00000000..382f2ef3 --- /dev/null +++ b/priv/repo/migrations/20260506181711_create_coverages.exs @@ -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 diff --git a/test/support/fixtures/coverages_fixtures.ex b/test/support/fixtures/coverages_fixtures.ex new file mode 100644 index 00000000..709f1d59 --- /dev/null +++ b/test/support/fixtures/coverages_fixtures.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 2ad96330..5adf300c 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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 diff --git a/test/towerops/coverages/antenna_test.exs b/test/towerops/coverages/antenna_test.exs new file mode 100644 index 00000000..55daa6c8 --- /dev/null +++ b/test/towerops/coverages/antenna_test.exs @@ -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 diff --git a/test/towerops/coverages/coverage_test.exs b/test/towerops/coverages/coverage_test.exs new file mode 100644 index 00000000..455edabe --- /dev/null +++ b/test/towerops/coverages/coverage_test.exs @@ -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 diff --git a/test/towerops/coverages_test.exs b/test/towerops/coverages_test.exs new file mode 100644 index 00000000..b1a160ca --- /dev/null +++ b/test/towerops/coverages_test.exs @@ -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