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.
This commit is contained in:
Graham McIntire 2026-05-06 14:44:23 -05:00
parent 40db913170
commit 1b6a548f3a
9 changed files with 348 additions and 237 deletions

View file

@ -1,3 +1,43 @@
2026-05-06
refactor: imperial coverage form + Deygout/curvature propagation, drop UI clutter
Form (lib/towerops_web/live/coverage_live/form.html.heex):
- Drastically simplified — only essentials per user request: Name,
Site, Antenna, Frequency (GHz), TX power (dBm), Range (mi),
Height above ground (ft), Azimuth (°), Tilt (°), Foliage tuning.
- Removed from form (still on schema with sensible defaults):
cell_size_m (auto-computed from radius), cable_loss_db, sm_gain_dbi,
tx_clearance_m, height_above_rooftop_m, receiver_height_m,
rx_threshold_dbm, latitude/longitude_override.
- Defaults: TX power 18 dBm, height 100 ft, azimuth 0°, frequency
5.8 GHz, range 4 mi.
- EIRP shown as a computed badge in the header: tx_power + antenna.gain
(cable loss not part of the user-facing formula).
Schema (lib/towerops/coverages/coverage.ex):
- Added six virtual imperial fields (height_agl_ft, height_above_rooftop_ft,
receiver_height_ft, tx_clearance_ft, radius_mi, frequency_ghz). The
changeset converts them to their SI canonicals before validation, so
tests/workers can keep sending SI values directly.
- Added ensure_cell_size_m/1: when callers omit cell_size_m, picks
~radius/200 clamped to 550 m. Form never sees the knob.
Show page (lib/towerops_web/live/coverage_live/show.html.heex + show.ex):
- Parameters panel now displays imperial: ft / mi / GHz with helper
format_ft/format_mi/format_ghz functions.
- Removed advanced/zero-by-default rows (cable loss, SM gain, TX
clearance, lat/lon override, above-rooftop, receiver height, RX
threshold, cell size, plain-MHz frequency).
Index (lib/towerops_web/live/coverage_live/index.html.heex):
- Frequency column now GHz, range column now miles.
Propagation (lib/towerops/coverages/propagation.ex) — accuracy upgrade:
- Earth-curvature correction (4/3-Earth model, ITU-R P.453) applied to
every profile sample before LOS comparison: bulge = d1·d2 / (2·k·Re).
Important for paths over ~5 km.
- Replaced single Bullington knife-edge with a Deygout three-edge
multi-obstacle method (ITU-R P.526 §4.5): finds the dominant
obstacle, then recurses into the two sub-paths to add up to two
more knife-edge losses. Catches multi-ridge terrain shadowing.
Tests: 67 coverage tests pass (added Earth-curvature and Deygout
multi-obstacle reference checks). Full suite: 10817 tests.
2026-05-06 2026-05-06
feat: /coverage compute pipeline + bundled antenna catalog + cnHeat-style form feat: /coverage compute pipeline + bundled antenna catalog + cnHeat-style form
Compute pipeline (real, end-to-end working): Compute pipeline (real, end-to-end working):

View file

