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:
parent
40db913170
commit
1b6a548f3a
9 changed files with 348 additions and 237 deletions
|
|
@ -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 5–50 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
|
||||
feat: /coverage compute pipeline + bundled antenna catalog + cnHeat-style form
|
||||
Compute pipeline (real, end-to-end working):
|
||||
|
|
|
|||
|
|
@ -48,6 +48,15 @@ defmodule Towerops.Coverages.Coverage do
|
|||
field :receiver_height_m, :float, default: 3.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 :progress_pct, :integer, default: 0
|
||||
field :error_message, :string
|
||||
|
|
@ -117,6 +126,8 @@ defmodule Towerops.Coverages.Coverage do
|
|||
latitude_override longitude_override
|
||||
radius_m cell_size_m receiver_height_m rx_threshold_dbm
|
||||
status progress_pct site_id
|
||||
height_agl_ft height_above_rooftop_ft receiver_height_ft tx_clearance_ft
|
||||
radius_mi frequency_ghz
|
||||
)a
|
||||
|
||||
@required_fields ~w(
|
||||
|
|
@ -127,11 +138,17 @@ defmodule Towerops.Coverages.Coverage do
|
|||
|
||||
@doc """
|
||||
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()
|
||||
def changeset(coverage, attrs) do
|
||||
coverage
|
||||
|> cast(attrs, @cast_fields)
|
||||
|> apply_imperial_conversions()
|
||||
|> ensure_cell_size_m()
|
||||
|> validate_required(@required_fields)
|
||||
|> 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)
|
||||
|
|
@ -233,6 +250,49 @@ defmodule Towerops.Coverages.Coverage do
|
|||
|
||||
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 5–50 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
|
||||
case get_field(changeset, :antenna_slug) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -4,30 +4,47 @@ defmodule Towerops.Coverages.Propagation do
|
|||
|
||||
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.
|
||||
* **Bullington single-knife-edge diffraction** (ITU-R P.526): finds
|
||||
the most obstructive point on a sampled DSM profile and adds the
|
||||
classical knife-edge diffraction loss when an obstacle protrudes
|
||||
into (or above) the geometric line-of-sight.
|
||||
* **Earth curvature** (4/3-Earth model): the geometric line-of-sight
|
||||
between two antennas drops below their direct ray by
|
||||
`d1·d2 / (2·k·Re)` at each point along the path. Subtracting that
|
||||
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
|
||||
effect for WISP planning (terrain shadowing) using only published
|
||||
open formulas, with no NIF dependency. The function signatures are
|
||||
designed so a future ITM-backed implementation can drop in without
|
||||
changing callers.
|
||||
effects (terrain shadowing, multi-edge diffraction, Earth curvature)
|
||||
using only published open formulas with no NIF dependency. The
|
||||
function signatures are designed so a future ITM-backed implementation
|
||||
can drop in without changing callers.
|
||||
|
||||
Antenna gain is applied separately by the worker (see
|
||||
`Towerops.Coverages.Antenna.attenuation_db/3`).
|
||||
|
||||
References:
|
||||
* 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
|
||||
@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 """
|
||||
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*
|
||||
profile samples respectively to form the geometric ray endpoints.
|
||||
|
||||
Returns `FSPL(d, f) + max(0, knife_edge_loss(v))` where `v` is the
|
||||
Fresnel parameter computed from the dominant obstructing terrain
|
||||
point along the profile.
|
||||
Returns `FSPL(d, f) + max(0, deygout_loss(...))` where the diffraction
|
||||
contribution accounts for Earth curvature and multiple knife-edge
|
||||
obstacles.
|
||||
"""
|
||||
@spec path_loss(
|
||||
distance_m :: number(),
|
||||
|
|
@ -81,7 +98,11 @@ defmodule Towerops.Coverages.Propagation do
|
|||
free + diff
|
||||
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(
|
||||
distance_m :: number(),
|
||||
frequency_mhz :: number(),
|
||||
|
|
@ -102,27 +123,94 @@ defmodule Towerops.Coverages.Propagation do
|
|||
n = length(profile)
|
||||
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
|
||||
|> Enum.with_index()
|
||||
|> Enum.drop(1)
|
||||
|> Enum.drop(-1)
|
||||
|> Enum.reduce({-1.0e9, -1}, &accumulate_v(&1, &2, distance_m, step_m, wavelength_m, tx_z, rx_z))
|
||||
|> Enum.map(fn {h, i} ->
|
||||
d1 = i * step_m
|
||||
d2 = max(distance_m - d1, 0.0)
|
||||
bulge = earth_bulge(d1, d2)
|
||||
{h + bulge, i, d1, d2}
|
||||
end)
|
||||
|
||||
if best_v == -1.0e9 do
|
||||
0.0
|
||||
else
|
||||
max(0.0, knife_edge_loss(best_v))
|
||||
# Strip endpoints — diffraction obstacles can only sit between TX and RX.
|
||||
interior = indexed |> Enum.drop(1) |> Enum.drop(-1)
|
||||
|
||||
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
|
||||
|
||||
defp accumulate_v({h, i}, {best_v, best_i}, distance_m, step_m, wavelength_m, tx_z, rx_z) do
|
||||
d1 = i * step_m
|
||||
d2 = distance_m - d1
|
||||
# Walks the interior samples and returns the most obstructive
|
||||
# `{v, index, d1}`, or nil if nothing protrudes into the LOS line.
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
alias Towerops.Coverages.Coverage
|
||||
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
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
|
@ -29,16 +37,18 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
defp apply_action(socket, :new, _params) do
|
||||
coverage = %Coverage{
|
||||
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,
|
||||
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
|
||||
height_agl_ft: @default_height_ft,
|
||||
radius_mi: @default_radius_mi,
|
||||
frequency_ghz: @default_frequency_ghz
|
||||
}
|
||||
|
||||
socket
|
||||
|
|
@ -48,7 +58,10 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
end
|
||||
|
||||
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
|
||||
|> assign(:page_title, t("Edit Coverage"))
|
||||
|
|
@ -106,9 +119,10 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
|> assign(:computed_eirp, eirp)
|
||||
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
|
||||
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
|
||||
|
|
@ -122,7 +136,7 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
0.0
|
||||
end
|
||||
|
||||
tx_power + gain - cable_loss
|
||||
tx_power + gain
|
||||
end
|
||||
|
||||
defp form_number(form, field, default) do
|
||||
|
|
@ -156,26 +170,24 @@ defmodule ToweropsWeb.CoverageLive.Form do
|
|||
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
|
||||
defp populate_imperial_virtuals(%Coverage{} = c) do
|
||||
%{
|
||||
c
|
||||
| height_agl_ft: m_to_ft(c.height_agl_m),
|
||||
height_above_rooftop_ft: m_to_ft(c.height_above_rooftop_m),
|
||||
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
|
||||
|
||||
@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 m_to_ft(nil), do: nil
|
||||
defp m_to_ft(m) when is_number(m), do: Float.round(m / 0.3048, 1)
|
||||
|
||||
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
|
||||
defp m_to_mi(nil), do: nil
|
||||
defp m_to_mi(m) when is_number(m), do: Float.round(m / 1609.344, 2)
|
||||
|
||||
defp mhz_to_ghz(nil), do: nil
|
||||
defp mhz_to_ghz(mhz) when is_number(mhz), do: Float.round(mhz / 1000.0, 3)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
{@page_title}
|
||||
<:subtitle>
|
||||
{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>
|
||||
<:actions>
|
||||
|
|
@ -67,35 +67,41 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Location override --%>
|
||||
<%!-- Radio --%>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Location")}
|
||||
{t("Radio")}
|
||||
</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>
|
||||
<.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 --%>
|
||||
|
|
@ -105,29 +111,20 @@
|
|||
</h3>
|
||||
|
||||
<.input
|
||||
field={@form[:height_agl_m]}
|
||||
field={@form[:height_agl_ft]}
|
||||
type="number"
|
||||
label={t("Height above ground (m)")}
|
||||
step="0.1"
|
||||
min="1"
|
||||
max="200"
|
||||
label={t("Height above ground (ft)")}
|
||||
step="1"
|
||||
min="3"
|
||||
max="650"
|
||||
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"
|
||||
step="1"
|
||||
min="0"
|
||||
max="360"
|
||||
required
|
||||
|
|
@ -141,62 +138,14 @@
|
|||
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 --%>
|
||||
<%!-- Foliage --%>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("RF parameters")}
|
||||
{t("Environment")}
|
||||
</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"
|
||||
|
|
@ -214,53 +163,11 @@
|
|||
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>
|
||||
|
||||
<%!-- 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">
|
||||
|
|
|
|||
|
|
@ -76,13 +76,13 @@
|
|||
{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)")}
|
||||
{t("Freq (GHz)")}
|
||||
</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)")}
|
||||
{t("Range (mi)")}
|
||||
</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")}
|
||||
|
|
@ -111,14 +111,14 @@
|
|||
<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 class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
|
||||
{Float.round(coverage.frequency_mhz / 1000.0, 3)}
|
||||
</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 class="px-4 py-2 text-sm text-right text-gray-700 dark:text-gray-300 font-mono">
|
||||
{Float.round(coverage.radius_m / 1609.344, 2)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm">
|
||||
<span class={[
|
||||
|
|
|
|||
|
|
@ -101,6 +101,18 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
|||
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 :value, :string, required: true
|
||||
|
||||
|
|
|
|||
|
|
@ -199,51 +199,18 @@
|
|||
/>
|
||||
<.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 label={t("Height AGL")} value={"#{@coverage.height_agl_m} m"} />
|
||||
<.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("Frequency")} value={format_ghz(@coverage.frequency_mhz)} />
|
||||
<.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
|
||||
:if={@coverage.sm_gain_dbi && @coverage.sm_gain_dbi > 0}
|
||||
label={t("SM gain")}
|
||||
value={"#{@coverage.sm_gain_dbi} dBi"}
|
||||
/>
|
||||
<.param_row
|
||||
:if={@coverage.tx_clearance_m}
|
||||
label={t("TX clearance")}
|
||||
value={"#{@coverage.tx_clearance_m} m"}
|
||||
/>
|
||||
<.param_row label={t("Height above ground")} value={format_ft(@coverage.height_agl_m)} />
|
||||
<.param_row label={t("Azimuth")} value={"#{@coverage.azimuth_deg}°"} />
|
||||
<.param_row label={t("Tilt")} value={"#{@coverage.downtilt_deg}°"} />
|
||||
<.param_row label={t("Range")} value={format_mi(@coverage.radius_m)} />
|
||||
<.param_row
|
||||
:if={@coverage.foliage_tuning && @coverage.foliage_tuning > 0}
|
||||
label={t("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
|
||||
:if={@coverage.computed_at}
|
||||
label={t("Computed at")}
|
||||
|
|
|
|||
|
|
@ -50,10 +50,10 @@ defmodule Towerops.Coverages.PropagationTest do
|
|||
end
|
||||
|
||||
describe "path_loss/4" do
|
||||
test "with a flat profile and no obstacles, equals FSPL only" do
|
||||
# 5 km flat profile, both endpoints at antenna+receiver heights well clear.
|
||||
test "with a flat short profile and no obstacles, equals FSPL only" do
|
||||
# 1 km flat profile — Earth curvature negligible at this scale.
|
||||
profile = List.duplicate(0.0, 50)
|
||||
distance_m = 5_000.0
|
||||
distance_m = 1_000.0
|
||||
freq_mhz = 5500.0
|
||||
tx_height = 30.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
|
||||
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
|
||||
# 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.
|
||||
|
|
@ -91,5 +102,19 @@ defmodule Towerops.Coverages.PropagationTest do
|
|||
Propagation.fspl(1_000.0, 5500.0),
|
||||
0.5
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue