towerops/lib/towerops_web/live/coverage_live/form.html.heex
Graham McIntire 1b6a548f3a refactor: imperial coverage form + Deygout/curvature propagation
Form simplification per user feedback. Form now shows only the
essentials (Name, Site, Antenna, Frequency, TX power, Range, Height,
Azimuth, Tilt, Foliage tuning) with imperial units throughout (ft, mi,
GHz). Defaults: TX power 18 dBm, height 100 ft, azimuth 0°, frequency
5.8 GHz, range 4 mi.

EIRP is calculated, not entered: shown as a live-updating badge in
the form header as `TX power + antenna.gain`.

Removed from the form (still on the schema with sensible defaults so
existing callers and the worker keep working): cell_size_m (auto-
computed from radius / 200, clamped 5–50 m), cable_loss_db, sm_gain_dbi,
tx_clearance_m, height_above_rooftop_m, receiver_height_m,
rx_threshold_dbm, latitude/longitude_override.

Schema:
- Six virtual imperial fields (height_agl_ft, height_above_rooftop_ft,
  receiver_height_ft, tx_clearance_ft, radius_mi, frequency_ghz). The
  changeset converts to SI before validation. Tests/workers/API keep
  sending SI directly.
- ensure_cell_size_m/1: defaults the cell size when callers omit it.

Show + index pages now display GHz / ft / mi.

Propagation accuracy upgrade:
- Earth-curvature correction (4/3-Earth model, ITU-R P.453) applied
  per profile sample. Important for paths over ~5 km.
- Replaced Bullington single-knife-edge with Deygout three-edge
  multi-obstacle method (ITU-R P.526 §4.5): catches multi-ridge
  terrain shadowing that a single dominant obstacle would miss.

Tests: 67 coverage tests pass (added Earth-curvature and Deygout
multi-obstacle reference checks). Full suite: 10817 tests.
2026-05-06 14:44:23 -05:00

182 lines
4.7 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 radio and antenna. Coverage will be computed in the background once you 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>
<%!-- Radio --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Radio")}
</h3>
<.input
field={@form[:frequency_ghz]}
type="number"
label={t("Frequency (GHz)")}
step="0.001"
min="0.7"
max="90"
required
/>
<.input
field={@form[:tx_power_dbm]}
type="number"
label={t("TX power (dBm)")}
step="0.1"
min="-10"
max="50"
required
/>
<.input
field={@form[:radius_mi]}
type="number"
label={t("Range (mi)")}
step="0.1"
min="0.3"
max="25"
required
/>
</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_ft]}
type="number"
label={t("Height above ground (ft)")}
step="1"
min="3"
max="650"
required
/>
<.input
field={@form[:azimuth_deg]}
type="number"
label={t("Azimuth (°, true north)")}
step="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"
/>
</div>
<%!-- Foliage --%>
<div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Environment")}
</h3>
<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"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t("Higher values reduce predicted signal in wooded areas.")}
</p>
</div>
</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>