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.
275 lines
6.9 KiB
Text
275 lines
6.9 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="coverage"
|
|
>
|
|
<.breadcrumb items={[
|
|
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
|
%{label: t("Coverage"), navigate: ~p"/coverage"},
|
|
%{label: @page_title}
|
|
]} />
|
|
|
|
<.header>
|
|
{@page_title}
|
|
<:subtitle>
|
|
{t(
|
|
"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
|
|
for={@form}
|
|
id="coverage-form"
|
|
phx-change="validate"
|
|
phx-submit="save"
|
|
class="mt-6 space-y-8"
|
|
>
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<%!-- Identity --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Identity")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:name]}
|
|
type="text"
|
|
label={t("Name")}
|
|
placeholder={t("e.g. North sector 5 GHz")}
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:site_id]}
|
|
type="select"
|
|
label={t("Site")}
|
|
prompt={t("Select a site")}
|
|
options={Enum.map(@sites, &{&1.name, &1.id})}
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:antenna_slug]}
|
|
type="select"
|
|
label={t("Antenna")}
|
|
prompt={t("Select an antenna")}
|
|
options={@antenna_options}
|
|
required
|
|
/>
|
|
</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">
|
|
{t("Mounting")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:height_agl_m]}
|
|
type="number"
|
|
label={t("Height above ground (m)")}
|
|
step="0.1"
|
|
min="1"
|
|
max="200"
|
|
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"
|
|
label={t("Azimuth (°, true north)")}
|
|
step="0.1"
|
|
min="0"
|
|
max="360"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:downtilt_deg]}
|
|
type="number"
|
|
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 --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("RF parameters")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:frequency_mhz]}
|
|
type="number"
|
|
label={t("Frequency (MHz)")}
|
|
step="1"
|
|
min="700"
|
|
max="90000"
|
|
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[:sm_gain_dbi]}
|
|
type="number"
|
|
label={t("SM gain (dBi)")}
|
|
step="0.1"
|
|
min="0"
|
|
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 --%>
|
|
<div class="space-y-4">
|
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
|
{t("Coverage extent")}
|
|
</h3>
|
|
|
|
<.input
|
|
field={@form[:radius_m]}
|
|
type="number"
|
|
label={t("Range / radius (m)")}
|
|
step="100"
|
|
min="500"
|
|
max="40000"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:cell_size_m]}
|
|
type="number"
|
|
label={t("Cell size (m)")}
|
|
step="1"
|
|
min="1"
|
|
max="50"
|
|
required
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:receiver_height_m]}
|
|
type="number"
|
|
label={t("Receiver height (m)")}
|
|
step="0.1"
|
|
min="0.5"
|
|
max="100"
|
|
/>
|
|
|
|
<.input
|
|
field={@form[:rx_threshold_dbm]}
|
|
type="number"
|
|
label={t("RX threshold (dBm)")}
|
|
step="0.5"
|
|
min="-130"
|
|
max="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2">
|
|
<.link navigate={~p"/coverage"} class="btn btn-outline">
|
|
{t("Cancel")}
|
|
</.link>
|
|
<.button type="submit" variant="primary">
|
|
{if @live_action == :new, do: t("Create coverage"), else: t("Save changes")}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</Layouts.authenticated>
|