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("Leave blank to use the parent site's coordinates.")} -
-+ {t("Higher values reduce predicted signal in wooded areas.")} +