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.
166 lines
7.5 KiB
Text
166 lines
7.5 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="coverage"
|
|
>
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>
|
|
{t(
|
|
"Predict signal coverage for each transmit antenna using LIDAR terrain and building data."
|
|
)}
|
|
</:subtitle>
|
|
<:actions>
|
|
<.button :if={@sites != []} navigate={~p"/coverage/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-5 w-5" /> {t("New Coverage")}
|
|
</.button>
|
|
</:actions>
|
|
</.header>
|
|
|
|
<%= cond do %>
|
|
<% @sites == [] -> %>
|
|
<div class="flex items-center justify-center py-16">
|
|
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
|
|
<div class="card-body items-center text-center">
|
|
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
|
|
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
|
|
</div>
|
|
<h3 class="card-title text-gray-900 dark:text-white">{t("Add a site first")}</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{t(
|
|
"Coverage predictions are computed from a site's location. Add a site, then return here to plan its coverage."
|
|
)}
|
|
</p>
|
|
<div class="card-actions mt-2">
|
|
<.button navigate={~p"/sites/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-4 w-4" /> {t("Add Site")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% @coverages == [] -> %>
|
|
<div class="flex items-center justify-center py-16">
|
|
<div class="card bg-base-100 shadow-md border border-base-200 dark:border-white/10 max-w-md w-full">
|
|
<div class="card-body items-center text-center">
|
|
<div class="rounded-full bg-blue-100 dark:bg-blue-900/40 p-4 mb-2">
|
|
<.icon name="hero-signal" class="h-12 w-12 text-blue-500 dark:text-blue-400" />
|
|
</div>
|
|
<h3 class="card-title text-gray-900 dark:text-white">{t("No coverages yet")}</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{t("Create your first coverage to predict where a tower's antenna reaches.")}
|
|
</p>
|
|
<div class="card-actions mt-2">
|
|
<.button navigate={~p"/coverage/new"} variant="primary">
|
|
<.icon name="hero-plus" class="h-4 w-4" /> {t("New Coverage")}
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% true -> %>
|
|
<div class="mt-6 overflow-x-auto rounded-lg border border-gray-200 dark:border-white/10">
|
|
<table
|
|
id="coverages-table"
|
|
class="min-w-full divide-y divide-gray-200 dark:divide-white/10"
|
|
>
|
|
<thead class="bg-gray-50 dark:bg-gray-800/80">
|
|
<tr>
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Name")}
|
|
</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Site")}
|
|
</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Antenna")}
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{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("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)")}
|
|
</th>
|
|
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Status")}
|
|
</th>
|
|
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
{t("Actions")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white divide-y divide-gray-200 dark:bg-gray-900 dark:divide-white/10">
|
|
<%= for coverage <- @coverages do %>
|
|
<tr id={"coverage-#{coverage.id}"}>
|
|
<td class="px-4 py-2 text-sm">
|
|
<.link
|
|
navigate={~p"/coverage/#{coverage.id}"}
|
|
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
|
>
|
|
{coverage.name}
|
|
</.link>
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<.link navigate={~p"/sites/#{coverage.site_id}"} class="hover:underline">
|
|
{coverage.site && coverage.site.name}
|
|
</.link>
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
|
|
<span class="font-mono text-xs">{coverage.antenna_slug}</span>
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
|
|
{coverage.frequency_mhz}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
|
|
{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}
|
|
</td>
|
|
<td class="px-4 py-2 text-sm">
|
|
<span class={[
|
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
|
status_badge_class(coverage.status)
|
|
]}>
|
|
{coverage.status}
|
|
</span>
|
|
</td>
|
|
<td class="px-4 py-2 text-sm text-right">
|
|
<div class="flex justify-end gap-2">
|
|
<.link
|
|
navigate={~p"/coverage/#{coverage.id}/edit"}
|
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
<.icon name="hero-pencil-square" class="h-4 w-4" />
|
|
</.link>
|
|
<button
|
|
type="button"
|
|
phx-click="recompute"
|
|
phx-value-id={coverage.id}
|
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
|
title={t("Recompute")}
|
|
>
|
|
<.icon name="hero-arrow-path" class="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click="delete"
|
|
phx-value-id={coverage.id}
|
|
data-confirm={t("Delete this coverage?")}
|
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
|
title={t("Delete")}
|
|
>
|
|
<.icon name="hero-trash" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<% end %>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|