feat: end-to-end /coverage compute pipeline + antenna catalog + cnHeat-style form

The compute pipeline now actually runs: per-pixel path loss over LIDAR
terrain, antenna pattern lookup, real GeoTIFF + colored PNG output,
Leaflet imageOverlay rendering with opacity slider and dBm legend.

Propagation:
- Towerops.Coverages.Propagation: Friis FSPL + Bullington single-knife-edge
  diffraction (ITU-R P.526). Pure Elixir, closed-form, no NIF dependency.
  Clean signature so a future ITM/Longley-Rice backend can drop in.
- Towerops.Coverages.Profile: AAIGrid sampler with elevation_at, sample
  along great circle, haversine distance.
- Towerops.Coverages.Raster: Float32 GeoTIFF via gdal_translate (with VRT
  wrapper) and colored PNG via gdaldem color-relief. Outputs to
  priv/static/coverage/<org>/<id>/, served by existing Plug.Static.

Worker:
- Towerops.Workers.CoverageWorker now does the full pipeline: bbox compute
  -> Towerops.Lidar.get_elevation_grid -> per-pixel Task.async_stream
  (parallel = schedulers) running antenna pattern lookup + propagation +
  RSSI threshold check, write rasters. Configurable terrain source via
  :coverage_terrain_module application env so tests can stub. Friendly
  failure messages for :no_tile, :grid_too_large, :nodata,
  :unknown_antenna, :missing_location.

Bundled antenna catalog (~95 entries):
- AntennaCatalog with compact specs for every antenna in the request list:
  Cambium, ALPHA, Antel, ITELITE, KP Performance, L-com, MARS, MikroTik,
  Mimosa, MTI, RADWIN, RF Elements, Ruckus, Tarana, Simulate, Ubiquiti.
