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.
181 lines
4.5 KiB
Elixir
181 lines
4.5 KiB
Elixir
defmodule ToweropsWeb.CoverageLive.Form do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Coverages
|
|
alias Towerops.Coverages.Antenna
|
|
alias Towerops.Coverages.Coverage
|
|
alias Towerops.Sites
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
sites = Sites.list_organization_sites(organization.id)
|
|
antennas = Antenna.list()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:organization, organization)
|
|
|> assign(:sites, sites)
|
|
|> assign(:antennas, antennas)
|
|
|> assign(:antenna_options, antenna_options(antennas))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
|
end
|
|
|
|
defp apply_action(socket, :new, _params) do
|
|
coverage = %Coverage{
|
|
organization_id: socket.assigns.organization.id,
|
|
downtilt_deg: 0.0,
|
|
receiver_height_m: 3.0,
|
|
rx_threshold_dbm: -90.0,
|
|
cell_size_m: 10,
|
|
radius_m: 5_000,
|
|
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(Coverages.change_coverage(coverage))
|
|
end
|
|
|
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
|
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id)
|
|
|
|
socket
|
|
|> assign(:page_title, t("Edit Coverage"))
|
|
|> assign(:coverage, coverage)
|
|
|> assign_form(Coverages.change_coverage(coverage))
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"coverage" => attrs}, socket) do
|
|
changeset =
|
|
socket.assigns.coverage
|
|
|> Coverages.change_coverage(attrs)
|
|
|> Map.put(:action, :validate)
|
|
|
|
{:noreply, assign_form(socket, changeset)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"coverage" => attrs}, socket) do
|
|
save(socket, socket.assigns.live_action, attrs)
|
|
end
|
|
|
|
defp save(socket, :new, attrs) do
|
|
case Coverages.create_coverage(socket.assigns.organization.id, attrs) do
|
|
{:ok, coverage} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Coverage created"))
|
|
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign_form(socket, changeset)}
|
|
end
|
|
end
|
|
|
|
defp save(socket, :edit, attrs) do
|
|
case Coverages.update_coverage(socket.assigns.coverage, attrs) do
|
|
{:ok, coverage} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t("Coverage updated"))
|
|
|> push_navigate(to: ~p"/coverage/#{coverage.id}")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign_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
|
|
|
|
defp antenna_options(antennas) do
|
|
antennas
|
|
|> Enum.group_by(& &1.manufacturer)
|
|
|> Enum.sort_by(fn {mfr, _} -> mfr || "" end)
|
|
|> Enum.map(fn {mfr, ants} ->
|
|
{mfr || t("Other"),
|
|
Enum.map(ants, fn ant ->
|
|
{"#{ant.model} (#{ant.gain_dbi} dBi)", ant.slug}
|
|
end)}
|
|
end)
|
|
end
|
|
|
|
@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
|