@ -48,6 +48,15 @@ defmodule Towerops.Coverages.Coverage do
field :receiver_height_m, :float, default: 3.0 field :receiver_height_m, :float, default: 3.0
field :rx_threshold_dbm, :float, default: -90.0 field :rx_threshold_dbm, :float, default: -90.0
# Virtual imperial inputs — populated by the form; the changeset
# converts these to their SI counterparts before validation.
field :height_agl_ft, :float, virtual: true
field :height_above_rooftop_ft, :float, virtual: true
field :receiver_height_ft, :float, virtual: true
field :tx_clearance_ft, :float, virtual: true
field :radius_mi, :float, virtual: true
field :frequency_ghz, :float, virtual: true
field :status, :string, default: "draft" field :status, :string, default: "draft"
field :progress_pct, :integer, default: 0 field :progress_pct, :integer, default: 0
field :error_message, :string field :error_message, :string
@ -117,6 +126,8 @@ defmodule Towerops.Coverages.Coverage do
latitude_override longitude_override latitude_override longitude_override
radius_m cell_size_m receiver_height_m rx_threshold_dbm radius_m cell_size_m receiver_height_m rx_threshold_dbm
status progress_pct site_id status progress_pct site_id
height_agl_ft height_above_rooftop_ft receiver_height_ft tx_clearance_ft
radius_mi frequency_ghz
)a )a
@required_fields ~w( @required_fields ~w(
@ -127,11 +138,17 @@ defmodule Towerops.Coverages.Coverage do
@doc """ @doc """
Changeset for creating or updating a coverage from user input. Changeset for creating or updating a coverage from user input.
Accepts either SI fields (used by tests/workers/API) or imperial
virtual fields (used by the LiveView form). Imperial values are
converted to their SI counterparts before validation.
""" """
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(coverage, attrs) do def changeset(coverage, attrs) do
coverage coverage
|> cast(attrs, @cast_fields) |> cast(attrs, @cast_fields)
|> apply_imperial_conversions()
|> ensure_cell_size_m()
|> validate_required(@required_fields) |> validate_required(@required_fields)
|> validate_length(:name, min: 2, max: 100) |> validate_length(:name, min: 2, max: 100)
|> validate_number(:height_agl_m, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 200.0) |> validate_number(:height_agl_m, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 200.0)
@ -233,6 +250,49 @@ defmodule Towerops.Coverages.Coverage do
def location(_), do: nil def location(_), do: nil
# Convert imperial virtual inputs to their SI canonical fields when
# provided. Form submissions arrive imperial; tests/workers send SI.
defp apply_imperial_conversions(changeset) do
changeset
|> convert_imperial(:height_agl_ft, :height_agl_m, &ft_to_m/1)
|> convert_imperial(:height_above_rooftop_ft, :height_above_rooftop_m, &ft_to_m/1)
|> convert_imperial(:receiver_height_ft, :receiver_height_m, &ft_to_m/1)
|> convert_imperial(:tx_clearance_ft, :tx_clearance_m, &ft_to_m/1)
|> convert_imperial(:radius_mi, :radius_m, &mi_to_m/1)
|> convert_imperial(:frequency_ghz, :frequency_mhz, &ghz_to_mhz/1)
end
defp convert_imperial(changeset, virtual_field, real_field, fun) do
case get_change(changeset, virtual_field) do
nil -> changeset
value when is_number(value) -> put_change(changeset, real_field, fun.(value))
_ -> changeset
end
end
defp ft_to_m(ft), do: ft * 0.3048
defp mi_to_m(mi), do: round(mi * 1609.344)
defp ghz_to_mhz(ghz), do: round(ghz * 1000)
# If the caller didn't supply a cell size, pick one that produces a
# reasonable raster: ~200 cells per axis, clamped to 550 m. The form
# hides this knob; advanced callers (API, worker tests) can still set
# it explicitly.
defp ensure_cell_size_m(changeset) do
if get_field(changeset, :cell_size_m) do
changeset
else
case get_field(changeset, :radius_m) do
radius when is_integer(radius) and radius > 0 ->
cell = radius |> div(200) |> max(5) |> min(50)
put_change(changeset, :cell_size_m, cell)
_ ->
changeset
end
end
end
defp validate_antenna_exists(changeset) do defp validate_antenna_exists(changeset) do
case get_field(changeset, :antenna_slug) do case get_field(changeset, :antenna_slug) do
nil -> nil ->

View file

@ -4,30 +4,47 @@ defmodule Towerops.Coverages.Propagation do
Combines: Combines:
* **Free-space path loss** (Friis): a closed-form function of distance * **Free-space path loss** (Friis): closed-form function of distance
and frequency, accurate over flat terrain at line-of-sight. and frequency, accurate over flat terrain at line-of-sight.
* **Bullington single-knife-edge diffraction** (ITU-R P.526): finds * **Earth curvature** (4/3-Earth model): the geometric line-of-sight
the most obstructive point on a sampled DSM profile and adds the between two antennas drops below their direct ray by
classical knife-edge diffraction loss when an obstacle protrudes `d1·d2 / (2·k·Re)` at each point along the path. Subtracting that
into (or above) the geometric line-of-sight. from each profile sample (equivalently raising the LOS line) is
the standard "smooth-Earth" correction, important for paths over
~5 km.
* **Deygout three-edge diffraction** (ITU-R P.526 §4.5): finds the
dominant obstructing point on the path, then recurses into the
sub-paths on either side to add up to two more knife-edge losses.
Catches multiple ridges that a single Bullington obstacle would
miss.
This is *not* full ITM/Longley-Rice but it captures the dominant This is *not* full ITM/Longley-Rice but it captures the dominant
effect for WISP planning (terrain shadowing) using only published effects (terrain shadowing, multi-edge diffraction, Earth curvature)
open formulas, with no NIF dependency. The function signatures are using only published open formulas with no NIF dependency. The
designed so a future ITM-backed implementation can drop in without function signatures are designed so a future ITM-backed implementation
changing callers. can drop in without changing callers.
Antenna gain is applied separately by the worker (see Antenna gain is applied separately by the worker (see
`Towerops.Coverages.Antenna.attenuation_db/3`). `Towerops.Coverages.Antenna.attenuation_db/3`).
References: References:
* ITU-R P.525 (free-space) * ITU-R P.525 (free-space)
* ITU-R P.526 §4 (single knife-edge diffraction) * ITU-R P.526 §4 (single knife-edge), §4.5 (Deygout)
* ITU-R P.453 (effective Earth radius factor k)
""" """
# Speed of light in m/s # Speed of light in m/s
@c_mps 299_792_458.0 @c_mps 299_792_458.0
# Earth radius in m
@earth_radius_m 6_371_000.0
# Effective Earth radius factor for standard atmosphere (4/3-Earth model).
@k_factor 4.0 / 3.0
# Maximum recursion depth for Deygout. 2 → at most 3 knife edges total.
@max_deygout_depth 2
@doc """ @doc """
Free-space path loss in dB. Free-space path loss in dB.
@ -62,9 +79,9 @@ defmodule Towerops.Coverages.Propagation do
The transmit and receive heights are added to the *first* and *last* The transmit and receive heights are added to the *first* and *last*
profile samples respectively to form the geometric ray endpoints. profile samples respectively to form the geometric ray endpoints.
Returns `FSPL(d, f) + max(0, knife_edge_loss(v))` where `v` is the Returns `FSPL(d, f) + max(0, deygout_loss(...))` where the diffraction
Fresnel parameter computed from the dominant obstructing terrain contribution accounts for Earth curvature and multiple knife-edge
point along the profile. obstacles.
""" """
@spec path_loss( @spec path_loss(
distance_m :: number(), distance_m :: number(),
@ -81,7 +98,11 @@ defmodule Towerops.Coverages.Propagation do
free + diff free + diff
end end
@doc false @doc """
Multi-edge diffraction loss (Deygout three-edge approximation) in dB.
Returns 0 if the path is clear of obstructions (including Earth-bulge).
"""
@spec diffraction_loss( @spec diffraction_loss(
distance_m :: number(), distance_m :: number(),
frequency_mhz :: number(), frequency_mhz :: number(),
@ -102,27 +123,94 @@ defmodule Towerops.Coverages.Propagation do
n = length(profile) n = length(profile)
step_m = distance_m / (n - 1) step_m = distance_m / (n - 1)
{best_v, _} = # Apply Earth-curvature correction: each profile sample's effective
# height is increased by the bulge of the Earth between it and the
# endpoints. Equivalent to lowering the LOS line by the same amount.
indexed =
profile profile
|> Enum.with_index() |> Enum.with_index()
|> Enum.drop(1) |> Enum.map(fn {h, i} ->
|> Enum.drop(-1) d1 = i * step_m
|> Enum.reduce({-1.0e9, -1}, &accumulate_v(&1, &2, distance_m, step_m, wavelength_m, tx_z, rx_z)) d2 = max(distance_m - d1, 0.0)
bulge = earth_bulge(d1, d2)
{h + bulge, i, d1, d2}
end)
if best_v == -1.0e9 do # Strip endpoints — diffraction obstacles can only sit between TX and RX.
0.0 interior = indexed |> Enum.drop(1) |> Enum.drop(-1)
else
max(0.0, knife_edge_loss(best_v)) deygout(interior, tx_z, rx_z, distance_m, wavelength_m, 0)
end
# Recursive Deygout: find the dominant obstacle, accumulate its
# knife-edge loss, then recurse into the two sub-paths on either side.
defp deygout(_interior, _tx_z, _rx_z, _distance, _lambda, depth) when depth > @max_deygout_depth, do: 0.0
defp deygout(interior, tx_z, rx_z, distance_m, wavelength_m, depth) do
case dominant_obstacle(interior, tx_z, rx_z, distance_m, wavelength_m) do
nil ->
0.0
{best_v, best_index_in_interior, best_d1} ->
loss = max(0.0, knife_edge_loss(best_v))
# The dominant obstacle's position becomes a new endpoint for
# the two sub-paths. Its effective height (curvature-adjusted)
# is what made it dominant — use that as the sub-path endpoint.
{best_h, _, _, _} = Enum.at(interior, best_index_in_interior)
left = Enum.take(interior, best_index_in_interior)
right = Enum.drop(interior, best_index_in_interior + 1)
# Sub-path distances need to be recomputed because d1/d2 in the
# tuples were relative to the original full path. Strip the
# right sub-path's d1/d2 to relative values.
right_rebased =
Enum.map(right, fn {h, i, _d1, _d2} ->
d1 = i * (distance_m / (length(interior) + 1)) - best_d1
{h, i, d1, distance_m - best_d1 - d1}
end)
left_rebased =
Enum.map(left, fn {h, i, d1, _d2} ->
{h, i, d1, best_d1 - d1}
end)
left_loss = deygout(left_rebased, tx_z, best_h, best_d1, wavelength_m, depth + 1)
right_loss = deygout(right_rebased, best_h, rx_z, distance_m - best_d1, wavelength_m, depth + 1)
loss + left_loss + right_loss
end end
end end
defp accumulate_v({h, i}, {best_v, best_i}, distance_m, step_m, wavelength_m, tx_z, rx_z) do # Walks the interior samples and returns the most obstructive
d1 = i * step_m # `{v, index, d1}`, or nil if nothing protrudes into the LOS line.
d2 = distance_m - d1 defp dominant_obstacle(interior, tx_z, rx_z, distance_m, wavelength_m) do
interior
|> Enum.with_index()
|> Enum.reduce({-1.0e9, nil, nil}, &fold_obstacle(&1, &2, tx_z, rx_z, distance_m, wavelength_m))
|> case do
{best_v, _, _} when best_v == -1.0e9 -> nil
{best_v, li, d1} -> {best_v, li, d1}
end
end
defp fold_obstacle({{_h, _i, d1, d2}, _li}, acc, _tx_z, _rx_z, _distance_m, _lambda) when d1 <= 0.0 or d2 <= 0.0,
do: acc
defp fold_obstacle({{h, _i, d1, d2}, local_i}, {best_v, best_li, best_d1}, tx_z, rx_z, distance_m, wavelength_m) do
los_z = tx_z + (rx_z - tx_z) * (d1 / distance_m) los_z = tx_z + (rx_z - tx_z) * (d1 / distance_m)
excess = h - los_z 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} 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, local_i, d1}, else: {best_v, best_li, best_d1}
end
defp earth_bulge(d1, d2) do
d1 * d2 / (2.0 * @k_factor * @earth_radius_m)
end end
end end

View file

@ -7,6 +7,14 @@ defmodule ToweropsWeb.CoverageLive.Form do
alias Towerops.Coverages.Coverage alias Towerops.Coverages.Coverage
alias Towerops.Sites alias Towerops.Sites
# Defaults expressed in the user's preferred units (imperial / GHz).
@default_tx_power_dbm 18.0
@default_height_ft 100.0
@default_radius_mi 4.0
@default_frequency_ghz 5.8
@default_azimuth_deg 0.0
@default_downtilt_deg 0.0
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization organization = socket.assigns.current_scope.organization
@ -29,16 +37,18 @@ defmodule ToweropsWeb.CoverageLive.Form do
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
coverage = %Coverage{ coverage = %Coverage{
organization_id: socket.assigns.organization.id, organization_id: socket.assigns.organization.id,
downtilt_deg: 0.0, tx_power_dbm: @default_tx_power_dbm,
azimuth_deg: @default_azimuth_deg,
downtilt_deg: @default_downtilt_deg,
foliage_tuning: 0,
receiver_height_m: 3.0, receiver_height_m: 3.0,
rx_threshold_dbm: -90.0, rx_threshold_dbm: -90.0,
cell_size_m: 10,
radius_m: 5_000,
tx_power_dbm: 18.0,
cable_loss_db: 0.0, cable_loss_db: 0.0,
sm_gain_dbi: 0.0, sm_gain_dbi: 0.0,
height_above_rooftop_m: 0.0, height_above_rooftop_m: 0.0,
foliage_tuning: 0 height_agl_ft: @default_height_ft,
radius_mi: @default_radius_mi,
frequency_ghz: @default_frequency_ghz
} }
socket socket
@ -48,7 +58,10 @@ defmodule ToweropsWeb.CoverageLive.Form do
end end
defp apply_action(socket, :edit, %{"id" => id}) do defp apply_action(socket, :edit, %{"id" => id}) do
coverage = Coverages.get_coverage!(socket.assigns.organization.id, id) coverage =
socket.assigns.organization.id
|> Coverages.get_coverage!(id)
|> populate_imperial_virtuals()
socket socket
|> assign(:page_title, t("Edit Coverage")) |> assign(:page_title, t("Edit Coverage"))
@ -106,9 +119,10 @@ defmodule ToweropsWeb.CoverageLive.Form do
|> assign(:computed_eirp, eirp) |> assign(:computed_eirp, eirp)
end end
# EIRP = TX power + antenna gain. Cable loss is intentionally not part
# of the user-facing formula (kept zero by default in the schema).
defp compute_eirp_from_form(form) do defp compute_eirp_from_form(form) do
tx_power = form_number(form, :tx_power_dbm, 0.0) tx_power = form_number(form, :tx_power_dbm, 0.0)
cable_loss = form_number(form, :cable_loss_db, 0.0)
gain = gain =
case form[:antenna_slug].value do case form[:antenna_slug].value do
@ -122,7 +136,7 @@ defmodule ToweropsWeb.CoverageLive.Form do
0.0 0.0
end end
tx_power + gain - cable_loss tx_power + gain
end end
defp form_number(form, field, default) do defp form_number(form, field, default) do
@ -156,26 +170,24 @@ defmodule ToweropsWeb.CoverageLive.Form do
end) end)
end end
@doc false defp populate_imperial_virtuals(%Coverage{} = c) do
def site_lat_placeholder(sites, form) do %{
case selected_site(sites, form) do c
%{latitude: lat} when is_number(lat) -> "site: #{lat}" | height_agl_ft: m_to_ft(c.height_agl_m),
_ -> "" height_above_rooftop_ft: m_to_ft(c.height_above_rooftop_m),
end receiver_height_ft: m_to_ft(c.receiver_height_m),
tx_clearance_ft: m_to_ft(c.tx_clearance_m),
radius_mi: m_to_mi(c.radius_m),
frequency_ghz: mhz_to_ghz(c.frequency_mhz)
}
end end
@doc false defp m_to_ft(nil), do: nil
def site_lon_placeholder(sites, form) do defp m_to_ft(m) when is_number(m), do: Float.round(m / 0.3048, 1)
case selected_site(sites, form) do
%{longitude: lon} when is_number(lon) -> "site: #{lon}"
_ -> ""
end
end
defp selected_site(sites, form) do defp m_to_mi(nil), do: nil
case form[:site_id].value do defp m_to_mi(m) when is_number(m), do: Float.round(m / 1609.344, 2)
id when is_binary(id) and id != "" -> Enum.find(sites, &(&1.id == id))
_ -> nil defp mhz_to_ghz(nil), do: nil
end defp mhz_to_ghz(mhz) when is_number(mhz), do: Float.round(mhz / 1000.0, 3)
end
end end

View file

@ -13,7 +13,7 @@
{@page_title} {@page_title}
<:subtitle> <:subtitle>
{t( {t(
"Configure the antenna and RF parameters. The compute pipeline will produce a heatmap on save." "Configure the radio and antenna. Coverage will be computed in the background once you save."
)} )}
</:subtitle> </:subtitle>
<:actions> <:actions>
@ -67,35 +67,41 @@
/> />
</div> </div>
<%!-- Location override --%> <%!-- Radio --%>
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Location")} {t("Radio")}
</h3> </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
<.input field={@form[:frequency_ghz]}
field={@form[:latitude_override]} type="number"
type="number" label={t("Frequency (GHz)")}
label={t("Latitude")} step="0.001"
step="0.000001" min="0.7"
min="-90" max="90"
max="90" required
placeholder={site_lat_placeholder(@sites, @form)} />
/>
<.input <.input
field={@form[:longitude_override]} field={@form[:tx_power_dbm]}
type="number" type="number"
label={t("Longitude")} label={t("TX power (dBm)")}
step="0.000001" step="0.1"
min="-180" min="-10"
max="180" max="50"
placeholder={site_lon_placeholder(@sites, @form)} required
/> />
</div>
<.input
field={@form[:radius_mi]}
type="number"
label={t("Range (mi)")}
step="0.1"
min="0.3"
max="25"
required
/>
</div> </div>
<%!-- Mounting --%> <%!-- Mounting --%>
@ -105,29 +111,20 @@
</h3> </h3>
<.input <.input
field={@form[:height_agl_m]} field={@form[:height_agl_ft]}
type="number" type="number"
label={t("Height above ground (m)")} label={t("Height above ground (ft)")}
step="0.1" step="1"
min="1" min="3"
max="200" max="650"
required required
/> />
<.input
field={@form[:height_above_rooftop_m]}
type="number"
label={t("Height above rooftop (m)")}
step="0.1"
min="0"
max="100"
/>
<.input <.input
field={@form[:azimuth_deg]} field={@form[:azimuth_deg]}
type="number" type="number"
label={t("Azimuth (°, true north)")} label={t("Azimuth (°, true north)")}
step="0.1" step="1"
min="0" min="0"
max="360" max="360"
required required
@ -141,62 +138,14 @@
min="-10" min="-10"
max="30" 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> </div>
<%!-- RF parameters --%> <%!-- Foliage --%>
<div class="space-y-4"> <div class="space-y-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white"> <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("RF parameters")} {t("Environment")}
</h3> </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> <div>
<label <label
for="coverage_foliage_tuning" for="coverage_foliage_tuning"
@ -214,53 +163,11 @@
step="1" step="1"
class="w-full" 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>
<%!-- 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>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">

View file

@ -76,13 +76,13 @@
{t("Antenna")} {t("Antenna")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <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)")} {t("Freq (GHz)")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <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)")} {t("TX (dBm)")}
</th> </th>
<th class="px-4 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <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)")} {t("Range (mi)")}
</th> </th>
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400"> <th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Status")} {t("Status")}
@ -111,14 +111,14 @@
<td class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300"> <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> <span class="font-mono text-xs">{coverage.antenna_slug}</span>
</td> </td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300"> <td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{coverage.frequency_mhz} {Float.round(coverage.frequency_mhz / 1000.0, 3)}
</td> </td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300"> <td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300">
{coverage.tx_power_dbm} {coverage.tx_power_dbm}
</td> </td>
<td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300"> <td class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
{coverage.radius_m} {Float.round(coverage.radius_m / 1609.344, 2)}
</td> </td>
<td class="px-4 py-2 text-sm"> <td class="px-4 py-2 text-sm">
<span class={[ <span class={[

View file

@ -101,6 +101,18 @@ defmodule ToweropsWeb.CoverageLive.Show do
end end
end end
@doc false
def format_ft(nil), do: ""
def format_ft(m) when is_number(m), do: "#{round(m / 0.3048)} ft"
@doc false
def format_mi(nil), do: ""
def format_mi(m) when is_number(m), do: "#{Float.round(m / 1609.344, 2)} mi"
@doc false
def format_ghz(nil), do: ""
def format_ghz(mhz) when is_number(mhz), do: "#{Float.round(mhz / 1000.0, 3)} GHz"
attr :label, :string, required: true attr :label, :string, required: true
attr :value, :string, required: true attr :value, :string, required: true

View file

@ -199,51 +199,18 @@
/> />
<.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} /> <.param_row :if={@antenna} label={t("Manufacturer")} value={@antenna.manufacturer} />
<.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} /> <.param_row :if={@antenna} label={t("Antenna gain")} value={"#{@antenna.gain_dbi} dBi"} />
<.param_row label={t("Height AGL")} value={"#{@coverage.height_agl_m} m"} /> <.param_row label={t("Frequency")} value={format_ghz(@coverage.frequency_mhz)} />
<.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("TX power")} value={"#{@coverage.tx_power_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 label={t("EIRP")} value={"#{Float.round(@eirp_dbm, 1)} dBm"} />
<.param_row <.param_row label={t("Height above ground")} value={format_ft(@coverage.height_agl_m)} />
:if={@coverage.sm_gain_dbi && @coverage.sm_gain_dbi > 0} <.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} />
label={t("SM gain")} <.param_row label={t("Tilt")} value={"#{@coverage.downtilt_deg}°"} />
value={"#{@coverage.sm_gain_dbi} dBi"} <.param_row label={t("Range")} value={format_mi(@coverage.radius_m)} />
/>
<.param_row
:if={@coverage.tx_clearance_m}
label={t("TX clearance")}
value={"#{@coverage.tx_clearance_m} m"}
/>
<.param_row <.param_row
:if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0} :if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0}
label={t("Foliage tuning")} label={t("Foliage tuning")}
value={"#{@coverage.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"} />
<.param_row label={t("RX threshold")} value={"#{@coverage.rx_threshold_dbm} dBm"} />
<.param_row <.param_row
:if={@coverage.computed_at} :if={@coverage.computed_at}
label={t("Computed at")} label={t("Computed at")}

