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.
This commit is contained in:
Graham McIntire 2026-04-18 13:32:19 -05:00
parent 8bded0c6e8
commit e827cacc48
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 67 additions and 19 deletions

View file

@ -739,14 +739,21 @@ export const PropagationMap: Record<string, unknown> & {
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]
})
}
})

View file

@ -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 ->

View file

@ -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