diff --git a/CHANGELOG.txt b/CHANGELOG.txt index e2ab0b56..2144b9bf 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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): diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index 5a1effc0..4506f394 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -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 -> diff --git a/lib/towerops/coverages/propagation.ex b/lib/towerops/coverages/propagation.ex index d83ac688..e3b4543f 100644 --- a/lib/towerops/coverages/propagation.ex +++ b/lib/towerops/coverages/propagation.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/form.ex b/lib/towerops_web/live/coverage_live/form.ex index a5f7ace2..639ba095 100644 --- a/lib/towerops_web/live/coverage_live/form.ex +++ b/lib/towerops_web/live/coverage_live/form.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex index b0a7b984..266c8107 100644 --- a/lib/towerops_web/live/coverage_live/form.html.heex +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -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." )} <:actions> @@ -67,35 +67,41 @@ /> - <%!-- Location override --%> + <%!-- Radio --%>

- {t("Location")} + {t("Radio")}

-

- {t("Leave blank to use the parent site's coordinates.")} -

-
- <.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)} - /> -
+ <.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 + />
<%!-- Mounting --%> @@ -105,29 +111,20 @@ <.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" - /> - <%!-- RF parameters --%> + <%!-- Foliage --%>

- {t("RF parameters")} + {t("Environment")}

- <.input - field={@form[:frequency_mhz]} - type="number" - label={t("Frequency (MHz)")} - step="1" - min="700" - max="90000" - 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[:cable_loss_db]} - type="number" - label={t("Cable loss (dB)")} - step="0.1" - min="0" - max="20" - /> -
- - <.input - field={@form[:sm_gain_dbi]} - type="number" - label={t("SM gain (dBi)")} - step="0.1" - min="0" - max="40" - /> -
- - <%!-- Coverage extent --%> -
-

- {t("Coverage extent")} -

- - <.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" - /> -
diff --git a/lib/towerops_web/live/coverage_live/index.html.heex b/lib/towerops_web/live/coverage_live/index.html.heex index 92c5b156..be4c88ad 100644 --- a/lib/towerops_web/live/coverage_live/index.html.heex +++ b/lib/towerops_web/live/coverage_live/index.html.heex @@ -76,13 +76,13 @@ {t("Antenna")} - {t("Freq (MHz)")} + {t("Freq (GHz)")} {t("TX (dBm)")} - {t("Radius (m)")} + {t("Range (mi)")} {t("Status")} @@ -111,14 +111,14 @@ {coverage.antenna_slug} - - {coverage.frequency_mhz} + + {Float.round(coverage.frequency_mhz / 1000.0, 3)} {coverage.tx_power_dbm} - - {coverage.radius_m} + + {Float.round(coverage.radius_m / 1609.344, 2)} <.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")} diff --git a/test/towerops/coverages/propagation_test.exs b/test/towerops/coverages/propagation_test.exs index 3f49bff9..63040143 100644 --- a/test/towerops/coverages/propagation_test.exs +++ b/test/towerops/coverages/propagation_test.exs @@ -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