From aaf3115fd66589c1aa11df4f97fe2d38bf7d4da6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Apr 2026 12:29:52 -0500 Subject: [PATCH] feat(path): show buildings stacked on terrain in path elevation profile Path calculator now queries Microsoft Building Footprints for the tallest structure within 80m of each profile sample and renders a red "Buildings" dataset on top of the terrain layer in the elevation chart. Also normalize on-disk grid HRRR cells to include the legacy :min_refractivity_gradient / :surface_refractivity / :ducting_detected keys (mapped from :native_min_gradient and :duct_count). Path-calculator crashed in prod with KeyError when a path's HRRR points came from the new on-disk grid format instead of the DB profile shape. --- assets/js/elevation_profile_hook.ts | 25 ++++++++++++++++++++++++- lib/microwaveprop_web/live/path_live.ex | 24 +++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/assets/js/elevation_profile_hook.ts b/assets/js/elevation_profile_hook.ts index 53932ee4..c31b451d 100644 --- a/assets/js/elevation_profile_hook.ts +++ b/assets/js/elevation_profile_hook.ts @@ -8,6 +8,7 @@ interface ProfilePoint { elev: number beam: number r1: number + building_m?: number } interface DuctRaw { @@ -191,6 +192,15 @@ export const ElevationProfile: ElevationProfileHook = { return (p.elev + bulge) * M2FT }) + // Buildings: terrain + max nearby building height. Same earth bulge so the + // dataset stacks correctly on top of terrain. + const hasBuildings = points.some(p => (p.building_m || 0) > 0.5) + const buildingTops = points.map((p, i) => { + const f = n > 0 ? i / n : 0 + const bulge = (f * dTotal * (1 - f) * dTotal) / (2 * k * R) + return (p.elev + (p.building_m || 0) + bulge) * M2FT + }) + // LOS beam: straight line from antenna A to antenna B const losLine = points.map(p => p.beam * M2FT) const fresnelLower = points.map(p => (p.beam - p.r1) * M2FT) @@ -207,7 +217,7 @@ export const ElevationProfile: ElevationProfileHook = { likely: d.likely || false })) - const allElevs = [...elevations, ...losLine, ...earthSurface] + const allElevs = [...elevations, ...losLine, ...earthSurface, ...(hasBuildings ? buildingTops : [])] let minY = Math.min(...allElevs) - 40 let maxY = Math.max(...allElevs) + 120 @@ -248,6 +258,19 @@ export const ElevationProfile: ElevationProfileHook = { tension: 0.1, order: 4 }, + ...(hasBuildings ? [ + { + label: "Buildings", + data: buildingTops, + borderColor: "#dc2626", + backgroundColor: "rgba(220, 38, 38, 0.45)", + fill: "-1", + pointRadius: 0, + borderWidth: 1, + tension: 0.1, + order: 3 + } + ] : []), { label: "Line of Sight", data: losLine, diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index ef8f446a..a420d349 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -4,6 +4,8 @@ defmodule MicrowavepropWeb.PathLive do import MicrowavepropWeb.Components.SkewTChart + alias Microwaveprop.Buildings.Index, as: BuildingsIndex + alias Microwaveprop.Buildings.Loader, as: BuildingsLoader alias Microwaveprop.Ionosphere alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig @@ -531,6 +533,10 @@ defmodule MicrowavepropWeb.PathLive do |> Map.put_new(:lat, lat) |> Map.put_new(:lon, lon) |> Map.put_new(:valid_time, valid_time) + |> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient)) + |> Map.put_new(:surface_refractivity, nil) + |> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0) + |> Map.put_new(:duct_characteristics, nil) end # Linear interpolation along the great-circle-approximate path. Good @@ -1622,14 +1628,18 @@ defmodule MicrowavepropWeb.PathLive do defp factor_name(:pressure), do: "Pressure" defp elevation_data(result) do - # ElevationProfile hook expects: points with dist_km, elev, beam, r1 + raw_points = result.terrain.analysis.points + _ = BuildingsLoader.ensure_loaded_for_bbox(profile_bbox(raw_points)) + + # ElevationProfile hook expects: points with dist_km, elev, beam, r1, building_m points = - Enum.map(result.terrain.analysis.points, fn p -> + Enum.map(raw_points, fn p -> %{ dist_km: p.dist_km, elev: p.elev, beam: p[:beam] || 0, - r1: p[:r1] || 0 + r1: p[:r1] || 0, + building_m: BuildingsIndex.max_height_near(p.lat, p.lon, 80) } end) @@ -1641,4 +1651,12 @@ defmodule MicrowavepropWeb.PathLive do ducts: [] } end + + defp profile_bbox([]), do: %{"south" => 0, "north" => 0, "west" => 0, "east" => 0} + + defp profile_bbox(points) do + lats = Enum.map(points, & &1.lat) + lons = Enum.map(points, & &1.lon) + %{"south" => Enum.min(lats), "north" => Enum.max(lats), "west" => Enum.min(lons), "east" => Enum.max(lons)} + end end