From e827cacc482933688fa1c8925dfa254d745965a7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 18 Apr 2026 13:32:19 -0500 Subject: [PATCH] feat(map): scale propagation ranges with antenna height Antenna height now drives typical/extended/exceptional range estimates and viewshed max range via a sqrt(h_m/10) factor clamped to [0.6, 2.0]. Baseline 33 ft matches the existing defaults. Changing the height input now pushes update_band_info and refreshes the clicked point's detail panel plus viewshed so the range-estimate line and coverage shape track the user's actual station height. --- assets/js/propagation_map_hook.ts | 11 +++- lib/microwaveprop_web/live/map_live.ex | 61 +++++++++++++------ test/microwaveprop_web/live/map_live_test.exs | 14 +++++ 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/assets/js/propagation_map_hook.ts b/assets/js/propagation_map_hook.ts index 7699ce1c..e3b603df 100644 --- a/assets/js/propagation_map_hook.ts +++ b/assets/js/propagation_map_hook.ts @@ -739,14 +739,21 @@ export const PropagationMap: Record & { this.bandInfo = band_info // Viewshed depends on freq + score-derived range, so recompute it when - // the band changes if a point is still selected. update_scores already - // handles the heatmap, reach polygon, and detail panel refresh. + // the band changes or antenna height moves if a point is still selected. + // Also re-request point_detail so the popup's range-estimate line picks + // up the updated typical/extended/exceptional ranges. update_scores + // (on band change) already refreshes the polygon/heatmap; for + // antenna-height changes the polygon is height-independent today. if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") { this.viewshedLoading = true this.pushEvent("compute_viewshed", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] }) + this.pushEvent("point_detail", { + lat: this.clickedLatLng[0], + lon: this.clickedLatLng[1] + }) } }) diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 9f807d8c..54467ada 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -159,7 +159,9 @@ defmodule MicrowavepropWeb.MapLive do |> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time) |> LiveStash.stash_assigns([:selected_band, :selected_time]) |> push_event("update_scores", %{scores: scores}) - |> push_event("update_band_info", %{band_info: band_info(band)}) + |> push_event("update_band_info", %{ + band_info: band_info(band, socket.assigns.antenna_height_ft) + }) |> push_timeline() {:noreply, socket} @@ -199,7 +201,14 @@ defmodule MicrowavepropWeb.MapLive do _ -> 8 end - {:noreply, assign(socket, :antenna_height_ft, height)} + socket = + socket + |> assign(:antenna_height_ft, height) + |> push_event("update_band_info", %{ + band_info: band_info(socket.assigns.selected_band, height) + }) + + {:noreply, socket} end def handle_event("toggle_grid", _params, socket) do @@ -274,7 +283,7 @@ defmodule MicrowavepropWeb.MapLive do _ -> 50 end - max_range_km = score_range_km(score, band_config) + max_range_km = score_range_km(score, band_config, socket.assigns.antenna_height_ft) socket = start_async(socket, :viewshed, fn -> @@ -433,29 +442,47 @@ defmodule MicrowavepropWeb.MapLive do Enum.min_by(times, fn t -> abs(DateTime.diff(t, now)) end) end - defp score_range_km(score, config) do - cond do - score >= 80 -> config.exceptional_range_km - score >= 65 -> config.extended_range_km - score >= 50 -> config.typical_range_km - score >= 33 -> round(config.typical_range_km * 0.6) - true -> round(config.typical_range_km * 0.3) - end + defp score_range_km(score, config, ant_height_ft) do + factor = height_factor(ant_height_ft) + + base = + cond do + score >= 80 -> config.exceptional_range_km + score >= 65 -> config.extended_range_km + score >= 50 -> config.typical_range_km + score >= 33 -> round(config.typical_range_km * 0.6) + true -> round(config.typical_range_km * 0.3) + end + + round(base * factor) end - defp band_info(band_mhz) do + defp band_info(band_mhz, ant_height_ft) do config = BandConfig.get(band_mhz) + factor = height_factor(ant_height_ft) %{ band_label: config.label, - typical_range_km: config.typical_range_km, - extended_range_km: config.extended_range_km, - exceptional_range_km: config.exceptional_range_km, + typical_range_km: round(config.typical_range_km * factor), + extended_range_km: round(config.extended_range_km * factor), + exceptional_range_km: round(config.exceptional_range_km * factor), humidity_effect: to_string(config.humidity_effect), - freq_mhz: config.freq_mhz + freq_mhz: config.freq_mhz, + antenna_height_ft: ant_height_ft } end + # Scales effective range with antenna height. Baseline 33 ft (~10 m) = 1.0; + # scales as sqrt(h_m / 10) because radio horizon scales as sqrt(height). + # Clamped to [0.6, 2.0] to avoid silly extremes at 0 ft and very tall towers. + defp height_factor(ant_height_ft) when is_number(ant_height_ft) do + h_m = max(ant_height_ft, 3) * 0.3048 + factor = :math.sqrt(h_m / 10.0) + factor |> max(0.6) |> min(2.0) + end + + defp height_factor(_), do: 1.0 + defp selected_label(bands, selected_band) do case Enum.find(bands, &(&1.freq_mhz == selected_band)) do nil -> "Select Band" @@ -555,7 +582,7 @@ defmodule MicrowavepropWeb.MapLive do phx-hook="PropagationMap" phx-update="ignore" data-scores={@initial_scores_json} - data-band-info={Jason.encode!(band_info(@selected_band))} + data-band-info={Jason.encode!(band_info(@selected_band, @antenna_height_ft))} data-valid-times={ Jason.encode!( Enum.map(@valid_times, fn t -> diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index b69b1feb..04e69c45 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -99,6 +99,20 @@ defmodule MicrowavepropWeb.MapLiveTest do html = render_change(lv, "set_antenna_height", %{"height_ft" => "20"}) assert html =~ ~s(value="20") end + + test "pushes update_band_info with height-scaled ranges", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + baseline = render_change(lv, "set_antenna_height", %{"height_ft" => "33"}) + assert_push_event(lv, "update_band_info", %{band_info: %{typical_range_km: baseline_range}}) + assert baseline =~ ~s(value="33") + + tall = render_change(lv, "set_antenna_height", %{"height_ft" => "100"}) + assert_push_event(lv, "update_band_info", %{band_info: %{typical_range_km: tall_range}}) + assert tall =~ ~s(value="100") + + assert tall_range > baseline_range + end end describe "compute_viewshed" do