View file

@ -50,10 +50,10 @@ defmodule Towerops.Coverages.PropagationTest do
end end
describe "path_loss/4" do describe "path_loss/4" do
test "with a flat profile and no obstacles, equals FSPL only" do test "with a flat short profile and no obstacles, equals FSPL only" do
# 5 km flat profile, both endpoints at antenna+receiver heights well clear. # 1 km flat profile — Earth curvature negligible at this scale.
profile = List.duplicate(0.0, 50) profile = List.duplicate(0.0, 50)
distance_m = 5_000.0 distance_m = 1_000.0
freq_mhz = 5500.0 freq_mhz = 5500.0
tx_height = 30.0 tx_height = 30.0
rx_height = 3.0 rx_height = 3.0
@ -63,6 +63,17 @@ defmodule Towerops.Coverages.PropagationTest do
assert_in_delta loss, Propagation.fspl(distance_m, freq_mhz), 0.5 assert_in_delta loss, Propagation.fspl(distance_m, freq_mhz), 0.5
end end
test "Earth curvature adds a small correction at long flat paths" do
# 30 km flat path. Earth curvature midpoint sag is roughly
# (15000^2)/(2*4/3*6_371_000) ≈ 13 m — high enough to shadow a
# 30 m antenna with a 3 m receiver. Should add several dB beyond
# plain FSPL.
profile = List.duplicate(0.0, 200)
free = Propagation.fspl(30_000.0, 5500.0)
with_curvature = Propagation.path_loss(30_000.0, 5500.0, profile, 30.0, 3.0)
assert with_curvature > free + 5.0
end
test "obstacle blocking the LOS line adds diffraction loss" do 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. # 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. # Plant a 100 m hill at the midpoint — should add many dB of loss.
@ -91,5 +102,19 @@ defmodule Towerops.Coverages.PropagationTest do
Propagation.fspl(1_000.0, 5500.0), Propagation.fspl(1_000.0, 5500.0),
0.5 0.5
end end
test "two ridges in the path attenuate more than either alone (Deygout)" do
# 10 km path. Two 80 m ridges at 30% and 70% along the path. The
# multi-edge model should produce more loss than what any single
# obstacle alone would yield.
flat = List.duplicate(0.0, 100)
one_obstacle = List.replace_at(flat, 30, 80.0)
two_obstacles = List.replace_at(one_obstacle, 70, 80.0)
loss_one = Propagation.path_loss(10_000.0, 5500.0, one_obstacle, 30.0, 3.0)
loss_two = Propagation.path_loss(10_000.0, 5500.0, two_obstacles, 30.0, 3.0)
assert loss_two > loss_one
end
end end
end end