- Antenna.from_spec/1 synthesizes 360+360 attenuation patterns from
  gain+beamwidth (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.

Schema (migration add_radio_fields_to_coverages):
- tx_power_dbm replaces eirp_dbm; EIRP is now derived as
  tx_power + antenna.gain - cable_loss in Coverage.eirp_dbm/2 and shown
  live in the 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:
- form.html.heex: cnHeat-style grouped form (Identity / Location /
  Mounting / RF / Coverage extent), foliage tuning range slider,
  computed EIRP badge in the header.
- show.html.heex: Leaflet map area with L.imageOverlay heatmap,
  opacity slider, dBm color legend, antenna marker with directional
  wedge for azimuth.
- assets/js/app.ts: CoverageMap hook registered.

Tests: 24 new (propagation reference values, profile sampling,
worker end-to-end with stubbed terrain that writes real GeoTIFF + PNG,
org-mismatch job-cancel guard). Full suite: 10815 tests.
This commit is contained in:
Graham McIntire 2026-05-06 14:32:38 -05:00
parent 5a9381f91a
commit 40db913170
24 changed files with 3008 additions and 86 deletions

View file

@ -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/<org>/<id>/ 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:

View file

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19
}).addTo(this.map)
// Antenna marker with a directional wedge derived from azimuth.
const antennaIcon = L.divIcon({
className: 'coverage-antenna-marker',
html: `
<div style="position:relative;width:32px;height:32px">
<div style="position:absolute;top:50%;left:50%;width:0;height:0;
transform:translate(-50%,-100%) rotate(${azimuth}deg);
transform-origin:50% 100%;
border-left:24px solid transparent;
border-right:24px solid transparent;
border-bottom:48px solid rgba(59,130,246,0.35);"></div>
<div style="position:absolute;top:50%;left:50%;width:14px;height:14px;
transform:translate(-50%,-50%);
background:#2563eb;border:2px solid white;border-radius:50%;
box-shadow:0 0 0 1px rgba(0,0,0,0.2);"></div>
</div>
`,
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<HTMLMetaElement>("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

View file

@ -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/<slug>.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"))

File diff suppressed because it is too large Load diff

View file

@ -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 ->

View file

@ -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

View file

@ -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

View file

@ -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/<organization_id>/<coverage_id>/` 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 -> <<f::float-32-little>> 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 = """
<VRTDataset rasterXSize="#{ncols}" rasterYSize="#{nrows}">
<SRS>EPSG:4326</SRS>
<GeoTransform>#{min_lon}, #{pixel_size_x}, 0.0, #{max_lat}, 0.0, #{-pixel_size_y}</GeoTransform>
<VRTRasterBand dataType="Float32" band="1" subClass="VRTRawRasterBand">
<SourceFilename relativeToVRT="1">#{Path.basename(raw_path)}</SourceFilename>
<ImageOffset>0</ImageOffset>
<PixelOffset>4</PixelOffset>
<LineOffset>#{ncols * 4}</LineOffset>
<ByteOrder>LSB</ByteOrder>
<NoDataValue>#{@nan_sentinel}</NoDataValue>
</VRTRasterBand>
</VRTDataset>
"""
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

View file

@ -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/<org>/<id>/`.
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

View file

@ -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

View file

@ -16,6 +16,14 @@
"Configure the antenna and RF parameters. The compute pipeline will produce a heatmap on save."
)}
</:subtitle>
<:actions>
<span class="inline-flex items-center px-3 py-1.5 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 text-sm">
<span class="text-blue-700 dark:text-blue-300 font-medium mr-2">EIRP:</span>
<span class="text-blue-900 dark:text-blue-100 font-mono">
{Float.round(@computed_eirp, 1)} dBm
</span>
</span>
</:actions>
</.header>
<.form
@ -59,6 +67,37 @@
/>
</div>
<%!-- Location override --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Location")}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Leave blank to use the parent site's coordinates.")}
</p>
<div class="grid grid-cols-2 gap-3">
<.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)}
/>
</div>
</div>
<%!-- Mounting --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
@ -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"
/>
</div>
<%!-- RF parameters --%>
@ -111,15 +168,53 @@
required
/>
<div class="grid grid-cols-2 gap-3">
<.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"
/>
</div>
<.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"
/>
<div>
<label
for="coverage_foliage_tuning"
class="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"
>
{t("Foliage tuning")}: {Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
</label>
<input
type="range"
id="coverage_foliage_tuning"
name="coverage[foliage_tuning]"
value={Phoenix.HTML.Form.input_value(@form, :foliage_tuning) || 0}
min="0"
max="100"
step="1"
class="w-full"
/>
</div>
</div>
<%!-- 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"

View file

@ -79,7 +79,7 @@
{t("Freq (MHz)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("EIRP (dBm)")}
{t("TX (dBm)")}
</th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Radius (m)")}
@ -115,7 +115,7 @@
{coverage.frequency_mhz}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.eirp_dbm}
{coverage.tx_power_dbm}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.radius_m}

View file

@ -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

View file

@ -94,24 +94,94 @@
<%!-- Map area --%>
<div class="lg:col-span-2">
<div class="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<%= if @coverage.png_path && @coverage.status == "ready" do %>
<%!-- Real heatmap rendering is implemented in the compute follow-up.
For now this only renders when png_path is set. --%>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-update="ignore"
>
</div>
<% else %>
<div class="h-[500px] w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
<div>
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
</p>
<%= cond do %>
<% @coverage.png_path && @coverage.status == "ready" -> %>
<div
id={"coverage-map-#{@coverage.id}"}
class="h-[500px] w-full bg-gray-100 dark:bg-gray-800"
phx-hook="CoverageMap"
phx-update="ignore"
data-name={@coverage.name}
data-lat={coverage_lat(@coverage)}
data-lon={coverage_lon(@coverage)}
data-azimuth={@coverage.azimuth_deg}
data-png={@coverage.png_path}
data-bbox={
Jason.encode!([
@coverage.bbox_min_lat,
@coverage.bbox_min_lon,
@coverage.bbox_max_lat,
@coverage.bbox_max_lon
])
}
>
</div>
<%!-- Opacity slider + cnHeat-style legend --%>
<div class="p-3 border-t border-gray-200 dark:border-white/10 bg-white dark:bg-gray-900 space-y-3">
<div class="flex items-center gap-3">
<label
for="coverage-opacity-slider"
class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap"
>
{t("Overlay opacity")}
</label>
<input
id="coverage-opacity-slider"
type="range"
min="0"
max="100"
step="5"
value="70"
class="flex-1"
/>
<span
id="coverage-opacity-label"
class="text-sm font-mono text-gray-700 dark:text-gray-300 w-12 text-right"
>
70%
</span>
</div>
<div class="flex items-center gap-1 text-xs">
<span class="text-gray-500 dark:text-gray-400 mr-2">{t("Signal:")}</span>
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(0,200,0,0.9)">
≥ -55 dBm
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(200,230,0,0.9)"
>
-65
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(255,165,0,0.9)"
>
-75
</span>
<span
class="px-2 py-0.5 rounded text-white"
style="background:rgba(220,60,60,0.9)"
>
-85
</span>
<span class="px-2 py-0.5 rounded text-white" style="background:rgba(100,0,0,0.9)">
-95
</span>
<span class="text-gray-500 dark:text-gray-400 ml-1">
{t("(weaker is darker)")}
</span>
</div>
</div>
<% true -> %>
<div class="h-[500px] w-full flex items-center justify-center bg-gray-50 dark:bg-gray-900 text-center px-6">
<div>
<.icon name="hero-signal-slash" class="h-12 w-12 mx-auto text-gray-400 mb-2" />
<p class="text-sm text-gray-600 dark:text-gray-400">
{t("No heatmap available yet. Run compute to generate the coverage prediction.")}
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>
@ -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"} />

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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