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.
This commit is contained in:
Graham McIntire 2026-04-26 12:29:52 -05:00
parent 031db7befc
commit aaf3115fd6
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 45 additions and 4 deletions

View file

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

View file

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