From ff3a879dff154cc16d66de8463deac4342227a9b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 31 Mar 2026 13:49:42 -0500 Subject: [PATCH] Rework viewshed: score-aware NLOS range, sidebar detail panel, factor explanations - Viewshed uses propagation score to set max range (ducting/NLOS distance) - Terrain checked within radio horizon; clear paths get full atmospheric range - Detail panel moves from map popup to sidebar for better visibility - Panel shows instantly on click with loading state - Dark opaque background for readability over map - Factor analysis section explains each score with contextual descriptions --- assets/js/propagation_map_hook.js | 175 ++++++++++++++++++++----- lib/microwaveprop/terrain/viewshed.ex | 68 +++++++++- lib/microwaveprop_web/live/map_live.ex | 42 +++++- 3 files changed, 239 insertions(+), 46 deletions(-) diff --git a/assets/js/propagation_map_hook.js b/assets/js/propagation_map_hook.js index 9b06f204..9442b405 100644 --- a/assets/js/propagation_map_hook.js +++ b/assets/js/propagation_map_hook.js @@ -29,7 +29,73 @@ function factorBar(score) { const filled = Math.round(score / 5) const empty = 20 - filled const tier = scoreTier(score) - return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` + return `${"\u2588".repeat(filled)}${"\u2591".repeat(empty)}` +} + +function factorExplanation(key, score, detail) { + const beneficial = detail.humidity_effect === "beneficial" + const band = detail.band_label || "" + + switch (key) { + case "humidity": + if (beneficial) { + if (score >= 80) return "High moisture — strong ducting potential" + if (score >= 55) return "Moderate moisture — some ducting possible" + return "Dry air — ducting unlikely" + } else { + if (score >= 80) return "Low humidity — minimal absorption" + if (score >= 50) return "Moderate humidity — some absorption" + return "High humidity — significant absorption at this frequency" + } + case "time_of_day": + if (score >= 80) return "Sunrise window — peak inversion strength" + if (score >= 65) return "Evening/post-sunrise — inversions forming or eroding" + if (score >= 40) return "Night — gradual cooling, weak inversions" + return "Afternoon — convective mixing destroys inversions" + case "td_depression": + if (beneficial) { + if (score >= 75) return "Tight depression — moist boundary layer favors ducting" + if (score >= 50) return "Moderate spread — some moisture present" + return "Wide spread — dry air limits ducting formation" + } else { + if (score >= 80) return "Wide spread — dry air reduces absorption" + if (score >= 50) return "Moderate spread" + return "Tight depression — moisture increasing absorption" + } + case "refractivity": + if (score >= 80) return "Strong inversion gradient — ducting layer detected" + if (score >= 60) return "Moderate gradient — enhanced refraction" + if (score >= 45) return "Weak gradient — near standard atmosphere" + return "No significant inversion — standard refraction" + case "sky": + if (score >= 80) return "Clear skies — radiative cooling strengthens inversions" + if (score >= 50) return "Partly cloudy — some radiative cooling" + return "Overcast — clouds prevent inversion formation" + case "season": + if (beneficial) { + if (score >= 70) return "Peak ducting season (summer months)" + if (score >= 40) return "Transitional season — moderate ducting" + return "Weakest season for ducting at this frequency" + } else { + if (score >= 70) return "Peak season — cool, dry conditions" + if (score >= 40) return "Transitional season" + return "Summer humidity degrades propagation at this frequency" + } + case "wind": + if (score >= 80) return "Calm winds — inversions preserved" + if (score >= 55) return "Light winds — minor mixing" + return "Strong winds — turbulent mixing destroys inversions" + case "rain": + if (score >= 90) return "No rain — no path attenuation" + if (score >= 50) return "Light rain — minor attenuation" + return "Heavy rain — significant path loss" + case "pressure": + if (score >= 70) return "Rising pressure — increasing stability favors inversions" + if (score >= 55) return "Steady pressure — stable conditions" + return "Falling pressure — instability weakens inversions" + default: + return "" + } } function rangeEstimate(score, detail) { @@ -40,12 +106,26 @@ function rangeEstimate(score, detail) { return `<${Math.round(detail.typical_range_km * 0.4)} km` } -function buildPopupHTML(detail) { +function buildLoadingHTML(detail) { + const tier = scoreTier(detail.score) + return `
+
+ ${detail.score}/100 + ${tier.label} +
+
+ Loading… +
+
` +} + +function buildPopupHTML(detail, viewshedLoading) { const tier = scoreTier(detail.score) const range = rangeEstimate(detail.score, detail) const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC" let rows = "" + let explanations = "" for (const key of FACTOR_ORDER) { const meta = FACTOR_META[key] const value = detail.factors[key] @@ -55,24 +135,39 @@ function buildPopupHTML(detail) { ${meta.label} ${factorBar(value)} ${value} - (${pct}%) + (${pct}%) ` + const expl = factorExplanation(key, value, detail) + const eTier = scoreTier(value) + explanations += `
+ \u25CF + ${meta.label}: ${expl} +
` } - return `
-
+ const viewshedStatus = viewshedLoading + ? `
Computing terrain coverage…
` + : "" + + return `
+
${detail.score}/100 ${tier.label}
-
+
${detail.band_label} — Est. range: ${range} (CW)
${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W — ${time}
- +
${rows}
+
+
Analysis
+ ${explanations} +
+ ${viewshedStatus}
` } @@ -216,7 +311,6 @@ export const PropagationMap = { this.scoreOverlay = null this.scores = [] - this.detailPopup = L.popup({ maxWidth: 340 }) this.colorScale = [ { min: 80, r: 0, g: 255, b: 163 }, @@ -269,9 +363,14 @@ export const PropagationMap = { // Click on map to request viewshed + factor detail from server this.map.on("click", (e) => { this.rangeCircles.clearLayers() + this.viewshedLoading = true + this.lastDetail = null + + // Remember click location for the center marker + this.clickedLatLng = [e.latlng.lat, e.latlng.lng] // Show a temporary marker while computing - L.circleMarker([e.latlng.lat, e.latlng.lng], { + L.circleMarker(this.clickedLatLng, { radius: 6, color: "#fff", weight: 2, @@ -283,25 +382,38 @@ export const PropagationMap = { // Always request terrain viewshed this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng }) - // Also request propagation detail if grid data exists + // Show panel immediately with whatever we have client-side const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng) - if (basic) { + if (basic && this.detailPanel) { + const merged = { ...basic, ...this.bandInfo, factors: null } + this.detailPanel.innerHTML = buildLoadingHTML(merged) + this.detailPanel.style.display = "block" this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng }) } }) + // Detail panel below sidebar instead of map popup + this.detailPanel = document.getElementById("detail-panel") + this.viewshedLoading = false + this.lastDetail = null + this.handleEvent("point_detail", (detail) => { - if (detail) { + if (detail && this.detailPanel) { const merged = { ...detail, ...this.bandInfo } - this.detailPopup - .setLatLng([detail.lat, detail.lon]) - .setContent(buildPopupHTML(merged)) - .openOn(this.map) + this.lastDetail = merged + this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading) + this.detailPanel.style.display = "block" } }) this.handleEvent("viewshed_result", ({ origin, points }) => { this.rangeCircles.clearLayers() + this.viewshedLoading = false + + // Re-render panel without loading indicator + if (this.lastDetail && this.detailPanel) { + this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false) + } if (points && points.length > 0) { const latlngs = points.map(p => [p.lat, p.lon]) @@ -315,26 +427,23 @@ export const PropagationMap = { interactive: false, smoothFactor: 1 }).addTo(this.rangeCircles) - - // Center marker colored by score tier if available - const basic = this.lookupPoint(origin.lat, origin.lon) - const markerColor = basic ? scoreTier(basic.score).color : "#888" - - L.circleMarker([origin.lat, origin.lon], { - radius: 6, - color: "#fff", - weight: 2, - fillColor: markerColor, - fillOpacity: 1, - interactive: false - }).addTo(this.rangeCircles) } + + // Always redraw center marker at clicked location + const loc = this.clickedLatLng || [origin.lat, origin.lon] + const basic = this.lookupPoint(loc[0], loc[1]) + const markerColor = basic ? scoreTier(basic.score).color : "#888" + + L.circleMarker(loc, { + radius: 6, + color: "#fff", + weight: 2, + fillColor: markerColor, + fillOpacity: 1, + interactive: false + }).addTo(this.rangeCircles) }) - this.map.on("popupclose", () => this.rangeCircles.clearLayers()) - - // Close popup on double-click so zoom works through it - this.map.on("dblclick", () => this.map.closePopup()) this.el.addEventListener("show-loading", () => topbar.show(300)) diff --git a/lib/microwaveprop/terrain/viewshed.ex b/lib/microwaveprop/terrain/viewshed.ex index ba848805..e789ec0c 100644 --- a/lib/microwaveprop/terrain/viewshed.ex +++ b/lib/microwaveprop/terrain/viewshed.ex @@ -51,6 +51,48 @@ defmodule Microwaveprop.Terrain.Viewshed do end end + @doc """ + Compute effective reach accounting for both terrain and propagation conditions. + Higher propagation scores indicate ducting potential, which lets signals + propagate beyond terrain obstructions via atmospheric waveguide. + """ + def effective_reach_km(analysis, max_range_km, score) do + case analysis.verdict do + "CLEAR" -> + max_range_km + + "FRESNEL_MINOR" -> + max_range_km * 0.9 + + "FRESNEL_PARTIAL" -> + max_range_km * 0.7 + + "BLOCKED" -> + terrain_factor = terrain_reach_factor(analysis.diffraction_db) + ducting_factor = ducting_reach_factor(score) + max_range_km * max(terrain_factor, ducting_factor) + end + end + + # Convert diffraction loss to a range reduction factor (0.0–1.0). + # Mild diffraction still allows significant propagation; + # heavy blockage attenuates sharply. + defp terrain_reach_factor(db) when db <= 3, do: 0.8 + defp terrain_reach_factor(db) when db <= 6, do: 0.5 + defp terrain_reach_factor(db) when db <= 12, do: 0.3 + defp terrain_reach_factor(db) when db <= 20, do: 0.15 + defp terrain_reach_factor(_db), do: 0.05 + + # Higher propagation scores mean stronger ducting potential — + # signals can ride atmospheric layers over terrain obstacles. + # Returns a floor factor so blocked paths still get range + # proportional to atmospheric conditions. + defp ducting_reach_factor(score) when score >= 80, do: 0.7 + defp ducting_reach_factor(score) when score >= 65, do: 0.5 + defp ducting_reach_factor(score) when score >= 50, do: 0.3 + defp ducting_reach_factor(score) when score >= 33, do: 0.15 + defp ducting_reach_factor(_score), do: 0.05 + @doc """ Compute a terrain viewshed from a point. Returns a map with :origin and :boundary (list of %{bearing, reach_km, lat, lon}). @@ -66,6 +108,7 @@ defmodule Microwaveprop.Terrain.Viewshed do freq_ghz = Keyword.get(opts, :freq_ghz, 10.0) max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km) ant_height_m = Keyword.get(opts, :ant_height_m, 2.4) + score = Keyword.get(opts, :score, 50) angular_step = Keyword.get(opts, :angular_step, @default_angular_step) tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir()) @@ -75,7 +118,7 @@ defmodule Microwaveprop.Terrain.Viewshed do bearings |> Task.async_stream( fn bearing -> - compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir) + compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir) end, max_concurrency: System.schedulers_online(), timeout: 30_000, @@ -100,26 +143,37 @@ defmodule Microwaveprop.Terrain.Viewshed do %{reach_km: reach_km, verdict: analysis.verdict} end - defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, tiles_dir) do - {end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, max_range_km) + defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir) do + # Check terrain within the radio horizon where terrain features matter. + # Beyond this, propagation is atmospheric (ducting/scatter) and terrain + # doesn't block — earth curvature is handled by the atmosphere. + terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km) + {end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_km) case Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir) do {:ok, profile} -> - result = analyse_ray(profile, max_range_km, freq_ghz, ant_height_m, ant_height_m) - {reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, result.reach_km) + analysis = TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_height_m, ant_height_m) + reach_km = effective_reach_km(analysis, max_range_km, score) + {reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km) %{ bearing: bearing, - reach_km: result.reach_km, + reach_km: reach_km, lat: reach_lat, lon: reach_lon } {:error, _} -> - %{bearing: bearing, reach_km: max_range_km, lat: end_lat, lon: end_lon} + {end_lat2, end_lon2} = destination_point(origin_lat, origin_lon, bearing, max_range_km) + %{bearing: bearing, reach_km: max_range_km, lat: end_lat2, lon: end_lon2} end end + # Radio horizon distance in km for a given antenna height (K=4/3 atmosphere) + defp radio_horizon_km(height_m) do + :math.sqrt(2 * (4 / 3) * @earth_radius_km * 1000 * height_m) / 1000 + end + defp srtm_tiles_dir do Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles")) end diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 8df90855..e2ace4fa 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -35,7 +35,7 @@ defmodule MicrowavepropWeb.MapLive do valid_time: valid_time, bounds: @initial_bounds, grid_visible: false, - antenna_height_ft: 8 + antenna_height_ft: 33 )} end @@ -91,17 +91,27 @@ defmodule MicrowavepropWeb.MapLive do end def handle_event("compute_viewshed", %{"lat" => lat, "lon" => lon}, socket) do - band_config = BandConfig.get(socket.assigns.selected_band) - freq_ghz = socket.assigns.selected_band / 1_000 + band_mhz = socket.assigns.selected_band + band_config = BandConfig.get(band_mhz) + freq_ghz = band_mhz / 1_000 ant_height_m = socket.assigns.antenna_height_ft * 0.3048 - max_range_km = min(band_config.typical_range_km, 100) + + # Use propagation score to determine atmospheric range potential + score = + case Propagation.point_detail(band_mhz, lat, lon) do + %{score: s} -> s + _ -> 50 + end + + max_range_km = score_range_km(score, band_config) socket = start_async(socket, :viewshed, fn -> Viewshed.compute(lat, lon, freq_ghz: freq_ghz, ant_height_m: ant_height_m, - max_range_km: max_range_km + max_range_km: max_range_km, + score: score ) end) @@ -139,6 +149,16 @@ defmodule MicrowavepropWeb.MapLive do {:noreply, socket} 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 + end + defp band_info(band_mhz) do config = BandConfig.get(band_mhz) @@ -146,7 +166,9 @@ defmodule MicrowavepropWeb.MapLive do 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 + exceptional_range_km: config.exceptional_range_km, + humidity_effect: to_string(config.humidity_effect), + freq_mhz: config.freq_mhz } end @@ -257,6 +279,14 @@ defmodule MicrowavepropWeb.MapLive do
+ + <%!-- Point detail panel (populated by JS on click) --%> +