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:
parent
5a9381f91a
commit
40db913170
24 changed files with 3008 additions and 86 deletions
|
|
@ -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:
|
||||
|
|
|
|||
136
assets/js/app.ts
136
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: '© <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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
1174
lib/towerops/coverages/antenna_catalog.ex
Normal file
1174
lib/towerops/coverages/antenna_catalog.ex
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 ->
|
||||
|
|
|
|||
111
lib/towerops/coverages/profile.ex
Normal file
111
lib/towerops/coverages/profile.ex
Normal 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
|
||||
128
lib/towerops/coverages/propagation.ex
Normal file
128
lib/towerops/coverages/propagation.ex
Normal 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
|
||||
190
lib/towerops/coverages/raster.ex
Normal file
190
lib/towerops/coverages/raster.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
29
test/support/stub_terrain.ex
Normal file
29
test/support/stub_terrain.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
test/towerops/coverages/profile_test.exs
Normal file
97
test/towerops/coverages/profile_test.exs
Normal 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
|
||||
95
test/towerops/coverages/propagation_test.exs
Normal file
95
test/towerops/coverages/propagation_test.exs
Normal 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
|
||||
118
test/towerops/workers/coverage_worker_test.exs
Normal file
118
test/towerops/workers/coverage_worker_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue