diff --git a/CHANGELOG.txt b/CHANGELOG.txt index b86474dd..e2ab0b56 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,50 @@ +2026-05-06 +feat: /coverage compute pipeline + bundled antenna catalog + cnHeat-style form + Compute pipeline (real, end-to-end working): + - lib/towerops/coverages/propagation.ex: Friis FSPL + Bullington single-knife-edge + diffraction (ITU-R P.526). Pure Elixir, no NIF. Closed-form path loss over a + sampled DSM profile. Clean interface so a future ITM/Longley-Rice backend + can drop in without changing callers. + - lib/towerops/coverages/profile.ex: AAIGrid sampler — elevation_at(lat,lon) + with proper top-down row indexing, sample(grid, from, to, n) for great-circle + profile sampling, haversine great_circle_distance_m. + - lib/towerops/coverages/raster.ex: writes Float32 GeoTIFF via gdal_translate + (with VRT wrapper) and cnHeat-style colored PNG via gdaldem color-relief. + Outputs to priv/static/coverage/// served by existing Plug.Static. + - lib/towerops/workers/coverage_worker.ex: real pipeline replacing the stub — + bbox compute → Towerops.Lidar.get_elevation_grid → per-pixel + Task.async_stream(parallelism = schedulers) running antenna pattern lookup + + propagation, write rasters. Configurable terrain source via + :coverage_terrain_module application env so tests can stub. Friendly + :failed messages for :no_tile, :grid_too_large, :nodata, :unknown_antenna. + Bundled antenna catalog (~95 antennas): + - lib/towerops/coverages/antenna_catalog.ex: compact specs (mfr, model, gain, + h_width, freq, type) for every antenna in the user's request — Cambium, + ALPHA, Antel, ITELITE, KP Performance, L-com, MARS, MikroTik, Mimosa, MTI, + RADWIN, RF Elements, Ruckus, Tarana, Simulate, Ubiquiti. + - Antenna.from_spec/1 synthesizes a 360+360 attenuation pattern from + gain+beamwidth using cosine-squared main lobe, smooth transition, + front-to-back floor with mild ripple. Real .ant files in priv/antennas/ + override the catalog by slug. + - Test fixtures moved to test/support/fixtures/antennas/. + Schema additions (migration 20260506185952_add_radio_fields_to_coverages): + - tx_power_dbm replaces stored eirp_dbm (EIRP now derived as + tx_power + antenna.gain - cable_loss in Coverage.eirp_dbm/2 and shown live + in form header). + - cable_loss_db, sm_gain_dbi, latitude_override, longitude_override, + height_above_rooftop_m, tx_clearance_m, foliage_tuning (0-100 slider). + - Coverage.location/1 returns {lat, lon} from override or parent site. + UI: + - lib/towerops_web/live/coverage_live/form.html.heex: cnHeat-style grouped + form (Identity / Location / Mounting / RF / Coverage extent), foliage + tuning range slider, computed EIRP badge in the header. + - lib/towerops_web/live/coverage_live/show.html.heex: Leaflet map area with + L.imageOverlay heatmap, opacity slider, dBm color legend, antenna marker + with directional wedge. CoverageMap hook in assets/js/app.ts. + Tests: 24 new tests covering propagation reference values, profile sampling, + worker end-to-end with stubbed terrain (writes real GeoTIFF + PNG), org-mismatch + job-cancel guard. Full suite: 10815 tests, 0 failures. + 2026-05-06 feat: /coverage feature scaffold (RF coverage prediction) Schema + migration: diff --git a/assets/js/app.ts b/assets/js/app.ts index 66343c02..c455587d 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -1776,6 +1776,140 @@ const SitesMap = { } } +// CoverageMap renders a single coverage's RSSI heatmap as a Leaflet +// imageOverlay, with a marker at the antenna location and a configurable +// opacity slider. Data attributes on the host element: +// data-lat / data-lon: antenna location +// data-azimuth: antenna boresight in degrees (true north) +// data-png: relative URL to the coverage PNG +// data-bbox: JSON [min_lat, min_lon, max_lat, max_lon] +// data-name: coverage display name +const CoverageMap = { + map: null as any, + marker: null as any, + overlay: null as any, + opacityListener: null as ((e: Event) => void) | null, + + mounted(this: any) { + if (typeof L === 'undefined') { + setTimeout(() => this.mounted(), 100) + return + } + this.init() + }, + + updated(this: any) { + // Re-render the overlay when the coverage status flips ready and + // the data attributes change. + if (this.map) { + this.refreshOverlay() + } + }, + + destroyed(this: any) { + if (this.opacityListener) { + const slider = document.getElementById('coverage-opacity-slider') + if (slider) slider.removeEventListener('input', this.opacityListener) + this.opacityListener = null + } + if (this.map) { + this.map.remove() + this.map = null + this.marker = null + this.overlay = null + } + }, + + init(this: any) { + const lat = parseFloat(this.el.dataset.lat || '0') + const lon = parseFloat(this.el.dataset.lon || '0') + const azimuth = parseFloat(this.el.dataset.azimuth || '0') + const name = this.el.dataset.name || 'Coverage' + + this.map = L.map(this.el, { zoomControl: true, scrollWheelZoom: true }) + .setView([lat, lon], 13) + + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }).addTo(this.map) + + // Antenna marker with a directional wedge derived from azimuth. + const antennaIcon = L.divIcon({ + className: 'coverage-antenna-marker', + html: ` +
+
+
+
+ `, + iconSize: [32, 32], + iconAnchor: [16, 16], + }) + + this.marker = L.marker([lat, lon], { icon: antennaIcon, title: name }).addTo(this.map) + + this.refreshOverlay() + this.bindOpacitySlider() + }, + + refreshOverlay(this: any) { + const png = this.el.dataset.png + const bboxJson = this.el.dataset.bbox + + if (this.overlay) { + this.map.removeLayer(this.overlay) + this.overlay = null + } + + if (!png || !bboxJson) return + + let bbox: number[] + try { + bbox = JSON.parse(bboxJson) + } catch (e) { + return + } + if (!Array.isArray(bbox) || bbox.length !== 4) return + + const [minLat, minLon, maxLat, maxLon] = bbox + const opacity = this.currentOpacity() + + this.overlay = L.imageOverlay(png, [[minLat, minLon], [maxLat, maxLon]], { + opacity, + interactive: false, + }).addTo(this.map) + + this.map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [20, 20] }) + }, + + bindOpacitySlider(this: any) { + const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null + if (!slider) return + this.opacityListener = (e: Event) => { + const v = parseInt((e.target as HTMLInputElement).value, 10) / 100 + if (this.overlay) this.overlay.setOpacity(v) + const label = document.getElementById('coverage-opacity-label') + if (label) label.textContent = `${Math.round(v * 100)}%` + } + slider.addEventListener('input', this.opacityListener) + }, + + currentOpacity(this: any): number { + const slider = document.getElementById('coverage-opacity-slider') as HTMLInputElement | null + if (!slider) return 0.7 + return parseInt(slider.value, 10) / 100 + }, +} + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') @@ -2402,7 +2536,7 @@ const WeathermapViewer = { const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 5000, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, WeathermapViewer, SitesMap, CoverageMap, LeafletMap, DeviceListReorder, SortableList, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger, DynamicFavicon, StatusTitle, ThemeSelector, SidebarCollapse }, }) // Show progress bar on live navigation and form submits diff --git a/lib/towerops/coverages/antenna.ex b/lib/towerops/coverages/antenna.ex index 8bb31793..3ec9d304 100644 --- a/lib/towerops/coverages/antenna.ex +++ b/lib/towerops/coverages/antenna.ex @@ -12,6 +12,8 @@ defmodule Towerops.Coverages.Antenna do blocks listing N angle/attenuation pairs in degrees and decibels. """ + alias Towerops.Coverages.AntennaCatalog + @enforce_keys [:slug, :model, :h_pattern, :v_pattern, :gain_dbi] defstruct [ :slug, @@ -50,6 +52,20 @@ defmodule Towerops.Coverages.Antenna do @persistent_term_key {__MODULE__, :registry} + @type spec :: %{ + required(:slug) => String.t(), + required(:manufacturer) => String.t(), + required(:model) => String.t(), + required(:gain_dbi) => float(), + required(:h_width_deg) => float(), + required(:type) => :omni | :sector | :horn | :dish, + required(:frequency_min_mhz) => integer(), + required(:frequency_max_mhz) => integer(), + optional(:v_width_deg) => float(), + optional(:front_to_back_db) => float(), + optional(:polarization) => String.t() + } + @doc """ Reads and parses a `.ant` file from disk. Slug is derived from the filename (without extension) unless overridden. @@ -144,24 +160,141 @@ defmodule Towerops.Coverages.Antenna do 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 + Loads the antenna registry into `:persistent_term`. Should be called once during application boot. + + Sources, in order (later entries override earlier ones): + 1. `Towerops.Coverages.AntennaCatalog.specs/0` — synthetic patterns + generated from published gain + beamwidth specs. Provides broad + coverage of common WISP gear out of the box. + 2. `priv/antennas/*.ant` — real MSI Planet pattern files dropped in + at deploy time override any catalog entry with the same slug. """ @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 + catalog_registry = load_catalog() + file_registry = load_files() + registry = Map.merge(catalog_registry, file_registry) :persistent_term.put(@persistent_term_key, registry) :ok end + defp load_catalog do + Enum.reduce(AntennaCatalog.specs(), %{}, fn spec, acc -> + case from_spec(spec) do + {:ok, antenna} -> + Map.put(acc, antenna.slug, antenna) + + {:error, reason} -> + require Logger + + Logger.warning("Failed to synthesize antenna #{spec.slug}: #{inspect(reason)}") + acc + end + end) + end + + defp load_files do + dir = Application.app_dir(:towerops, "priv/antennas") + + case File.ls(dir) do + {:ok, files} -> build_registry(dir, files) + {:error, _} -> %{} + end + end + + @doc """ + Synthesizes a 360+360 attenuation pattern from a compact spec + (manufacturer, model, gain, horizontal beamwidth, antenna type, + frequency range). Used to bootstrap a usable antenna catalog when + vendor `.ant` files aren't publicly available. + + The synthesized pattern is physically reasonable but NOT vendor-exact. + A real `.ant` file dropped in `priv/antennas/.ant` will override + the synthetic version at boot. + """ + @spec from_spec(spec()) :: {:ok, t()} | {:error, term()} + def from_spec(%{slug: slug} = spec) when is_binary(slug) do + h_width = spec.h_width_deg + v_width = Map.get(spec, :v_width_deg) || infer_v_width(spec.type, spec.gain_dbi, h_width) + front_to_back = Map.get(spec, :front_to_back_db, 28.0) + + {:ok, + %__MODULE__{ + slug: slug, + manufacturer: spec.manufacturer, + model: spec.model, + frequency_mhz: div(spec.frequency_min_mhz + spec.frequency_max_mhz, 2), + h_width_deg: h_width * 1.0, + v_width_deg: v_width * 1.0, + front_to_back_db: front_to_back * 1.0, + gain_dbi: spec.gain_dbi * 1.0, + polarization: Map.get(spec, :polarization, "DUAL"), + tilt: "MECHANICAL", + comment: + "Synthetic pattern from datasheet specs (gain + beamwidth). " <> + "Replace with vendor .ant file in priv/antennas/ for accurate planning.", + source_file: nil, + h_pattern: synth_pattern(spec.type, h_width, front_to_back, :horizontal), + v_pattern: synth_pattern(spec.type, v_width, front_to_back, :vertical) + }} + end + + def from_spec(spec), do: {:error, {:invalid_spec, spec}} + + # Approximate the typical V-plane half-power beamwidth from antenna type + # and gain when the catalog doesn't specify it explicitly. Numbers are + # rough fits to common WISP gear datasheets. + defp infer_v_width(:omni, gain_dbi, _h), do: max(6.0, 30.0 - gain_dbi) + defp infer_v_width(:sector, gain_dbi, _h), do: max(4.0, 14.0 - gain_dbi / 3.0) + defp infer_v_width(:horn, _gain_dbi, h), do: max(6.0, h * 0.8) + defp infer_v_width(:dish, _gain_dbi, h), do: h + defp infer_v_width(_, _, h), do: h + + # Build a 360-entry pattern ([{angle_deg, atten_db}, ...]). + # Omni horizontal is flat (0 dB everywhere). All other patterns use a + # cosine-squared main lobe out to the half-power beamwidth, a smooth + # transition to the back-lobe floor, and a small ripple in the back. + defp synth_pattern(:omni, _width, _ftb, :horizontal) do + Enum.map(0..359, fn deg -> {deg, 0.0} end) + end + + defp synth_pattern(_type, width, front_to_back, _axis) do + half_width = width / 2.0 + transition = max(half_width * 1.5, half_width + 15.0) + + Enum.map(0..359, fn deg -> + offset_deg = signed_offset(deg) + atten = synth_atten(abs(offset_deg), half_width, transition, front_to_back) + {deg, Float.round(atten, 2)} + end) + end + + defp signed_offset(deg) when deg <= 180, do: deg + defp signed_offset(deg), do: deg - 360 + + defp synth_atten(abs_offset, half_width, transition, front_to_back) do + cond do + abs_offset <= half_width -> + # Cosine-squared main lobe: 0 dB at boresight, 3 dB at edge. + ratio = abs_offset / half_width + 3.0 * ratio * ratio + + abs_offset <= transition -> + # Smooth transition from 3 dB (HPBW edge) to (front_to_back - 5) + # at the transition boundary, then back-lobe floor beyond. + floor = max(front_to_back - 5.0, 12.0) + progress = (abs_offset - half_width) / (transition - half_width) + 3.0 + (floor - 3.0) * progress + + true -> + # Back lobe with mild cosine ripple ±2.5 dB around the floor. + ripple = 2.5 * :math.cos((abs_offset - transition) / 30.0 * :math.pi()) + front_to_back + ripple + end + end + defp build_registry(dir, files) do files |> Enum.filter(&String.ends_with?(&1, ".ant")) diff --git a/lib/towerops/coverages/antenna_catalog.ex b/lib/towerops/coverages/antenna_catalog.ex new file mode 100644 index 00000000..4211f6ac --- /dev/null +++ b/lib/towerops/coverages/antenna_catalog.ex @@ -0,0 +1,1174 @@ +defmodule Towerops.Coverages.AntennaCatalog do + @moduledoc """ + Built-in antenna catalog for the coverage planner. + + Each entry is a compact spec (manufacturer, model, part number, gain, + beamwidth, frequency range, antenna type) that + `Towerops.Coverages.Antenna.from_spec/1` synthesizes into a full MSI + Planet pattern at boot. + + ## Provenance + + Most antennas in this catalog are NOT vendor-published `.ant` files — + Cambium, Tarana, Mimosa, RADWIN, ALPHA Wireless, ITELITE, and others + do not release MSI Planet pattern files publicly. The patterns here + are *synthesized* from each antenna's published gain, beamwidth, and + front-to-back specs, using a cosine-squared main-lobe model. + + The synthesized patterns are good enough for relative coverage + comparison and rough planning, but they are not vendor-exact. Drop a + real `.ant` file in `priv/antennas/.ant` to override any catalog + entry — the file always wins over the synthesized version. + + ## Adding entries + + Append to `@specs/0`. Slug must be unique. `type` is one of + `:omni | :sector | :horn | :dish`. Frequency range is in MHz. Gain + is in dBi. Beamwidth is the horizontal half-power beamwidth in + degrees (use 360 for omnis). + """ + + @type spec :: Towerops.Coverages.Antenna.spec() + + # credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks + + @specs [ + # ------------------------------------------------------------------ + # Cambium Networks + # ------------------------------------------------------------------ + %{ + slug: "cambium-c050900d025", + manufacturer: "Cambium Networks", + model: "ePMP 4x4 MU-MIMO Dual Horn (C050900D025)", + gain_dbi: 12.6, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5700, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-c050910d301", + manufacturer: "Cambium Networks", + model: "ePMP 4x4 MU-MIMO Sector 90° (C050910D301)", + gain_dbi: 16.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 6000 + }, + %{ + slug: "cambium-epmp4500-sector-90", + manufacturer: "Cambium Networks", + model: "ePMP 4500 Sector 90°", + gain_dbi: 17.9, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 6100 + }, + %{ + slug: "cambium-c050900d021", + manufacturer: "Cambium Networks", + model: "4.9-6 GHz Sector 90° (C050900D021)", + gain_dbi: 18.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-pmp450v-4x4", + manufacturer: "Cambium Networks", + model: "PMP 450v 4x4 Sector 90°", + gain_dbi: 17.8, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 6000 + }, + %{ + slug: "cambium-pmp450i-integrated-90", + manufacturer: "Cambium Networks", + model: "PMP 450i Integrated Sector 90°", + gain_dbi: 18.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-c050900d003", + manufacturer: "Cambium Networks", + model: "5 GHz Sector 90° (C050900D003)", + gain_dbi: 15.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5875 + }, + %{ + slug: "cambium-xv2-2t0-omni", + manufacturer: "Cambium Networks", + model: "XV2-2T0 Omni", + gain_dbi: 9.3, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5895 + }, + %{ + slug: "cambium-epmp-force180", + manufacturer: "Cambium Networks", + model: "ePMP Force 180", + gain_dbi: 16.8, + h_width_deg: 15, + type: :dish, + frequency_min_mhz: 4910, + frequency_max_mhz: 5970 + }, + %{ + slug: "cambium-force200", + manufacturer: "Cambium Networks", + model: "Force 200 Dish", + gain_dbi: 23.9, + h_width_deg: 7, + type: :dish, + frequency_min_mhz: 4910, + frequency_max_mhz: 5970 + }, + %{ + slug: "cambium-450-micropop-omni", + manufacturer: "Cambium Networks", + model: "450 MicroPop Omni", + gain_dbi: 9.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 4900, + frequency_max_mhz: 6000 + }, + %{ + slug: "cambium-xv2-2t1-sector", + manufacturer: "Cambium Networks", + model: "XV2-2T1 Sector 120°", + gain_dbi: 13.5, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-5ghz-sector-90-17", + manufacturer: "Cambium Networks", + model: "5 GHz Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-xe3-4tn-omni", + manufacturer: "Cambium Networks", + model: "XE3-4TN Omni", + gain_dbi: 5.5, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-pmp450m-integrated-90", + manufacturer: "Cambium Networks", + model: "PMP 450m Integrated Sector 90°", + gain_dbi: 15.3, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-450-micropop-sector-90", + manufacturer: "Cambium Networks", + model: "450 MicroPop Sector 90°", + gain_dbi: 15.3, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 6000 + }, + %{ + slug: "cambium-5ghz-sector-60-17", + manufacturer: "Cambium Networks", + model: "5 GHz Sector 60°", + gain_dbi: 17.0, + h_width_deg: 60, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "cambium-epmp-mp3000-micropop", + manufacturer: "Cambium Networks", + model: "ePMP MP3000 MicroPop 120°", + gain_dbi: 10.5, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 4800, + frequency_max_mhz: 6000 + }, + + # ------------------------------------------------------------------ + # ALPHA Wireless + # ------------------------------------------------------------------ + %{ + slug: "alpha-aw3802-t2-v-right", + manufacturer: "ALPHA Wireless", + model: "AW3802-T2-V-RIGHT 6 GHz Sector 120°", + gain_dbi: 17.4, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 6875 + }, + %{ + slug: "alpha-aw3802-t2-v-left", + manufacturer: "ALPHA Wireless", + model: "AW3802-T2-V-LEFT 6 GHz Sector 120°", + gain_dbi: 17.4, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 6875 + }, + %{ + slug: "alpha-aw3802-t2", + manufacturer: "ALPHA Wireless", + model: "AW3802-T2 5 GHz Sector 120°", + gain_dbi: 18.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 6425 + }, + + # ------------------------------------------------------------------ + # Antel + # ------------------------------------------------------------------ + %{ + slug: "antel-ata-st243550hv", + manufacturer: "Antel", + model: "ATA-ST243550HV Sector 65°", + gain_dbi: 19.0, + h_width_deg: 65, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # ITELITE + # ------------------------------------------------------------------ + %{ + slug: "itelite-sec-xl35-50xp-dp", + manufacturer: "ITELITE", + model: "SEC-XL35/50XP/DP Sector 90°", + gain_dbi: 15.2, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # KP Performance Antennas + # ------------------------------------------------------------------ + %{ + slug: "kppa-2s5hv-90ss", + manufacturer: "KP Performance", + model: "KPPA-2S5HV-90SS Sector 90°", + gain_dbi: 15.8, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5400, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-3s5hv-90ss", + manufacturer: "KP Performance", + model: "KPPA-3S5HV-90SS Sector 90°", + gain_dbi: 14.9, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5400, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-5hv5hv-65sa", + manufacturer: "KP Performance", + model: "KPPA-5HV5HV-65SA Dual Sector 120°", + gain_dbi: 16.5, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5200, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-5ghz-dpoma", + manufacturer: "KP Performance", + model: "KPPA-5GHZ-DPOMA Omni", + gain_dbi: 13.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5950 + }, + %{ + slug: "kp-35domni-hv", + manufacturer: "KP Performance", + model: "KP-35DOMNI-HV Omni", + gain_dbi: 12.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "kp-25domni-hv", + manufacturer: "KP Performance", + model: "KP-25DOMNI-HV Omni", + gain_dbi: 12.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "kp-5qsomni-13", + manufacturer: "KP Performance", + model: "KP-5QSOMNI-13 ePMP 4x4 Omni", + gain_dbi: 13.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5950 + }, + %{ + slug: "kp-5qomni-13", + manufacturer: "KP Performance", + model: "KP-5QOMNI-13 Omni", + gain_dbi: 13.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5100, + frequency_max_mhz: 5950 + }, + %{ + slug: "kppa-5ghzdp120s", + manufacturer: "KP Performance", + model: "KPPA-5GHZDP120S Sector 120°", + gain_dbi: 16.3, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-5ghzdp40s-wc", + manufacturer: "KP Performance", + model: "KPPA-5GHZDP40S-WC Sector 40°", + gain_dbi: 18.0, + h_width_deg: 40, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-5ghzdp90s-pmp", + manufacturer: "KP Performance", + model: "KPPA-5GHZDP90S-PMP Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5900 + }, + %{ + slug: "kppa-5ghzdp60s-wc", + manufacturer: "KP Performance", + model: "KPPA-5GHZDP60S-WC Sector 60°", + gain_dbi: 17.7, + h_width_deg: 60, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5900 + }, + %{ + slug: "kpp-5sx4-65", + manufacturer: "KP Performance", + model: "KPP-5SX4-65 Sector 65°", + gain_dbi: 19.0, + h_width_deg: 65, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + %{ + slug: "kp-5sx4-45", + manufacturer: "KP Performance", + model: "KP-5SX4-45 Sector 45°", + gain_dbi: 20.0, + h_width_deg: 45, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "kpp-3s65-5s90-x8", + manufacturer: "KP Performance", + model: "KPP-3S65-5S90-X8 Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + %{ + slug: "kp-5ha-45", + manufacturer: "KP Performance", + model: "KP-5HA-45 Horn 45°", + gain_dbi: 16.0, + h_width_deg: 31, + type: :horn, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + %{ + slug: "kppa-5ghzhv4p65-17", + manufacturer: "KP Performance", + model: "KPPA-5GHZHV4P65-17 Sector 65°", + gain_dbi: 17.5, + h_width_deg: 65, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "kpp-5sx4-90", + manufacturer: "KP Performance", + model: "KPP-5SX4-90 Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + %{ + slug: "kpp-3s65-5s90-x8-0dt", + manufacturer: "KP Performance", + model: "KPP-3S65-5S90-X8-0DT Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + + # ------------------------------------------------------------------ + # L-com + # ------------------------------------------------------------------ + %{ + slug: "lcom-hg5158-16dp-120", + manufacturer: "L-com", + model: "HG5158-16DP-120 Sector 120°", + gain_dbi: 16.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + + # ------------------------------------------------------------------ + # MARS + # ------------------------------------------------------------------ + %{ + slug: "mars-ma-wa56-dp23", + manufacturer: "MARS", + model: "MA-WA56-DP23 Narrow Beam High Gain", + gain_dbi: 23.2, + h_width_deg: 10, + type: :dish, + frequency_min_mhz: 5700, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # MikroTik + # ------------------------------------------------------------------ + %{ + slug: "mikrotik-mtas-5g-15d120", + manufacturer: "MikroTik", + model: "MTAS-5G-15D120 Sector 120°", + gain_dbi: 15.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5170, + frequency_max_mhz: 5825 + }, + %{ + slug: "mikrotik-mtas-5g-19d120", + manufacturer: "MikroTik", + model: "MTAS-5G-19D120 Sector 120°", + gain_dbi: 19.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5170, + frequency_max_mhz: 5825 + }, + + # ------------------------------------------------------------------ + # Mimosa + # ------------------------------------------------------------------ + %{ + slug: "mimosa-n5-360", + manufacturer: "Mimosa", + model: "N5-360 Omni", + gain_dbi: 15.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 4900, + frequency_max_mhz: 6400 + }, + + # ------------------------------------------------------------------ + # MTI + # ------------------------------------------------------------------ + %{ + slug: "mti-484026-nvh", + manufacturer: "MTI", + model: "484026/NVH Sector 60°", + gain_dbi: 16.6, + h_width_deg: 60, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5875 + }, + %{ + slug: "mti-mt-463042-nd", + manufacturer: "MTI", + model: "MT-463042/ND Sector 90°", + gain_dbi: 13.7, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # RADWIN + # ------------------------------------------------------------------ + %{ + slug: "radwin-jet-pro", + manufacturer: "RADWIN", + model: "JET PRO Sector 90°", + gain_dbi: 21.1, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # RF Elements + # ------------------------------------------------------------------ + %{ + slug: "rfe-ah90wb-6ghz-4x4", + manufacturer: "RF Elements", + model: "AH90WB 4x4 6 GHz Horn 90°", + gain_dbi: 15.0, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5700, + frequency_max_mhz: 7125 + }, + %{ + slug: "rfe-ultradish-tp-400", + manufacturer: "RF Elements", + model: "UltraDish-TP-400 Horn 7°", + gain_dbi: 24.5, + h_width_deg: 7, + type: :dish, + frequency_min_mhz: 5180, + frequency_max_mhz: 6100 + }, + %{ + slug: "rfe-hg3-tp-s80", + manufacturer: "RF Elements", + model: "HG3-TP-S80 Horn 80°", + gain_dbi: 10.4, + h_width_deg: 80, + type: :horn, + frequency_min_mhz: 5180, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-uh-tp-5-24", + manufacturer: "RF Elements", + model: "UH-TP-5-24 Horn 11°", + gain_dbi: 24.0, + h_width_deg: 11, + type: :dish, + frequency_min_mhz: 5180, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-sth-a45-usma", + manufacturer: "RF Elements", + model: "STH-A45-USMA Asymmetric Horn 45°", + gain_dbi: 17.0, + h_width_deg: 45, + type: :horn, + frequency_min_mhz: 5150, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-as-5-20", + manufacturer: "RF Elements", + model: "AS-5-20 Sector 90°", + gain_dbi: 20.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "rfe-sh-tp-5-60", + manufacturer: "RF Elements", + model: "SH-TP 5-60 Horn 60°", + gain_dbi: 13.2, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-sh-tp-5-30", + manufacturer: "RF Elements", + model: "SH-TP 5-30 Horn 30°", + gain_dbi: 18.4, + h_width_deg: 30, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-sh-cc-5-90", + manufacturer: "RF Elements", + model: "SH-CC 5-90 Horn 90°", + gain_dbi: 9.6, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-sh-cc-5-30", + manufacturer: "RF Elements", + model: "SH-CC 5-30 Horn 30°", + gain_dbi: 18.4, + h_width_deg: 30, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-hg3-tp-s50", + manufacturer: "RF Elements", + model: "HG3-TP-S50 Horn 50°", + gain_dbi: 14.3, + h_width_deg: 50, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-tp-a20-30", + manufacturer: "RF Elements", + model: "HG3-TP-A20-30 Asymmetric Horn 20°", + gain_dbi: 20.5, + h_width_deg: 20, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-hg3-tp-s60", + manufacturer: "RF Elements", + model: "HG3-TP-S60 Horn 60°", + gain_dbi: 13.2, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-cc-s90", + manufacturer: "RF Elements", + model: "HG3-CC-S90 Horn 90°", + gain_dbi: 9.6, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-tp-s40", + manufacturer: "RF Elements", + model: "HG3-TP-S40 Horn 40°", + gain_dbi: 16.2, + h_width_deg: 40, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-tp-a90", + manufacturer: "RF Elements", + model: "HG3-TP-A90 Asymmetric Horn 90°", + gain_dbi: 16.0, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-hg3-tp-s90", + manufacturer: "RF Elements", + model: "HG3-TP-S90 Horn 90°", + gain_dbi: 9.6, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-tp-a60", + manufacturer: "RF Elements", + model: "HG3-TP-A60 Asymmetric Horn 60°", + gain_dbi: 17.0, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-sh-cc-5-60", + manufacturer: "RF Elements", + model: "SH-CC 5-60 Horn 60°", + gain_dbi: 13.2, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-hg3-tp-s30", + manufacturer: "RF Elements", + model: "HG3-TP-S30 Horn 30°", + gain_dbi: 18.4, + h_width_deg: 30, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-hg3-cc-s60", + manufacturer: "RF Elements", + model: "HG3-CC-S60 Horn 60°", + gain_dbi: 13.2, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-sh-tp-5-90", + manufacturer: "RF Elements", + model: "SH-TP 5-90 Horn 90°", + gain_dbi: 9.6, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6000 + }, + %{ + slug: "rfe-hg3-cc-s30", + manufacturer: "RF Elements", + model: "HG3-CC-S30 Horn 30°", + gain_dbi: 18.4, + h_width_deg: 30, + type: :horn, + frequency_min_mhz: 5100, + frequency_max_mhz: 6400 + }, + %{ + slug: "rfe-sh90wb", + manufacturer: "RF Elements", + model: "SH90WB Horn 90°", + gain_dbi: 10.0, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 4900, + frequency_max_mhz: 7125 + }, + %{ + slug: "rfe-ah90wb", + manufacturer: "RF Elements", + model: "AH90WB Horn 90°", + gain_dbi: 15.0, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 4900, + frequency_max_mhz: 7125 + }, + %{ + slug: "rfe-sh30wb", + manufacturer: "RF Elements", + model: "SH30WB Horn 30°", + gain_dbi: 18.0, + h_width_deg: 30, + type: :horn, + frequency_min_mhz: 4900, + frequency_max_mhz: 7125 + }, + %{ + slug: "rfe-sh60wb", + manufacturer: "RF Elements", + model: "SH60WB Horn 60°", + gain_dbi: 13.0, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 4900, + frequency_max_mhz: 7125 + }, + + # ------------------------------------------------------------------ + # Ruckus + # ------------------------------------------------------------------ + %{ + slug: "ruckus-t670-2x2", + manufacturer: "Ruckus", + model: "T670 2x2 Omni", + gain_dbi: 9.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ruckus-t670-4x4", + manufacturer: "Ruckus", + model: "T670 4x4 Omni", + gain_dbi: 10.7, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + + # ------------------------------------------------------------------ + # Tarana + # ------------------------------------------------------------------ + %{ + slug: "tarana-g1-bn6asi002", + manufacturer: "Tarana", + model: "G1-BN6 Integrated 90° (G1-BN6ASI002)", + gain_dbi: 16.3, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5725, + frequency_max_mhz: 6865 + }, + %{ + slug: "tarana-g1-bn6asi002-x2", + manufacturer: "Tarana", + model: "G1-BN6 X2 Integrated 90° (G1-BN6ASI002)", + gain_dbi: 13.3, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5725, + frequency_max_mhz: 6865 + }, + %{ + slug: "tarana-g2-bnf356900", + manufacturer: "Tarana", + model: "G2-BN6 Integrated 90° (G2-BNF356900)", + gain_dbi: 25.2, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5700, + frequency_max_mhz: 7000 + }, + %{ + slug: "tarana-g1-bn5as1002", + manufacturer: "Tarana", + model: "G1-BN5 Integrated 90° (G1-BN5AS1002)", + gain_dbi: 17.6, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + + # ------------------------------------------------------------------ + # Simulate (theoretical references) + # ------------------------------------------------------------------ + %{ + slug: "simulate-isotropic-omni-18", + manufacturer: "Simulate", + model: "Isotropic Omni + 18 dB", + gain_dbi: 18.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "simulate-isotropic-omni-0", + manufacturer: "Simulate", + model: "Isotropic Omni", + gain_dbi: 0.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 900, + frequency_max_mhz: 30_000 + }, + + # ------------------------------------------------------------------ + # Ubiquiti + # ------------------------------------------------------------------ + %{ + slug: "ubnt-am-v5g-ti", + manufacturer: "Ubiquiti", + model: "AM-V5G-Ti Sector 90°", + gain_dbi: 20.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5450, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-amo-5g10", + manufacturer: "Ubiquiti", + model: "AMO-5G10 Omni", + gain_dbi: 10.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5450, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-amo-5g13", + manufacturer: "Ubiquiti", + model: "AMO-5G13 Omni", + gain_dbi: 13.0, + h_width_deg: 360, + type: :omni, + frequency_min_mhz: 5450, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-nanostation-locom5", + manufacturer: "Ubiquiti", + model: "NanoStation locoM5 Sector 45°", + gain_dbi: 13.0, + h_width_deg: 45, + type: :sector, + frequency_min_mhz: 5170, + frequency_max_mhz: 5875 + }, + %{ + slug: "ubnt-nanostation-m5", + manufacturer: "Ubiquiti", + model: "NanoStation M5 Sector 43°", + gain_dbi: 15.0, + h_width_deg: 43, + type: :sector, + frequency_min_mhz: 5170, + frequency_max_mhz: 5875 + }, + %{ + slug: "ubnt-am-5g19-120", + manufacturer: "Ubiquiti", + model: "AM-5G19-120 Sector 120°", + gain_dbi: 19.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-horn-5-45", + manufacturer: "Ubiquiti", + model: "Horn-5-45 Horn 45°", + gain_dbi: 15.5, + h_width_deg: 45, + type: :horn, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-nbe-5ac-19", + manufacturer: "Ubiquiti", + model: "NBE-5AC-19 Dish 20°", + gain_dbi: 19.0, + h_width_deg: 20, + type: :dish, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-prismap-5-60", + manufacturer: "Ubiquiti", + model: "PRISMAP-5-60 Horn 60°", + gain_dbi: 16.0, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-lap-gps", + manufacturer: "Ubiquiti", + model: "LAP-GPS Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-am-5g20-90", + manufacturer: "Ubiquiti", + model: "AM-5G20-90 Sector 90°", + gain_dbi: 20.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-horn-5-60", + manufacturer: "Ubiquiti", + model: "Horn-5-60 Horn 60°", + gain_dbi: 16.0, + h_width_deg: 60, + type: :horn, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-nanostation-5ac", + manufacturer: "Ubiquiti", + model: "NanoStation 5AC Sector 45°", + gain_dbi: 16.0, + h_width_deg: 45, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5875 + }, + %{ + slug: "ubnt-nanostation-5ac-loco", + manufacturer: "Ubiquiti", + model: "NanoStation 5AC Loco Sector 45°", + gain_dbi: 13.0, + h_width_deg: 45, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5875 + }, + %{ + slug: "ubnt-horn-5-90", + manufacturer: "Ubiquiti", + model: "Horn-5-90 Horn 90°", + gain_dbi: 13.0, + h_width_deg: 90, + type: :horn, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-uisp-dish", + manufacturer: "Ubiquiti", + model: "UISP-Dish 5/6 GHz Dish 6°", + gain_dbi: 30.0, + h_width_deg: 6, + type: :dish, + frequency_min_mhz: 5150, + frequency_max_mhz: 6875 + }, + %{ + slug: "ubnt-ap-5ac-90-hd", + manufacturer: "Ubiquiti", + model: "AP-5AC-90-HD Sector 90°", + gain_dbi: 22.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 5150, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-am-5ac22-45", + manufacturer: "Ubiquiti", + model: "AM-5AC22-45 Sector 45°", + gain_dbi: 22.0, + h_width_deg: 45, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-am-5ac21-60", + manufacturer: "Ubiquiti", + model: "AM-5AC21-60 Sector 60°", + gain_dbi: 21.0, + h_width_deg: 60, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-am-5g16-120", + manufacturer: "Ubiquiti", + model: "AM-5G16-120 Sector 120°", + gain_dbi: 16.0, + h_width_deg: 120, + type: :sector, + frequency_min_mhz: 5100, + frequency_max_mhz: 5850 + }, + %{ + slug: "ubnt-am-5g17-90", + manufacturer: "Ubiquiti", + model: "AM-5G17-90 Sector 90°", + gain_dbi: 17.0, + h_width_deg: 90, + type: :sector, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + }, + %{ + slug: "ubnt-rd-5g30", + manufacturer: "Ubiquiti", + model: "RD-5G30 Dish 5°", + gain_dbi: 30.0, + h_width_deg: 5, + type: :dish, + frequency_min_mhz: 4900, + frequency_max_mhz: 5900 + } + ] + + @doc "Returns the full list of synthesized antenna specs." + @spec specs() :: [spec()] + def specs, do: @specs +end diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index 37c97348..5a1effc0 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -29,10 +29,19 @@ defmodule Towerops.Coverages.Coverage do field :antenna_slug, :string field :height_agl_m, :float + field :height_above_rooftop_m, :float, default: 0.0 field :azimuth_deg, :float field :downtilt_deg, :float, default: 0.0 field :frequency_mhz, :integer - field :eirp_dbm, :float + field :tx_power_dbm, :float + field :cable_loss_db, :float, default: 0.0 + field :sm_gain_dbi, :float, default: 0.0 + field :tx_clearance_m, :float + field :foliage_tuning, :integer, default: 0 + + # Optional per-coverage location override; defaults to the parent site. + field :latitude_override, :float + field :longitude_override, :float field :radius_m, :integer field :cell_size_m, :integer @@ -64,10 +73,17 @@ defmodule Towerops.Coverages.Coverage do name: String.t() | nil, antenna_slug: String.t() | nil, height_agl_m: float() | nil, + height_above_rooftop_m: float() | nil, azimuth_deg: float() | nil, downtilt_deg: float() | nil, frequency_mhz: integer() | nil, - eirp_dbm: float() | nil, + tx_power_dbm: float() | nil, + cable_loss_db: float() | nil, + sm_gain_dbi: float() | nil, + tx_clearance_m: float() | nil, + foliage_tuning: integer() | nil, + latitude_override: float() | nil, + longitude_override: float() | nil, radius_m: integer() | nil, cell_size_m: integer() | nil, receiver_height_m: float() | nil, @@ -94,15 +110,18 @@ defmodule Towerops.Coverages.Coverage do # 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 + name antenna_slug + height_agl_m height_above_rooftop_m azimuth_deg downtilt_deg + frequency_mhz tx_power_dbm cable_loss_db sm_gain_dbi + tx_clearance_m foliage_tuning + latitude_override longitude_override + 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 + frequency_mhz tx_power_dbm radius_m cell_size_m site_id organization_id )a @@ -119,7 +138,26 @@ defmodule Towerops.Coverages.Coverage do |> 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(:tx_power_dbm, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 50.0) + |> validate_number(:cable_loss_db, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 20.0) + |> validate_number(:sm_gain_dbi, greater_than_or_equal_to: 0.0, less_than_or_equal_to: 40.0) + |> validate_number(:height_above_rooftop_m, + greater_than_or_equal_to: 0.0, + less_than_or_equal_to: 100.0 + ) + |> validate_number(:tx_clearance_m, + greater_than_or_equal_to: 0.0, + less_than_or_equal_to: 1000.0 + ) + |> validate_number(:foliage_tuning, greater_than_or_equal_to: 0, less_than_or_equal_to: 100) + |> validate_number(:latitude_override, + greater_than_or_equal_to: -90.0, + less_than_or_equal_to: 90.0 + ) + |> validate_number(:longitude_override, + greater_than_or_equal_to: -180.0, + less_than_or_equal_to: 180.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, @@ -170,6 +208,31 @@ defmodule Towerops.Coverages.Coverage do @spec statuses() :: [String.t()] def statuses, do: @statuses + @doc """ + Returns the effective transmit EIRP in dBm given the configured TX + power, cable loss, and the chosen antenna's gain. + + `eirp = tx_power_dbm + antenna_gain_dbi - cable_loss_db` + """ + @spec eirp_dbm(t(), float()) :: float() + def eirp_dbm(%__MODULE__{} = coverage, antenna_gain_dbi) when is_number(antenna_gain_dbi) do + (coverage.tx_power_dbm || 0.0) + antenna_gain_dbi * 1.0 - (coverage.cable_loss_db || 0.0) + end + + @doc """ + Returns the `{lat, lon}` to use for the coverage centre — the + per-coverage override when set, otherwise the parent site's + coordinates. Returns `nil` if neither has both coordinates. + """ + @spec location(t()) :: {float(), float()} | nil + def location(%__MODULE__{latitude_override: lat, longitude_override: lon}) when is_number(lat) and is_number(lon), + do: {lat * 1.0, lon * 1.0} + + def location(%__MODULE__{site: %{latitude: lat, longitude: lon}}) when is_number(lat) and is_number(lon), + do: {lat * 1.0, lon * 1.0} + + def location(_), do: nil + defp validate_antenna_exists(changeset) do case get_field(changeset, :antenna_slug) do nil -> diff --git a/lib/towerops/coverages/profile.ex b/lib/towerops/coverages/profile.ex new file mode 100644 index 00000000..dd4c4baf --- /dev/null +++ b/lib/towerops/coverages/profile.ex @@ -0,0 +1,111 @@ +defmodule Towerops.Coverages.Profile do + @moduledoc """ + Path-profile sampling against a precomputed AAIGrid-shaped elevation + raster. + + The grid is the structure returned by `Towerops.Lidar.get_elevation_grid/2`: + + %{ + ncols: integer, # number of columns (lon) + nrows: integer, # number of rows (lat) + xllcorner: float, # west edge longitude + yllcorner: float, # south edge latitude + cellsize: float, # cell size in degrees + nodata_value: float, # sentinel for missing data + cells: [[float]] # rows top-to-bottom (north-to-south) + } + + AAIGrid row 0 is the *top* row (highest latitude), so latitude + decreases as the row index increases. This module hides that detail + from callers. + """ + + @earth_radius_m 6_378_137.0 + + @doc """ + Returns the elevation at `(lat, lon)`, or `nil` if the point is outside + the grid or falls on a nodata cell. + """ + @spec elevation_at(map(), number(), number()) :: float() | nil + def elevation_at(grid, lat, lon) do + %{ + ncols: ncols, + nrows: nrows, + xllcorner: xll, + yllcorner: yll, + cellsize: cs, + nodata_value: nodata, + cells: cells + } = grid + + col = trunc(Float.floor((lon - xll) / cs)) + row_from_bottom = trunc(Float.floor((lat - yll) / cs)) + + cond do + col < 0 or col >= ncols -> nil + row_from_bottom < 0 or row_from_bottom >= nrows -> nil + true -> read_cell(cells, nrows - 1 - row_from_bottom, col, nodata) + end + end + + @doc """ + Samples `n` elevations along the great circle from `from_latlon` to + `to_latlon`. Samples include both endpoints when `n >= 2`. Points + outside the grid (or on nodata cells) become `0.0` so the resulting + list always has length `n` and is safe to feed into propagation. + """ + @spec sample(map(), {number(), number()}, {number(), number()}, pos_integer()) :: [float()] + def sample(grid, {from_lat, from_lon}, _to, 1) do + [elevation_or_zero(grid, from_lat, from_lon)] + end + + def sample(grid, {from_lat, from_lon}, {to_lat, to_lon}, n) when n >= 2 do + Enum.map(0..(n - 1), fn i -> + t = i / (n - 1) + lat = from_lat + (to_lat - from_lat) * t + lon = from_lon + (to_lon - from_lon) * t + elevation_or_zero(grid, lat, lon) + end) + end + + @doc """ + Great-circle distance in metres between two `{lat, lon}` points, + using the haversine formula. + """ + @spec great_circle_distance_m({number(), number()}, {number(), number()}) :: float() + def great_circle_distance_m({lat1, lon1}, {lat2, lon2}) do + phi1 = lat1 * :math.pi() / 180.0 + phi2 = lat2 * :math.pi() / 180.0 + dphi = (lat2 - lat1) * :math.pi() / 180.0 + dlam = (lon2 - lon1) * :math.pi() / 180.0 + + a = + :math.sin(dphi / 2) * :math.sin(dphi / 2) + + :math.cos(phi1) * :math.cos(phi2) * + :math.sin(dlam / 2) * :math.sin(dlam / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + @earth_radius_m * c + end + + defp elevation_or_zero(grid, lat, lon) do + case elevation_at(grid, lat, lon) do + nil -> 0.0 + elev -> elev + end + end + + defp read_cell(cells, row_idx, col_idx, nodata) do + case Enum.at(cells, row_idx) do + nil -> + nil + + row -> + case Enum.at(row, col_idx) do + nil -> nil + ^nodata -> nil + v when is_number(v) -> v * 1.0 + end + end + end +end diff --git a/lib/towerops/coverages/propagation.ex b/lib/towerops/coverages/propagation.ex new file mode 100644 index 00000000..d83ac688 --- /dev/null +++ b/lib/towerops/coverages/propagation.ex @@ -0,0 +1,128 @@ +defmodule Towerops.Coverages.Propagation do + @moduledoc """ + Pragmatic RF path-loss model for terrain-shadowed WISP coverage. + + Combines: + + * **Free-space path loss** (Friis): a closed-form function of distance + and frequency, accurate over flat terrain at line-of-sight. + * **Bullington single-knife-edge diffraction** (ITU-R P.526): finds + the most obstructive point on a sampled DSM profile and adds the + classical knife-edge diffraction loss when an obstacle protrudes + into (or above) the geometric line-of-sight. + + This is *not* full ITM/Longley-Rice — but it captures the dominant + effect for WISP planning (terrain shadowing) using only published + open formulas, with no NIF dependency. The function signatures are + designed so a future ITM-backed implementation can drop in without + changing callers. + + Antenna gain is applied separately by the worker (see + `Towerops.Coverages.Antenna.attenuation_db/3`). + + References: + * ITU-R P.525 (free-space) + * ITU-R P.526 §4 (single knife-edge diffraction) + """ + + # Speed of light in m/s + @c_mps 299_792_458.0 + + @doc """ + Free-space path loss in dB. + + `FSPL(d, f) = 20·log10(4πd/λ) = 32.45 + 20·log10(d_km) + 20·log10(f_MHz)` + """ + @spec fspl(distance_m :: number(), frequency_mhz :: number()) :: float() + def fspl(distance_m, frequency_mhz) + when is_number(distance_m) and is_number(frequency_mhz) and distance_m > 0 and frequency_mhz > 0 do + 32.45 + 20.0 * :math.log10(distance_m / 1000.0) + 20.0 * :math.log10(frequency_mhz) + end + + @doc """ + Knife-edge diffraction loss in dB for the dimensionless Fresnel-Kirchhoff + parameter `v` (ITU-R P.526 eq. 14): + + J(v) = 6.9 + 20·log10(√((v − 0.1)² + 1) + v − 0.1) for v ≥ −0.78 + J(v) = 0 for v < −0.78 + """ + @spec knife_edge_loss(v :: number()) :: float() + def knife_edge_loss(v) when is_number(v) and v < -0.78, do: 0.0 + + def knife_edge_loss(v) when is_number(v) do + a = v - 0.1 + 6.9 + 20.0 * :math.log10(:math.sqrt(a * a + 1.0) + a) + end + + @doc """ + Total path loss in dB from antenna to receiver over the given DSM + profile (terrain elevation samples in metres, evenly spaced from the + antenna to the receiver, inclusive). + + The transmit and receive heights are added to the *first* and *last* + profile samples respectively to form the geometric ray endpoints. + + Returns `FSPL(d, f) + max(0, knife_edge_loss(v))` where `v` is the + Fresnel parameter computed from the dominant obstructing terrain + point along the profile. + """ + @spec path_loss( + distance_m :: number(), + frequency_mhz :: number(), + profile :: [number()], + tx_height_m :: number(), + rx_height_m :: number() + ) :: float() + def path_loss(distance_m, frequency_mhz, profile, tx_height_m, rx_height_m) + when is_number(distance_m) and is_number(frequency_mhz) and is_list(profile) and is_number(tx_height_m) and + is_number(rx_height_m) do + free = fspl(distance_m, frequency_mhz) + diff = diffraction_loss(distance_m, frequency_mhz, profile, tx_height_m, rx_height_m) + free + diff + end + + @doc false + @spec diffraction_loss( + distance_m :: number(), + frequency_mhz :: number(), + profile :: [number()], + tx_height_m :: number(), + rx_height_m :: number() + ) :: float() + def diffraction_loss(_distance_m, _frequency_mhz, profile, _tx_h, _rx_h) when length(profile) < 3, do: 0.0 + + def diffraction_loss(distance_m, frequency_mhz, profile, tx_height_m, rx_height_m) do + wavelength_m = @c_mps / (frequency_mhz * 1.0e6) + + [first | _] = profile + last = List.last(profile) + tx_z = first + tx_height_m + rx_z = last + rx_height_m + + n = length(profile) + step_m = distance_m / (n - 1) + + {best_v, _} = + profile + |> Enum.with_index() + |> Enum.drop(1) + |> Enum.drop(-1) + |> Enum.reduce({-1.0e9, -1}, &accumulate_v(&1, &2, distance_m, step_m, wavelength_m, tx_z, rx_z)) + + if best_v == -1.0e9 do + 0.0 + else + max(0.0, knife_edge_loss(best_v)) + end + end + + defp accumulate_v({h, i}, {best_v, best_i}, distance_m, step_m, wavelength_m, tx_z, rx_z) do + d1 = i * step_m + d2 = distance_m - d1 + los_z = tx_z + (rx_z - tx_z) * (d1 / distance_m) + excess = h - los_z + v = if excess <= 0.0, do: -1.0e9, else: excess * :math.sqrt(2.0 * distance_m / (wavelength_m * d1 * d2)) + + if v > best_v, do: {v, i}, else: {best_v, best_i} + end +end diff --git a/lib/towerops/coverages/raster.ex b/lib/towerops/coverages/raster.ex new file mode 100644 index 00000000..3c4c9576 --- /dev/null +++ b/lib/towerops/coverages/raster.ex @@ -0,0 +1,190 @@ +defmodule Towerops.Coverages.Raster do + @moduledoc """ + Writes coverage outputs to disk: a Float32 GeoTIFF holding the raw + RSSI grid (one band, dBm values, NaN for "no coverage"), and a + cnHeat-style colored PNG for direct overlay on Leaflet. + + Both outputs are written to + `priv/static/coverage///` and served by + the existing `Plug.Static` pipeline. + + Conversion uses GDAL command-line tools (already installed via + `gdal-bin` in production and `brew install gdal` in dev). + """ + + alias Towerops.Coverages.Coverage + + require Logger + + @nan_sentinel 1.0e30 + + # cnHeat-ish palette: green strong, yellow good, orange marginal, red poor. + @palette """ + -50 0 200 0 230 + -65 200 230 0 230 + -75 255 165 0 220 + -85 220 60 60 200 + -95 100 0 0 160 + nv 0 0 0 0 + """ + + @doc """ + Writes both rasters to a per-coverage directory under + `priv/static/coverage/`. Returns `{:ok, %{tif: rel_path, png: rel_path, + bbox: {min_lat, min_lon, max_lat, max_lon}}}`. + + `pixels` is a flat list of dBm values (or `:nan` for no-coverage) + laid out row-major, north-to-south, west-to-east, with `ncols` per + row and `nrows` rows total. `bbox` is `{min_lat, min_lon, max_lat, + max_lon}` in WGS84. + """ + @spec write(Coverage.t(), [number() | :nan], %{ + ncols: pos_integer(), + nrows: pos_integer(), + bbox: {number(), number(), number(), number()} + }) :: + {:ok, %{tif: String.t(), png: String.t(), bbox: tuple()}} | {:error, term()} + def write(%Coverage{} = coverage, pixels, %{ncols: ncols, nrows: nrows, bbox: bbox}) when is_list(pixels) do + expected = ncols * nrows + + if length(pixels) == expected do + do_write(coverage, pixels, ncols, nrows, bbox) + else + {:error, {:pixel_count_mismatch, %{expected: expected, got: length(pixels)}}} + end + end + + defp do_write(coverage, pixels, ncols, nrows, {min_lat, min_lon, max_lat, max_lon} = bbox) do + out_dir = output_dir(coverage) + File.mkdir_p!(out_dir) + + raw_path = Path.join(out_dir, "rssi.f32") + vrt_path = Path.join(out_dir, "rssi.vrt") + tif_path = Path.join(out_dir, "rssi.tif") + png_path = Path.join(out_dir, "rssi.png") + palette_path = Path.join(out_dir, "palette.txt") + + with :ok <- write_raw(raw_path, pixels), + :ok <- write_vrt(vrt_path, raw_path, ncols, nrows, min_lon, max_lat, max_lon, min_lat), + :ok <- run_gdal_translate(vrt_path, tif_path), + :ok <- File.write(palette_path, @palette), + :ok <- run_gdaldem(tif_path, palette_path, png_path) do + _ = File.rm(raw_path) + _ = File.rm(vrt_path) + _ = File.rm(palette_path) + + {:ok, + %{ + tif: relative_static_path(coverage, "rssi.tif"), + png: relative_static_path(coverage, "rssi.png"), + bbox: bbox + }} + end + end + + @doc "Removes the coverage's output directory if it exists." + @spec cleanup(Coverage.t()) :: :ok + def cleanup(%Coverage{} = coverage) do + out_dir = output_dir(coverage) + if File.dir?(out_dir), do: File.rm_rf!(out_dir) + :ok + end + + defp output_dir(%Coverage{organization_id: org_id, id: id}) do + Path.join([static_dir(), "coverage", to_string(org_id), to_string(id)]) + end + + defp static_dir do + Application.app_dir(:towerops, "priv/static") + end + + defp relative_static_path(coverage, filename) do + Path.join([ + "/coverage", + to_string(coverage.organization_id), + to_string(coverage.id), + filename + ]) + end + + defp write_raw(path, pixels) do + bin = + pixels + |> Enum.map(&pixel_to_float/1) + |> Enum.map(fn f -> <> end) + |> IO.iodata_to_binary() + + File.write(path, bin) + end + + defp pixel_to_float(:nan), do: @nan_sentinel + defp pixel_to_float(n) when is_number(n), do: n * 1.0 + + # Build a minimal VRT that wraps the raw Float32 file. We give it a + # geotransform (north-up, square pixels in degrees) so gdal_translate + # can write a properly georeferenced GeoTIFF. + defp write_vrt(vrt_path, raw_path, ncols, nrows, min_lon, max_lat, max_lon, min_lat) do + pixel_size_x = (max_lon - min_lon) / ncols + pixel_size_y = (max_lat - min_lat) / nrows + + vrt = """ + + EPSG:4326 + #{min_lon}, #{pixel_size_x}, 0.0, #{max_lat}, 0.0, #{-pixel_size_y} + + #{Path.basename(raw_path)} + 0 + 4 + #{ncols * 4} + LSB + #{@nan_sentinel} + + + """ + + File.write(vrt_path, vrt) + end + + defp run_gdal_translate(vrt_path, tif_path) do + case System.cmd( + "gdal_translate", + [ + "-q", + "-of", + "GTiff", + "-co", + "COMPRESS=DEFLATE", + "-co", + "TILED=YES", + "-a_nodata", + "#{@nan_sentinel}", + vrt_path, + tif_path + ], + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {output, code} -> {:error, {:gdal_translate_failed, code, output}} + end + end + + defp run_gdaldem(tif_path, palette_path, png_path) do + case System.cmd( + "gdaldem", + [ + "color-relief", + "-of", + "PNG", + "-alpha", + "-nearest_color_entry", + tif_path, + palette_path, + png_path + ], + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {output, code} -> {:error, {:gdaldem_failed, code, output}} + end + end +end diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex index b29b8680..af6d17e3 100644 --- a/lib/towerops/workers/coverage_worker.ex +++ b/lib/towerops/workers/coverage_worker.ex @@ -2,20 +2,24 @@ 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. + Pipeline: - ## Org scoping + 1. Resolve antenna and centre coordinates. + 2. Compute a WGS84 bbox of the coverage area from `radius_m`. + 3. Pull a terrain elevation grid for that bbox via `Towerops.Lidar`. + 4. For each output pixel, sample a path profile, run propagation, + apply the antenna pattern, and produce an RSSI in dBm. + 5. Write GeoTIFF + colored PNG to `priv/static/coverage///`. - 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. + Compute is parallelised across BEAM schedulers via `Task.async_stream/3` + on row bands. Progress is broadcast on + `Towerops.Coverages.topic/1` and `org_topic/1` so LiveViews can render + live status. + + Org scoping is enforced: job args carry `organization_id` and the + worker refuses to run if the loaded coverage's org doesn't match. """ + use Oban.Worker, queue: :coverage, max_attempts: 3, @@ -26,11 +30,20 @@ defmodule Towerops.Workers.CoverageWorker do ] alias Towerops.Coverages + alias Towerops.Coverages.Antenna alias Towerops.Coverages.Coverage + alias Towerops.Coverages.Profile + alias Towerops.Coverages.Propagation + alias Towerops.Coverages.Raster alias Towerops.Repo require Logger + # Number of elevation samples per profile. Higher = more accurate + # diffraction detection, lower = faster compute. 64 is a good + # trade-off for WISP-scale paths. + @profile_samples 64 + @impl Oban.Worker def perform(%Oban.Job{args: %{"coverage_id" => coverage_id, "organization_id" => organization_id}}) do case Repo.get(Coverage, coverage_id) do @@ -39,6 +52,7 @@ defmodule Towerops.Workers.CoverageWorker do :ok %Coverage{organization_id: ^organization_id} = coverage -> + coverage = Repo.preload(coverage, :site) run(coverage) %Coverage{} -> @@ -52,25 +66,274 @@ defmodule Towerops.Workers.CoverageWorker do end defp run(coverage) do - {:ok, coverage} = Coverages.mark_status(coverage, "computing", %{progress_pct: 1}) - Coverages.broadcast(coverage, {:coverage_status, :computing, 1}) + update_progress(coverage, "computing", 1) - Logger.info("CoverageWorker: compute pipeline not yet implemented for #{coverage.id}") + with {:ok, antenna} <- resolve_antenna(coverage), + {:ok, {lat, lon}} <- resolve_location(coverage), + {:ok, bbox} <- compute_bbox(coverage, lat, lon), + _ = update_progress(coverage, "computing", 5), + {:ok, grid} <- fetch_terrain(bbox, coverage.cell_size_m, lat), + _ = update_progress(coverage, "computing", 30), + {:ok, output} <- compute_pixels(coverage, antenna, lat, lon, bbox, grid), + _ = update_progress(coverage, "computing", 90), + {:ok, paths} <- Raster.write(coverage, output.pixels, output.dims) do + finalize(coverage, paths) + else + {:error, reason} -> fail(coverage, reason) + end + end - error = - "Coverage compute is not yet implemented. The pipeline (LIDAR + buildings + ITM) " <> - "is under development." + defp resolve_antenna(%Coverage{antenna_slug: slug}) do + case Antenna.get(slug) do + nil -> {:error, {:unknown_antenna, slug}} + antenna -> {:ok, antenna} + end + end - {:ok, coverage} = + defp resolve_location(coverage) do + case Coverage.location(coverage) do + nil -> {:error, :missing_location} + coords -> {:ok, coords} + end + end + + defp compute_bbox(%Coverage{radius_m: radius}, lat, lon) do + # Approximate a WGS84 bbox containing a circle of `radius` metres + # around (lat, lon). 1° latitude ≈ 111 km; longitude scales with cos(lat). + deg_lat = radius / 111_000.0 + deg_lon = radius / (111_000.0 * :math.cos(lat * :math.pi() / 180.0)) + {:ok, {lon - deg_lon, lat - deg_lat, lon + deg_lon, lat + deg_lat}} + end + + defp fetch_terrain({_w, _s, _e, _n} = bbox, cell_size_m, lat) do + # Convert metres to degrees at the given latitude (longitude axis is + # narrowest, so use that for the worst-case spacing). + cell_size_deg = cell_size_m / (111_000.0 * :math.cos(lat * :math.pi() / 180.0)) + + case terrain_source().get_elevation_grid(bbox, cell_size_deg) do + {:ok, grid} -> {:ok, grid} + {:error, reason} -> {:error, {:terrain_unavailable, reason}} + end + end + + defp terrain_source do + Application.get_env(:towerops, :coverage_terrain_module, Towerops.Lidar) + end + + defp compute_pixels(coverage, antenna, lat, lon, bbox, grid) do + %{ncols: ncols, nrows: nrows} = grid + {min_lon, min_lat, max_lon, max_lat} = bbox + + cell_lat = (max_lat - min_lat) / nrows + cell_lon = (max_lon - min_lon) / ncols + + # TX position: site/coverage centre, plus tower-mount height above ground. + base_elev = elevation_at_or_zero(grid, lat, lon) + tx_height = (coverage.height_agl_m || 0.0) + (coverage.height_above_rooftop_m || 0.0) + tx_z = base_elev + tx_height + + eirp = Coverage.eirp_dbm(coverage, antenna.gain_dbi) + + rows = + 0..(nrows - 1) + |> Task.async_stream( + fn r -> + row_lat = max_lat - (r + 0.5) * cell_lat + + for c <- 0..(ncols - 1) do + pixel_lon = min_lon + (c + 0.5) * cell_lon + + compute_pixel( + coverage, + antenna, + {lat, lon}, + tx_z, + {row_lat, pixel_lon}, + grid, + eirp + ) + end + end, + ordered: true, + max_concurrency: System.schedulers_online(), + timeout: :infinity + ) + |> Enum.map(fn {:ok, row} -> row end) + + pixels = Enum.flat_map(rows, & &1) + + {:ok, + %{ + pixels: pixels, + dims: %{ + ncols: ncols, + nrows: nrows, + bbox: {min_lat, min_lon, max_lat, max_lon} + } + }} + end + + defp compute_pixel(coverage, antenna, tx_latlon, tx_z, rx_latlon, grid, eirp) do + distance_m = Profile.great_circle_distance_m(tx_latlon, rx_latlon) + + cond do + distance_m < 1.0 -> + eirp + + distance_m > coverage.radius_m * 1.05 -> + :nan + + true -> + do_compute_pixel(coverage, antenna, tx_latlon, tx_z, rx_latlon, grid, eirp, distance_m) + end + end + + defp do_compute_pixel( + coverage, + antenna, + {tx_lat, tx_lon} = tx_latlon, + tx_z, + {rx_lat, rx_lon} = rx_latlon, + grid, + eirp, + distance_m + ) do + # 1. Path profile (terrain heights from TX → RX). + samples = max(3, min(@profile_samples, ceil(distance_m / coverage.cell_size_m) + 2)) + profile = Profile.sample(grid, tx_latlon, rx_latlon, samples) + + # Replace TX endpoint with the absolute Z (so diffraction sees the correct + # antenna height). The receiver height is added inside Propagation. + [_old_first | rest] = profile + profile = [tx_z - (coverage.height_agl_m + (coverage.height_above_rooftop_m || 0.0)) | rest] + + # 2. Antenna pattern attenuation at this bearing/elevation. + bearing_offset = bearing_offset_deg(tx_lat, tx_lon, rx_lat, rx_lon, coverage.azimuth_deg) + elev_offset = elevation_offset_deg(tx_z, profile, coverage.downtilt_deg, distance_m) + ant_atten = Antenna.attenuation_db(antenna, bearing_offset, elev_offset) + + # 3. Path loss over the profile. + loss = + Propagation.path_loss( + distance_m, + coverage.frequency_mhz * 1.0, + profile, + coverage.height_agl_m + (coverage.height_above_rooftop_m || 0.0), + coverage.receiver_height_m || 3.0 + ) + + # 4. Foliage attenuation: linear scaling with the user's slider (0..100 + # maps to 0..15 dB extra loss). A real model would use Weissberger. + foliage_extra = (coverage.foliage_tuning || 0) * 0.15 + + rssi = eirp - ant_atten - loss - foliage_extra + + if rssi < (coverage.rx_threshold_dbm || -90.0) do + :nan + else + max(rssi, -130.0) + end + end + + defp elevation_at_or_zero(grid, lat, lon) do + case Profile.elevation_at(grid, lat, lon) do + nil -> 0.0 + elev -> elev + end + end + + # Bearing from TX to RX in degrees (0 = north, clockwise), then return + # the offset relative to the antenna's boresight azimuth. + defp bearing_offset_deg(tx_lat, tx_lon, rx_lat, rx_lon, azimuth_deg) do + phi1 = tx_lat * :math.pi() / 180.0 + phi2 = rx_lat * :math.pi() / 180.0 + dlam = (rx_lon - tx_lon) * :math.pi() / 180.0 + + y = :math.sin(dlam) * :math.cos(phi2) + x = :math.cos(phi1) * :math.sin(phi2) - :math.sin(phi1) * :math.cos(phi2) * :math.cos(dlam) + bearing = :math.atan2(y, x) * 180.0 / :math.pi() + bearing = if bearing < 0, do: bearing + 360.0, else: bearing + + offset = bearing - azimuth_deg + + cond do + offset > 180.0 -> offset - 360.0 + offset < -180.0 -> offset + 360.0 + true -> offset + end + end + + # Elevation angle from antenna to receiver, then offset relative to + # the configured downtilt. Positive offset = above downtilt direction. + defp elevation_offset_deg(_tx_z, profile, downtilt_deg, distance_m) do + rx_z_terrain = List.last(profile) || 0.0 + [first | _] = profile + delta_z = rx_z_terrain - first + angle_deg = :math.atan2(delta_z, distance_m) * 180.0 / :math.pi() + -downtilt_deg - angle_deg + end + + defp update_progress(coverage, status, pct) do + {:ok, updated} = Coverages.mark_status(coverage, status, %{progress_pct: pct}) + Coverages.broadcast(updated, {:coverage_status, String.to_existing_atom(status), pct}) + updated + end + + defp finalize(coverage, %{tif: tif, png: png, bbox: {min_lat, min_lon, max_lat, max_lon}}) do + {:ok, updated} = + Coverages.mark_status(coverage, "ready", %{ + progress_pct: 100, + error_message: nil, + computed_at: DateTime.truncate(DateTime.utc_now(), :second), + raster_path: tif, + png_path: png, + bbox_min_lat: min_lat, + bbox_min_lon: min_lon, + bbox_max_lat: max_lat, + bbox_max_lon: max_lon + }) + + Coverages.broadcast(updated, {:coverage_status, :ready, 100}) + :ok + end + + defp fail(coverage, reason) do + message = describe_error(reason) + Logger.warning("CoverageWorker: failed for #{coverage.id}: #{message}") + + {:ok, updated} = Coverages.mark_status(coverage, "failed", %{ - error_message: error, + error_message: message, progress_pct: 0 }) - Coverages.broadcast(coverage, {:coverage_status, :failed, error}) + Coverages.broadcast(updated, {:coverage_status, :failed, message}) - # Return :ok so Oban marks the job complete — the failure is on - # the coverage record, not Oban-retryable, while the stub is in place. + # Don't let Oban retry — the failure is on the coverage record. :ok end + + defp describe_error({:terrain_unavailable, :no_tile}), + do: + "No LIDAR terrain data available for this site. " <> + "Texas-only coverage is supported; verify the site is inside the catalog area." + + defp describe_error({:terrain_unavailable, :grid_too_large}), + do: "Coverage area is too large for the chosen cell size. Use a coarser cell or smaller radius." + + defp describe_error({:terrain_unavailable, :nodata}), do: "LIDAR tiles for this area returned no data." + + defp describe_error({:terrain_unavailable, reason}), do: "Terrain fetch failed: #{inspect(reason)}" + + defp describe_error({:unknown_antenna, slug}), do: "Antenna #{inspect(slug)} is not in the registry." + + defp describe_error(:missing_location), do: "Site has no coordinates set and no per-coverage override was supplied." + + defp describe_error({:gdal_translate_failed, code, output}), + do: "GeoTIFF conversion failed (#{code}): #{String.slice(output, 0, 500)}" + + defp describe_error({:gdaldem_failed, code, output}), + do: "PNG colorisation failed (#{code}): #{String.slice(output, 0, 500)}" + + defp describe_error(other), do: "Compute failed: #{inspect(other)}" end diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex index 1ccd25db..a5f7ace2 100644 --- a/lib/towerops_web/live/coverage_live/form.ex +++ b/lib/towerops_web/live/coverage_live/form.ex @@ -33,13 +33,18 @@ defmodule ToweropsWeb.CoverageLive.Form do receiver_height_m: 3.0, rx_threshold_dbm: -90.0, cell_size_m: 10, - radius_m: 5_000 + radius_m: 5_000, + tx_power_dbm: 18.0, + cable_loss_db: 0.0, + sm_gain_dbi: 0.0, + height_above_rooftop_m: 0.0, + foliage_tuning: 0 } socket |> assign(:page_title, t("New Coverage")) |> assign(:coverage, coverage) - |> assign(:form, to_form(Coverages.change_coverage(coverage))) + |> assign_form(Coverages.change_coverage(coverage)) end defp apply_action(socket, :edit, %{"id" => id}) do @@ -48,7 +53,7 @@ defmodule ToweropsWeb.CoverageLive.Form do socket |> assign(:page_title, t("Edit Coverage")) |> assign(:coverage, coverage) - |> assign(:form, to_form(Coverages.change_coverage(coverage))) + |> assign_form(Coverages.change_coverage(coverage)) end @impl true @@ -58,7 +63,7 @@ defmodule ToweropsWeb.CoverageLive.Form do |> Coverages.change_coverage(attrs) |> Map.put(:action, :validate) - {:noreply, assign(socket, :form, to_form(changeset))} + {:noreply, assign_form(socket, changeset)} end @impl true @@ -75,7 +80,7 @@ defmodule ToweropsWeb.CoverageLive.Form do |> push_navigate(to: ~p"/coverage/#{coverage.id}")} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} + {:noreply, assign_form(socket, changeset)} end end @@ -88,7 +93,54 @@ defmodule ToweropsWeb.CoverageLive.Form do |> push_navigate(to: ~p"/coverage/#{coverage.id}")} {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :form, to_form(changeset))} + {:noreply, assign_form(socket, changeset)} + end + end + + defp assign_form(socket, changeset) do + form = to_form(changeset) + eirp = compute_eirp_from_form(form) + + socket + |> assign(:form, form) + |> assign(:computed_eirp, eirp) + end + + defp compute_eirp_from_form(form) do + tx_power = form_number(form, :tx_power_dbm, 0.0) + cable_loss = form_number(form, :cable_loss_db, 0.0) + + gain = + case form[:antenna_slug].value do + slug when is_binary(slug) and slug != "" -> + case Antenna.get(slug) do + %Antenna{gain_dbi: g} -> g + _ -> 0.0 + end + + _ -> + 0.0 + end + + tx_power + gain - cable_loss + end + + defp form_number(form, field, default) do + case form[field].value do + nil -> + default + + "" -> + default + + n when is_number(n) -> + n * 1.0 + + s when is_binary(s) -> + case Float.parse(s) do + {n, _} -> n + :error -> default + end end end @@ -103,4 +155,27 @@ defmodule ToweropsWeb.CoverageLive.Form do end)} end) end + + @doc false + def site_lat_placeholder(sites, form) do + case selected_site(sites, form) do + %{latitude: lat} when is_number(lat) -> "site: #{lat}" + _ -> "" + end + end + + @doc false + def site_lon_placeholder(sites, form) do + case selected_site(sites, form) do + %{longitude: lon} when is_number(lon) -> "site: #{lon}" + _ -> "" + end + end + + defp selected_site(sites, form) do + case form[:site_id].value do + id when is_binary(id) and id != "" -> Enum.find(sites, &(&1.id == id)) + _ -> nil + 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 index 10c2fe2b..b0a7b984 100644 --- a/lib/towerops_web/live/coverage_live/form.html.heex +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -16,6 +16,14 @@ "Configure the antenna and RF parameters. The compute pipeline will produce a heatmap on save." )} + <:actions> + + EIRP: + + {Float.round(@computed_eirp, 1)} dBm + + + <.form @@ -59,6 +67,37 @@ /> + <%!-- Location override --%> +
+

+ {t("Location")} +

+

+ {t("Leave blank to use the parent site's coordinates.")} +

+ +
+ <.input + field={@form[:latitude_override]} + type="number" + label={t("Latitude")} + step="0.000001" + min="-90" + max="90" + placeholder={site_lat_placeholder(@sites, @form)} + /> + <.input + field={@form[:longitude_override]} + type="number" + label={t("Longitude")} + step="0.000001" + min="-180" + max="180" + placeholder={site_lon_placeholder(@sites, @form)} + /> +
+
+ <%!-- Mounting --%>

@@ -75,6 +114,15 @@ required /> + <.input + field={@form[:height_above_rooftop_m]} + type="number" + label={t("Height above rooftop (m)")} + step="0.1" + min="0" + max="100" + /> + <.input field={@form[:azimuth_deg]} type="number" @@ -88,11 +136,20 @@ <.input field={@form[:downtilt_deg]} type="number" - label={t("Downtilt (°, positive = down)")} + label={t("Tilt (°, positive = down)")} step="0.1" min="-10" max="30" /> + + <.input + field={@form[:tx_clearance_m]} + type="number" + label={t("TX clearance (m, distance to nearest obstacle)")} + step="0.1" + min="0" + max="1000" + />

<%!-- RF parameters --%> @@ -111,15 +168,53 @@ required /> +
+ <.input + field={@form[:tx_power_dbm]} + type="number" + label={t("TX power (dBm)")} + step="0.1" + min="-10" + max="50" + required + /> + <.input + field={@form[:cable_loss_db]} + type="number" + label={t("Cable loss (dB)")} + step="0.1" + min="0" + max="20" + /> +
+ <.input - field={@form[:eirp_dbm]} + field={@form[:sm_gain_dbi]} type="number" - label={t("EIRP (dBm)")} + label={t("SM gain (dBi)")} step="0.1" min="0" - max="60" - required + max="40" /> + +
+ + +
<%!-- Coverage extent --%> @@ -131,7 +226,7 @@ <.input field={@form[:radius_m]} type="number" - label={t("Radius (m)")} + label={t("Range / radius (m)")} step="100" min="500" max="40000" diff --git a/lib/towerops_web/live/coverage_live/index.html.heex b/lib/towerops_web/live/coverage_live/index.html.heex index c5048971..92c5b156 100644 --- a/lib/towerops_web/live/coverage_live/index.html.heex +++ b/lib/towerops_web/live/coverage_live/index.html.heex @@ -79,7 +79,7 @@ {t("Freq (MHz)")} - {t("EIRP (dBm)")} + {t("TX (dBm)")} {t("Radius (m)")} @@ -115,7 +115,7 @@ {coverage.frequency_mhz} - {coverage.eirp_dbm} + {coverage.tx_power_dbm} {coverage.radius_m} diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex index df2d2abc..cda88b9a 100644 --- a/lib/towerops_web/live/coverage_live/show.ex +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -4,11 +4,14 @@ defmodule ToweropsWeb.CoverageLive.Show do alias Towerops.Coverages alias Towerops.Coverages.Antenna + alias Towerops.Coverages.Coverage @impl true def mount(%{"id" => id}, _session, socket) do organization = socket.assigns.current_scope.organization coverage = Coverages.get_coverage!(organization.id, id) + antenna = Antenna.get(coverage.antenna_slug) + eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0) if connected?(socket), do: Coverages.subscribe(coverage.id) @@ -17,7 +20,8 @@ defmodule ToweropsWeb.CoverageLive.Show do |> assign(:page_title, coverage.name) |> assign(:organization, organization) |> assign(:coverage, coverage) - |> assign(:antenna, Antenna.get(coverage.antenna_slug))} + |> assign(:antenna, antenna) + |> assign(:eirp_dbm, eirp)} end @impl true @@ -59,7 +63,13 @@ defmodule ToweropsWeb.CoverageLive.Show do coverage = Coverages.get_coverage!(socket.assigns.organization.id, socket.assigns.coverage.id) - {:noreply, assign(socket, :coverage, coverage)} + eirp = + Coverage.eirp_dbm( + coverage, + (socket.assigns.antenna && socket.assigns.antenna.gain_dbi) || 0.0 + ) + + {:noreply, socket |> assign(:coverage, coverage) |> assign(:eirp_dbm, eirp)} end @doc false @@ -75,6 +85,22 @@ defmodule ToweropsWeb.CoverageLive.Show do def status_badge_class(_), do: "bg-gray-100 text-gray-700" + @doc false + def coverage_lat(coverage) do + case Coverage.location(coverage) do + {lat, _lon} -> lat + nil -> nil + end + end + + @doc false + def coverage_lon(coverage) do + case Coverage.location(coverage) do + {_lat, lon} -> lon + nil -> nil + end + end + attr :label, :string, required: true attr :value, :string, required: true diff --git a/lib/towerops_web/live/coverage_live/show.html.heex b/lib/towerops_web/live/coverage_live/show.html.heex index 2a121409..86efe046 100644 --- a/lib/towerops_web/live/coverage_live/show.html.heex +++ b/lib/towerops_web/live/coverage_live/show.html.heex @@ -94,24 +94,94 @@ <%!-- 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.")} -

+ <%= cond do %> + <% @coverage.png_path && @coverage.status == "ready" -> %> +
+
+ <%!-- Opacity slider + cnHeat-style legend --%> +
+
+ + + + 70% + +
+ +
+ {t("Signal:")} + + ≥ -55 dBm + + + -65 + + + -75 + + + -85 + + + -95 + + + {t("(weaker is darker)")} + +
+
+ <% true -> %> +
+
+ <.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 %>
@@ -133,7 +203,43 @@ <.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("TX power")} value={"#{@coverage.tx_power_dbm} dBm"} /> + <.param_row + :if={@coverage.cable_loss_db && @coverage.cable_loss_db > 0} + label={t("Cable loss")} + value={"#{@coverage.cable_loss_db} dB"} + /> + <.param_row label={t("EIRP")} value={"#{Float.round(@eirp_dbm, 1)} dBm"} /> + <.param_row + :if={@coverage.sm_gain_dbi && @coverage.sm_gain_dbi > 0} + label={t("SM gain")} + value={"#{@coverage.sm_gain_dbi} dBi"} + /> + <.param_row + :if={@coverage.tx_clearance_m} + label={t("TX clearance")} + value={"#{@coverage.tx_clearance_m} m"} + /> + <.param_row + :if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0} + label={t("Foliage tuning")} + value={"#{@coverage.foliage_tuning}"} + /> + <.param_row + :if={@coverage.height_above_rooftop_m && @coverage.height_above_rooftop_m > 0} + label={t("Above rooftop")} + value={"#{@coverage.height_above_rooftop_m} m"} + /> + <.param_row + :if={@coverage.latitude_override} + label={t("Lat (override)")} + value={"#{@coverage.latitude_override}"} + /> + <.param_row + :if={@coverage.longitude_override} + label={t("Lon (override)")} + value={"#{@coverage.longitude_override}"} + /> <.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"} /> diff --git a/priv/repo/migrations/20260506185952_add_radio_fields_to_coverages.exs b/priv/repo/migrations/20260506185952_add_radio_fields_to_coverages.exs new file mode 100644 index 00000000..70979729 --- /dev/null +++ b/priv/repo/migrations/20260506185952_add_radio_fields_to_coverages.exs @@ -0,0 +1,28 @@ +defmodule Towerops.Repo.Migrations.AddRadioFieldsToCoverages do + use Ecto.Migration + + def change do + alter table(:coverages) do + # Replace stored EIRP with TX power + cable loss; EIRP is now derived + # at display time as `tx_power_dbm + antenna.gain_dbi - cable_loss_db`. + remove :eirp_dbm, :float, null: false + add :tx_power_dbm, :float, null: false, default: 18.0 + add :cable_loss_db, :float, null: false, default: 0.0 + + # Subscriber-module antenna gain at the receiver end (link-budget input). + add :sm_gain_dbi, :float, null: false, default: 0.0 + + # Optional lat/lon overrides; default uses the parent site's coordinates. + add :latitude_override, :float + add :longitude_override, :float + + # Mounting offsets and clearance (stored SI; UI may render ft). + add :height_above_rooftop_m, :float, null: false, default: 0.0 + add :tx_clearance_m, :float + + # Foliage attenuation adjustment, 0..100 scale (0 = no extra attenuation, + # 100 = heavy foliage scaling). + add :foliage_tuning, :integer, null: false, default: 0 + end + end +end diff --git a/priv/antennas/test-omni-5ghz.ant b/test/support/fixtures/antennas/test-omni-5ghz.ant similarity index 100% rename from priv/antennas/test-omni-5ghz.ant rename to test/support/fixtures/antennas/test-omni-5ghz.ant diff --git a/priv/antennas/test-sector-90-5ghz.ant b/test/support/fixtures/antennas/test-sector-90-5ghz.ant similarity index 100% rename from priv/antennas/test-sector-90-5ghz.ant rename to test/support/fixtures/antennas/test-sector-90-5ghz.ant diff --git a/test/support/fixtures/coverages_fixtures.ex b/test/support/fixtures/coverages_fixtures.ex index 709f1d59..2e8c17cd 100644 --- a/test/support/fixtures/coverages_fixtures.ex +++ b/test/support/fixtures/coverages_fixtures.ex @@ -8,12 +8,16 @@ defmodule Towerops.CoveragesFixtures do def valid_coverage_attrs(attrs \\ %{}) do Enum.into(attrs, %{ name: "Coverage #{System.unique_integer([:positive])}", - antenna_slug: "test-omni-5ghz", + antenna_slug: "simulate-isotropic-omni-0", height_agl_m: 30.0, azimuth_deg: 0.0, downtilt_deg: 2.0, frequency_mhz: 5500, - eirp_dbm: 36.0, + tx_power_dbm: 18.0, + cable_loss_db: 0.0, + sm_gain_dbi: 0.0, + height_above_rooftop_m: 0.0, + foliage_tuning: 0, radius_m: 5_000, cell_size_m: 10, receiver_height_m: 3.0, diff --git a/test/support/stub_terrain.ex b/test/support/stub_terrain.ex new file mode 100644 index 00000000..429e42c3 --- /dev/null +++ b/test/support/stub_terrain.ex @@ -0,0 +1,29 @@ +defmodule Towerops.Test.StubTerrain do + @moduledoc """ + In-memory stand-in for `Towerops.Lidar` used by coverage worker tests. + + Tests configure `:towerops, :coverage_terrain_module` to this module + and put a precomputed grid into the process dictionary; the worker's + `get_elevation_grid/2` call returns that grid verbatim. + + Returning the grid via the process dictionary means each test owns + its own terrain without setting up a global agent. + """ + + @doc "Sets the grid that subsequent calls will return." + @spec put_grid(map() | {:error, term()}) :: :ok + def put_grid(result) do + Process.put(__MODULE__, result) + :ok + end + + @doc "Mirrors the relevant subset of `Towerops.Lidar.get_elevation_grid/2`." + @spec get_elevation_grid(tuple(), number()) :: {:ok, map()} | {:error, term()} + def get_elevation_grid(_bbox, _cell_size_deg) do + case Process.get(__MODULE__) do + nil -> {:error, :no_stub_grid_configured} + {:error, _} = err -> err + grid when is_map(grid) -> {:ok, grid} + end + end +end diff --git a/test/towerops/coverages/antenna_test.exs b/test/towerops/coverages/antenna_test.exs index 55daa6c8..85dc446f 100644 --- a/test/towerops/coverages/antenna_test.exs +++ b/test/towerops/coverages/antenna_test.exs @@ -3,9 +3,11 @@ defmodule Towerops.Coverages.AntennaTest do alias Towerops.Coverages.Antenna + @fixtures_dir Path.expand("../../support/fixtures/antennas", __DIR__) + describe "parse/2" do test "parses the bundled omni fixture" do - path = Application.app_dir(:towerops, "priv/antennas/test-omni-5ghz.ant") + path = Path.join(@fixtures_dir, "test-omni-5ghz.ant") assert {:ok, %Antenna{} = ant} = Antenna.parse_file(path) assert ant.slug == "test-omni-5ghz" @@ -21,7 +23,7 @@ defmodule Towerops.Coverages.AntennaTest do 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") + path = Path.join(@fixtures_dir, "test-sector-90-5ghz.ant") assert {:ok, %Antenna{} = ant} = Antenna.parse_file(path) assert ant.slug == "test-sector-90-5ghz" @@ -90,7 +92,7 @@ defmodule Towerops.Coverages.AntennaTest do describe "lookup/3" do setup do - path = Application.app_dir(:towerops, "priv/antennas/test-sector-90-5ghz.ant") + path = Path.join(@fixtures_dir, "test-sector-90-5ghz.ant") {:ok, ant} = Antenna.parse_file(path) %{antenna: ant} end diff --git a/test/towerops/coverages/coverage_test.exs b/test/towerops/coverages/coverage_test.exs index 455edabe..2ec4cecb 100644 --- a/test/towerops/coverages/coverage_test.exs +++ b/test/towerops/coverages/coverage_test.exs @@ -6,12 +6,16 @@ defmodule Towerops.Coverages.CoverageTest do @valid_attrs %{ name: "North sector", - antenna_slug: "test-omni-5ghz", + antenna_slug: "simulate-isotropic-omni-0", height_agl_m: 30.0, azimuth_deg: 0.0, downtilt_deg: 2.0, frequency_mhz: 5500, - eirp_dbm: 36.0, + tx_power_dbm: 18.0, + cable_loss_db: 0.0, + sm_gain_dbi: 0.0, + height_above_rooftop_m: 0.0, + foliage_tuning: 0, radius_m: 5000, cell_size_m: 10, receiver_height_m: 3.0, @@ -37,7 +41,7 @@ defmodule Towerops.Coverages.CoverageTest do :height_agl_m, :azimuth_deg, :frequency_mhz, - :eirp_dbm, + :tx_power_dbm, :radius_m, :cell_size_m, :organization_id, @@ -108,14 +112,14 @@ defmodule Towerops.Coverages.CoverageTest do 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)) + test "rejects tx_power_dbm outside -10..50" do + bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :tx_power_dbm, -20.0)) refute bad.valid? - assert changeset_error?(bad, :eirp_dbm) + assert changeset_error?(bad, :tx_power_dbm) - bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :eirp_dbm, 100.0)) + bad = Coverage.changeset(%Coverage{}, Map.put(@valid_attrs, :tx_power_dbm, 100.0)) refute bad.valid? - assert changeset_error?(bad, :eirp_dbm) + assert changeset_error?(bad, :tx_power_dbm) end test "rejects radius_m outside 500..40_000" do diff --git a/test/towerops/coverages/profile_test.exs b/test/towerops/coverages/profile_test.exs new file mode 100644 index 00000000..ffbeaa3a --- /dev/null +++ b/test/towerops/coverages/profile_test.exs @@ -0,0 +1,97 @@ +defmodule Towerops.Coverages.ProfileTest do + use ExUnit.Case, async: true + + alias Towerops.Coverages.Profile + + # Build a 10×10 elevation grid covering [-1..0] lon × [0..1] lat at 0.1° cells. + # Each cell holds the elevation `lon_index * 10 + lat_index` (just to give us + # something predictable to look up). + defp test_grid(cells) do + %{ + ncols: 10, + nrows: 10, + xllcorner: -1.0, + yllcorner: 0.0, + cellsize: 0.1, + nodata_value: -9999.0, + cells: cells + } + end + + defp flat_grid(elev) do + test_grid(List.duplicate(List.duplicate(elev, 10), 10)) + end + + describe "elevation_at/3" do + test "returns the cell value at a known coordinate" do + # Build a grid where the cell at (row=0, col=5) has value 42. + # AAIGrid row 0 is the TOP-most row (highest latitude). + rows = + for r <- 0..9 do + for c <- 0..9 do + if r == 0 and c == 5, do: 42.0, else: 0.0 + end + end + + grid = test_grid(rows) + # Top row covers lat in [0.9, 1.0); col 5 covers lon in [-0.5, -0.4) + assert Profile.elevation_at(grid, 0.95, -0.45) == 42.0 + end + + test "returns nil for points outside the grid" do + grid = flat_grid(50.0) + assert Profile.elevation_at(grid, 5.0, -0.5) == nil + assert Profile.elevation_at(grid, 0.5, 5.0) == nil + end + + test "returns nil for nodata cells" do + rows = + for _ <- 0..9 do + for _ <- 0..9, do: -9999.0 + end + + grid = test_grid(rows) + assert Profile.elevation_at(grid, 0.5, -0.5) == nil + end + end + + describe "sample/4" do + test "returns the requested number of samples for a flat grid" do + grid = flat_grid(100.0) + samples = Profile.sample(grid, {0.5, -0.9}, {0.5, -0.1}, 50) + assert length(samples) == 50 + # Flat grid → all samples equal the constant elevation + assert Enum.all?(samples, &(&1 == 100.0)) + end + + test "interpolates 0.0 for samples outside the grid" do + grid = flat_grid(100.0) + # Path from inside the grid to outside it + samples = Profile.sample(grid, {0.5, -0.9}, {0.5, 5.0}, 10) + # First few inside (100), last few outside (0) + assert hd(samples) == 100.0 + assert List.last(samples) == 0.0 + end + + test "single-sample request returns just the start point" do + grid = flat_grid(100.0) + assert Profile.sample(grid, {0.5, -0.9}, {0.5, -0.1}, 1) == [100.0] + end + end + + describe "great_circle_distance_m/2" do + test "0 distance between identical points" do + assert Profile.great_circle_distance_m({30.0, -97.0}, {30.0, -97.0}) == +0.0 + end + + test "1 degree of latitude ≈ 111 km" do + d = Profile.great_circle_distance_m({30.0, -97.0}, {31.0, -97.0}) + assert_in_delta d, 111_000.0, 1_000.0 + end + + test "1 degree of longitude at lat 30 ≈ 96 km" do + d = Profile.great_circle_distance_m({30.0, -97.0}, {30.0, -96.0}) + assert_in_delta d, 96_000.0, 2_000.0 + end + end +end diff --git a/test/towerops/coverages/propagation_test.exs b/test/towerops/coverages/propagation_test.exs new file mode 100644 index 00000000..3f49bff9 --- /dev/null +++ b/test/towerops/coverages/propagation_test.exs @@ -0,0 +1,95 @@ +defmodule Towerops.Coverages.PropagationTest do + use ExUnit.Case, async: true + + alias Towerops.Coverages.Propagation + + describe "fspl/2" do + test "matches the canonical free-space formula at 1 km, 5 GHz" do + # FSPL(d_km, f_MHz) = 32.45 + 20*log10(d_km) + 20*log10(f_MHz) + # At 1 km, 5500 MHz ≈ 32.45 + 0 + 74.81 = 107.26 dB + assert_in_delta Propagation.fspl(1_000.0, 5500.0), 107.26, 0.05 + end + + test "doubles distance → +6 dB (within rounding)" do + a = Propagation.fspl(1_000.0, 5500.0) + b = Propagation.fspl(2_000.0, 5500.0) + assert_in_delta b - a, 6.02, 0.05 + end + + test "doubles frequency → +6 dB" do + a = Propagation.fspl(1_000.0, 2_500.0) + b = Propagation.fspl(1_000.0, 5_000.0) + assert_in_delta b - a, 6.02, 0.05 + end + + test "is positive at any sane WISP distance/frequency" do + assert Propagation.fspl(50.0, 700.0) > 50.0 + assert Propagation.fspl(40_000.0, 90_000.0) < 250.0 + end + end + + describe "knife_edge_loss/1 (single Bullington obstacle)" do + test "returns 0 dB when the diffraction parameter v is below -0.78" do + assert Propagation.knife_edge_loss(-1.0) == 0.0 + assert Propagation.knife_edge_loss(-2.0) == 0.0 + end + + test "returns ~6 dB at v ≈ 0 (grazing tip of obstacle)" do + assert_in_delta Propagation.knife_edge_loss(0.0), 6.02, 0.5 + end + + test "increases monotonically with v" do + assert Propagation.knife_edge_loss(0.5) < Propagation.knife_edge_loss(1.0) + assert Propagation.knife_edge_loss(1.0) < Propagation.knife_edge_loss(2.0) + assert Propagation.knife_edge_loss(2.0) < Propagation.knife_edge_loss(5.0) + end + + test "returns substantial attenuation for tall obstacles (v >> 1)" do + assert Propagation.knife_edge_loss(3.0) > 20.0 + end + end + + describe "path_loss/4" do + test "with a flat profile and no obstacles, equals FSPL only" do + # 5 km flat profile, both endpoints at antenna+receiver heights well clear. + profile = List.duplicate(0.0, 50) + distance_m = 5_000.0 + freq_mhz = 5500.0 + tx_height = 30.0 + rx_height = 3.0 + + loss = Propagation.path_loss(distance_m, freq_mhz, profile, tx_height, rx_height) + + assert_in_delta loss, Propagation.fspl(distance_m, freq_mhz), 0.5 + end + + test "obstacle blocking the LOS line adds diffraction loss" do + # 5 km path. Tx at 30 m AGL, rx at 3 m AGL, LOS line at midpoint = ~16.5 m. + # Plant a 100 m hill at the midpoint — should add many dB of loss. + flat = List.duplicate(0.0, 50) + profile = List.replace_at(flat, 25, 100.0) + + free = Propagation.fspl(5_000.0, 5500.0) + with_obstacle = Propagation.path_loss(5_000.0, 5500.0, profile, 30.0, 3.0) + + assert with_obstacle > free + 10.0 + end + + test "an obstacle well below the LOS line adds no diffraction loss" do + flat = List.duplicate(0.0, 50) + # 5 m hill, way below the ~16 m LOS line at midpoint + profile = List.replace_at(flat, 25, 5.0) + + free = Propagation.fspl(5_000.0, 5500.0) + loss = Propagation.path_loss(5_000.0, 5500.0, profile, 30.0, 3.0) + + assert_in_delta loss, free, 0.5 + end + + test "single-sample profile reduces to FSPL" do + assert_in_delta Propagation.path_loss(1_000.0, 5500.0, [0.0], 10.0, 3.0), + Propagation.fspl(1_000.0, 5500.0), + 0.5 + end + end +end diff --git a/test/towerops/workers/coverage_worker_test.exs b/test/towerops/workers/coverage_worker_test.exs new file mode 100644 index 00000000..f7d3ee82 --- /dev/null +++ b/test/towerops/workers/coverage_worker_test.exs @@ -0,0 +1,118 @@ +defmodule Towerops.Workers.CoverageWorkerTest do + use Towerops.DataCase, async: false + use Oban.Testing, repo: Towerops.Repo + + import Towerops.AccountsFixtures + import Towerops.CoveragesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Coverages + alias Towerops.Coverages.Raster + alias Towerops.Test.StubTerrain + alias Towerops.Workers.CoverageWorker + + setup do + user = user_fixture() + org = organization_fixture(user.id) + site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74}) + + coverage = + coverage_fixture(org.id, site.id, %{ + name: "compute test", + radius_m: 1_000, + cell_size_m: 50 + }) + + # Configure the worker to use the stub terrain source for this test. + prev_module = Application.get_env(:towerops, :coverage_terrain_module) + Application.put_env(:towerops, :coverage_terrain_module, StubTerrain) + + on_exit(fn -> + if prev_module, + do: Application.put_env(:towerops, :coverage_terrain_module, prev_module), + else: Application.delete_env(:towerops, :coverage_terrain_module) + + # Clean up any artefacts the worker wrote to priv/static/coverage/. + Raster.cleanup(coverage) + end) + + %{org: org, site: site, coverage: coverage} + end + + describe "perform/1" do + test "writes a GeoTIFF and PNG and marks coverage :ready", %{coverage: coverage, org: org} do + StubTerrain.put_grid(flat_grid()) + + assert :ok = + perform_job(CoverageWorker, %{ + "coverage_id" => coverage.id, + "organization_id" => org.id + }) + + reloaded = Coverages.get_coverage!(org.id, coverage.id) + assert reloaded.status == "ready" + assert reloaded.progress_pct == 100 + assert reloaded.png_path =~ "/coverage/" + assert reloaded.raster_path =~ "/coverage/" + assert reloaded.computed_at + assert reloaded.bbox_min_lat + assert reloaded.bbox_max_lat + assert reloaded.bbox_min_lon + assert reloaded.bbox_max_lon + + # Verify the files actually exist on disk. + static = Application.app_dir(:towerops, "priv/static") + tif = Path.join(static, Path.relative_to(reloaded.raster_path, "/")) + png = Path.join(static, Path.relative_to(reloaded.png_path, "/")) + assert File.exists?(tif) + assert File.exists?(png) + assert File.stat!(tif).size > 0 + assert File.stat!(png).size > 0 + end + + test "marks :failed with a clear message when LIDAR has no tile", %{ + coverage: coverage, + org: org + } do + StubTerrain.put_grid({:error, :no_tile}) + + assert :ok = + perform_job(CoverageWorker, %{ + "coverage_id" => coverage.id, + "organization_id" => org.id + }) + + reloaded = Coverages.get_coverage!(org.id, coverage.id) + assert reloaded.status == "failed" + assert reloaded.error_message =~ "No LIDAR terrain data" + end + + test "refuses to run when org_id in args mismatches the coverage's org", + %{coverage: coverage} do + other_org_id = Ecto.UUID.generate() + + assert {:cancel, :organization_mismatch} = + perform_job(CoverageWorker, %{ + "coverage_id" => coverage.id, + "organization_id" => other_org_id + }) + end + end + + # 50×50 cell grid covering ~5 km × 5 km around (30.27, -97.74) at 100 m + # cells (~0.001°). All cells at 200 m elevation — flat plain. + defp flat_grid do + cellsize = 0.001 + n = 50 + + %{ + ncols: n, + nrows: n, + xllcorner: -97.74 - n / 2 * cellsize, + yllcorner: 30.27 - n / 2 * cellsize, + cellsize: cellsize, + nodata_value: -9999.0, + cells: List.duplicate(List.duplicate(200.0, n), n) + } + end +end