From 40cbb40cb1e93dc59718769f0d272cc6e3057512 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 17:29:24 -0500 Subject: [PATCH] feat(rover): score per-station terrain clearance from SRTM path samples For each (rover cell, fixed station) pair, sample SRTM along the great-circle path and add a per-link dB bonus proportional to the rover's elevation above the highest intermediate terrain. Rover spots on hilltops with clear sight to multiple stations now rise to the top. Also vendor Leaflet's layers control PNGs and copy them under priv/static/assets/css/images/ on build so /rover stops 404'ing on layers-2x.png. --- assets/vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes assets/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes lib/microwaveprop/rover/compute.ex | 50 ++++++++---- lib/microwaveprop/rover/elevation.ex | 49 +++++++++-- lib/microwaveprop/rover/path_terrain.ex | 77 ++++++++++++++++++ mix.exs | 24 +++++- test/microwaveprop/rover/compute_test.exs | 12 ++- .../microwaveprop/rover/path_terrain_test.exs | 58 +++++++++++++ 8 files changed, 240 insertions(+), 30 deletions(-) create mode 100644 assets/vendor/leaflet/images/layers-2x.png create mode 100644 assets/vendor/leaflet/images/layers.png create mode 100644 lib/microwaveprop/rover/path_terrain.ex create mode 100644 test/microwaveprop/rover/path_terrain_test.exs diff --git a/assets/vendor/leaflet/images/layers-2x.png b/assets/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..200c333dca9652ac4cba004d609e5af4eee168c1 GIT binary patch literal 1259 zcmVFhCYNy;#0irRPomHqW|G1C*;4?@4#E?jH>?v@U%cy?3dQAc-DchXVErpOh~ z-jbon+tNbnl6hoEb;)TVk+%hTDDi_G%i3*RZ&15!$Fjr^f;Ke&A@|?=`2&+{zr+3a z{D*=t(`AXyS%X7N z%a#RZw6vD^t_rnM`L4E>m=U&R!A-&}nZIi$BOPvkhrCuUe@BN~-lRD)f44;J%TwgE zcze8u!PQ_NR7?o(NylLXVTfDO zxs5=@|GsYEsNo4M#nT%N!UE(?dnS)t2+{ELYAFp*3=iF=|EQnTp`#vlSXuGVraYo? z+RCzXo6h3qA8{KG?S4nE(lM+;Eb4nT3XV;7gcAxUi5m)`k5tv}cPy()8ZR3TLW3I- zAS^}cq-IJvL7a4RgR!yk@~RT%$lA7{L5ES*hyx)M4(yxI$Ub(4f)K|^v1>zvwQY!_ zIrWw8q9GS^!Dp~}+?mbnB6jDF8mVlbQ!jFKDY;w=7;XO{9bq7>LXGK24WA`;rL)_Z z)&j}pbV(;6gY;VMhbxgvn`X;6x}VUEE-7 z%)7j-%t8S=ZL3yc)HbXDAqJZvBTPoiW_A-+a8m3_Z?v{DN7Tnr#O_VUMT0UBt$;p` zDh6JbGHN8JJ*JN%y2%msb97@_S>9!%Egwk;?PEkU9ntz&3uR}%Fj5d$JHQbQb3}a{ zSzFT^#n=VInPpcAS}CNxj?_ zVscANk5Cfz(51EI1pz};AWWb|kgbYNb4wCEGUn3+eMUMV?1-{=I4TlmLJMot@rd07 zZuo2hk1ccu{YmGkcYdWAVdk{Z4Nm?^cTD&}jGm+Q1SYIXMwmG*oO*83&#>l%nbR`G zhh=lZ%xIb7kU3#;TBbfECrnC9P=-XpL|TG2BoZdj61*XiFbW8?1Z_wp%#;>${SUIy V$8qr;L*)Pf002ovPDHLkV1hYLS~36t literal 0 HcmV?d00001 diff --git a/assets/vendor/leaflet/images/layers.png b/assets/vendor/leaflet/images/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..1a72e5784b2b456eac5d7670738db80697af3377 GIT binary patch literal 696 zcmV;p0!RIcP)*@&l2<6p=!C&s@#ZL+%BQvF&b?w6S%wp=I>1QHj7AP5C)IWy#b znXXB;g;j=$a-tW89K%FbDceHVq&unY*Wx3L#=EGWH=rjqnp|4c_Ulec!ql3#G-5ZF zVlbBA@XP=)C8U&+Lrc)S4O5%1$&{(;7R^K(CSnvSr$v;+B$8q&7Bf|h$#PARo1^%M zf1H^nG-EiXVXr07OH(*8R)xa|FD;lXUlg_-%)~ZGsL2cX0NXaAzN2q%jqLRR6ruVk8`Jb7n#{`T;o@`F= z#3YcynIR^s83UNF3D!f5m#Mg)NJ24&Qfrqb&_z=yF;=B)#9Iq7u-@^O!(mW{D;qvr zPc)gVb%aowtS8m@ElL4A9G>w#ffQ~q{i&_i)*6f^)Sz|C?C>zb4Uo?H<-&Hz@a?J; z$ml@zGygWofb9$ZBj6aLjpLhsT2AzjOu=-*u_gSCU Enum.map(fn cell -> annotate_cell(cell, elev_map, home, home_elev, mode, selected_stations) end) + |> Enum.map(fn cell -> + annotate_cell(cell, elev_map, clearance_map, home, mode, selected_stations) + end) |> Enum.filter(&keep_cell?(&1, home_elev, min_elev_gain)) top_candidates = @@ -122,21 +132,25 @@ defmodule Microwaveprop.Rover.Compute do defp keep_cell?(_, _, _), do: false - defp annotate_cell(cell, elev_map, home, home_elev, mode, stations) do + defp annotate_cell(cell, elev_map, clearance_map, home, mode, stations) do elev_m = Map.get(elev_map, {cell.lat, cell.lon}) + base_margin = LinkMargin.link_margin_from_score(cell.score, mode) - margins = Enum.map(stations, fn _s -> LinkMargin.link_margin_from_score(cell.score, mode) end) - agg = Aggregator.cell_margin_db(margins) + margins = + Enum.map(stations, fn s -> + clearance_db = + terrain_db(Map.get(clearance_map, {{cell.lat, cell.lon}, {s.lat, s.lon}})) - case agg do + base_margin + clearance_db + end) + + case Aggregator.cell_margin_db(margins) do nil -> nil agg_db -> dist_km = DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) - elev_bonus = elev_bonus(elev_m, home_elev) - drive_penalty = drive_penalty(dist_km) - score = agg_db + elev_bonus - drive_penalty + score = agg_db - drive_penalty(dist_km) %{ lat: cell.lat, @@ -149,6 +163,13 @@ defmodule Microwaveprop.Rover.Compute do end end + defp terrain_db(nil), do: 0.0 + + defp terrain_db(clearance_m) do + db = clearance_m / @clearance_m_per_db + db |> max(-@clearance_cap_db) |> min(@clearance_cap_db) + end + defp candidate_payload(cell, home) do drive_min = DriveTime.drive_min(cell.distance_km) bearing = DriveTime.bearing_compass({home.lat, home.lon}, {cell.lat, cell.lon}) @@ -168,13 +189,6 @@ defmodule Microwaveprop.Rover.Compute do } end - defp elev_bonus(nil, _elev_home), do: 0.0 - - defp elev_bonus(elev_c, elev_home) do - diff = (elev_c - elev_home) / 100.0 * @elev_bonus_db_per_100m - diff |> max(0.0) |> min(@elev_bonus_cap_db) - end - defp drive_penalty(dist_km), do: dist_km / @avg_speed_kmh * @drive_penalty_db_per_hour defp tier_color(score) do diff --git a/lib/microwaveprop/rover/elevation.ex b/lib/microwaveprop/rover/elevation.ex index ff4ee8fd..02fbd753 100644 --- a/lib/microwaveprop/rover/elevation.ex +++ b/lib/microwaveprop/rover/elevation.ex @@ -2,22 +2,53 @@ defmodule Microwaveprop.Rover.Elevation do @moduledoc """ Bulk SRTM elevation lookup for a list of `{lat, lon}` points. - Wraps `Microwaveprop.Terrain.Srtm.lookup/3`, which already opens and - reads each tile per call. Cells whose tile is missing or whose value - is the SRTM void sentinel are mapped to `nil`. + Groups requested points by SRTM tile filename so each `.hgt` is opened + exactly once per call, then closed. Cells whose tile is missing or + whose value is the SRTM void sentinel are mapped to `nil`. """ alias Microwaveprop.Terrain.Srtm + @samples 3601 + @void -32_768 + @spec lookup_many([{float(), float()}]) :: %{{float(), float()} => integer() | nil} + def lookup_many([]), do: %{} + def lookup_many(points) when is_list(points) do tiles_dir = Application.get_env(:microwaveprop, :srtm_tiles_dir, "/data/srtm") + unique = Enum.uniq(points) - Map.new(points, fn {lat, lon} = key -> - case Srtm.lookup(lat, lon, tiles_dir) do - {:ok, elev} -> {key, elev} - {:error, _} -> {key, nil} - end - end) + grouped = Enum.group_by(unique, fn {lat, lon} -> Srtm.tile_filename(lat, lon) end) + + grouped + |> Enum.flat_map(fn {filename, pts} -> read_tile(Path.join(tiles_dir, filename), pts) end) + |> Map.new() + end + + defp read_tile(path, pts) do + case :file.open(path, [:read, :binary, :raw]) do + {:ok, fd} -> + try do + Enum.map(pts, fn {lat, lon} = key -> {key, read_point(fd, lat, lon)} end) + after + :file.close(fd) + end + + {:error, _} -> + Enum.map(pts, fn key -> {key, nil} end) + end + end + + defp read_point(fd, lat, lon) do + row = round((floor(lat) + 1 - lat) * (@samples - 1)) + col = round((lon - floor(lon)) * (@samples - 1)) + offset = (row * @samples + col) * 2 + + case :file.pread(fd, offset, 2) do + {:ok, <>} when elev == @void -> nil + {:ok, <>} -> elev + _ -> nil + end end end diff --git a/lib/microwaveprop/rover/path_terrain.ex b/lib/microwaveprop/rover/path_terrain.ex new file mode 100644 index 00000000..9410d07d --- /dev/null +++ b/lib/microwaveprop/rover/path_terrain.ex @@ -0,0 +1,77 @@ +defmodule Microwaveprop.Rover.PathTerrain do + @moduledoc """ + Per-link terrain clearance for the rover scoring pipeline. + + For each (rover cell, fixed station) pair, samples elevation along the + great-circle path between them and reports how far the rover sits + above the highest intermediate terrain. Positive clearance means the + rover should clear trees/buildings between it and the station; negative + clearance means the path is blocked by an intervening ridge. + """ + + alias Microwaveprop.Rover.Elevation + + # Number of intermediate samples between rover and station. + @sample_count 12 + + @type latlon :: {float(), float()} + + @doc """ + Returns `%{ {rover_pt, station_pt} => clearance_m | nil }`. + + `clearance_m` is `rover_elev - max(intermediate_path_elev)`. `nil` when + the rover cell or the entire intermediate path lacks SRTM coverage. + """ + @spec clearance_map([map()], [map()], keyword()) :: %{{latlon(), latlon()} => integer() | nil} + def clearance_map(cells, stations, opts \\ []) when is_list(cells) and is_list(stations) do + elev_lookup = Keyword.get(opts, :elev_lookup, &Elevation.lookup_many/1) + + pairs = + for cell <- cells, station <- stations do + {{cell.lat, cell.lon}, {station.lat, station.lon}} + end + + sample_points = + pairs + |> Enum.flat_map(fn {a, b} -> intermediate_samples(a, b) end) + |> Enum.uniq() + + rover_points = Enum.map(cells, fn c -> {c.lat, c.lon} end) + elev = elev_lookup.(rover_points ++ sample_points) + + Map.new(pairs, fn {rover_pt, station_pt} -> + {{rover_pt, station_pt}, clearance(rover_pt, station_pt, elev)} + end) + end + + defp clearance(rover_pt, station_pt, elev) do + rover_elev = Map.get(elev, rover_pt) + + case rover_elev do + nil -> + nil + + _ -> + path_max = + rover_pt + |> intermediate_samples(station_pt) + |> Enum.map(&Map.get(elev, &1)) + |> Enum.reject(&is_nil/1) + |> safe_max() + + if path_max, do: rover_elev - path_max + end + end + + defp intermediate_samples({lat1, lon1}, {lat2, lon2}) do + # Linear interpolation in lat/lon — fine at the distances we work with + # (≤200 mi). Skip endpoints (i=0 is the rover, i=N+1 is the station). + for i <- 1..@sample_count do + f = i / (@sample_count + 1) + {lat1 + f * (lat2 - lat1), lon1 + f * (lon2 - lon1)} + end + end + + defp safe_max([]), do: nil + defp safe_max(xs), do: Enum.max(xs) +end diff --git a/mix.exs b/mix.exs index d99386bc..5e2278dc 100644 --- a/mix.exs +++ b/mix.exs @@ -124,16 +124,38 @@ defmodule Microwaveprop.MixProject do "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], - "assets.build": ["compile", "tailwind microwaveprop", "esbuild microwaveprop"], + "assets.build": [ + "compile", + "tailwind microwaveprop", + "esbuild microwaveprop", + ©_leaflet_images/1 + ], "assets.deploy": [ "tailwind microwaveprop --minify", "esbuild microwaveprop --minify", + ©_leaflet_images/1, "phx.digest" ], precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] ] end + # Mirrors `assets/vendor/leaflet/images/` into `priv/static/assets/css/images/` + # so Leaflet's CSS (which references `images/layers.png` etc. relative to the + # bundled stylesheet) can resolve those URLs in production. `priv/static/` + # is gitignored, so this needs to run on every build/deploy. + defp copy_leaflet_images(_args) do + src = "assets/vendor/leaflet/images" + dest = "priv/static/assets/css/images" + + if File.dir?(src) do + File.mkdir_p!(dest) + File.cp_r!(src, dest) + end + + :ok + end + # Applies every patch file in `priv/dep_patches/*.patch` to its corresponding # dependency under `deps/`. Inlined here (instead of a `Mix.Task` module # under `lib/mix/tasks/`) so it can run during `mix deps.get` itself — diff --git a/test/microwaveprop/rover/compute_test.exs b/test/microwaveprop/rover/compute_test.exs index c594fe7d..1a87f591 100644 --- a/test/microwaveprop/rover/compute_test.exs +++ b/test/microwaveprop/rover/compute_test.exs @@ -21,6 +21,7 @@ defmodule Microwaveprop.Rover.ComputeTest do scores_at = fn _band, _t, _bbox -> grid end elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _cells, _stations -> %{} end args = %{ home: home, @@ -32,7 +33,12 @@ defmodule Microwaveprop.Rover.ComputeTest do min_elev_gain: 0 } - result = Compute.run(args, scores_at: scores_at, elev_lookup: elev_lookup) + result = + Compute.run(args, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup + ) assert is_map(result) assert Map.has_key?(result, :cells) @@ -78,6 +84,7 @@ defmodule Microwaveprop.Rover.ComputeTest do # All elevations below home — nothing should pass min_elev_gain=200. scores_at = fn _band, _t, _bbox -> grid end elev_lookup = fn points -> Map.new(points, fn p -> {p, 100} end) end + clearance_lookup = fn _cells, _stations -> %{} end result = Compute.run( @@ -91,7 +98,8 @@ defmodule Microwaveprop.Rover.ComputeTest do min_elev_gain: 200 }, scores_at: scores_at, - elev_lookup: elev_lookup + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup ) assert result.cells == [] diff --git a/test/microwaveprop/rover/path_terrain_test.exs b/test/microwaveprop/rover/path_terrain_test.exs new file mode 100644 index 00000000..bf25d7cd --- /dev/null +++ b/test/microwaveprop/rover/path_terrain_test.exs @@ -0,0 +1,58 @@ +defmodule Microwaveprop.Rover.PathTerrainTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Rover.PathTerrain + + describe "clearance_map/2" do + test "returns positive clearance when rover sits well above intermediate terrain" do + cells = [%{lat: 33.0, lon: -96.0}] + stations = [%{lat: 33.5, lon: -96.5}] + + # Rover at 800 m, intermediate path tops out at 200 m → 600 m clearance. + lookup = fn _pts -> + %{} + |> Map.put({33.0, -96.0}, 800) + |> Map.merge(intermediate_elevs(cells, stations, 200)) + end + + result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup) + + assert result[{{33.0, -96.0}, {33.5, -96.5}}] == 600 + end + + test "returns negative clearance when rover is below path obstruction" do + cells = [%{lat: 33.0, lon: -96.0}] + stations = [%{lat: 33.5, lon: -96.5}] + + lookup = fn _pts -> + %{} + |> Map.put({33.0, -96.0}, 100) + |> Map.merge(intermediate_elevs(cells, stations, 500)) + end + + result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup) + + assert result[{{33.0, -96.0}, {33.5, -96.5}}] == -400 + end + + test "returns nil when rover cell has no SRTM coverage" do + cells = [%{lat: 33.0, lon: -96.0}] + stations = [%{lat: 33.5, lon: -96.5}] + + lookup = fn _pts -> %{} end + + result = PathTerrain.clearance_map(cells, stations, elev_lookup: lookup) + + assert result[{{33.0, -96.0}, {33.5, -96.5}}] == nil + end + end + + defp intermediate_elevs(cells, stations, elev) do + for cell <- cells, station <- stations, i <- 1..12, into: %{} do + f = i / 13 + lat = cell.lat + f * (station.lat - cell.lat) + lon = cell.lon + f * (station.lon - cell.lon) + {{lat, lon}, elev} + end + end +end