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.
This commit is contained in:
parent
f02a5b90e7
commit
40cbb40cb1
8 changed files with 240 additions and 30 deletions
BIN
assets/vendor/leaflet/images/layers-2x.png
vendored
Normal file
BIN
assets/vendor/leaflet/images/layers-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/vendor/leaflet/images/layers.png
vendored
Normal file
BIN
assets/vendor/leaflet/images/layers.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
|
|
@ -13,13 +13,19 @@ defmodule Microwaveprop.Rover.Compute do
|
|||
alias Microwaveprop.Rover.DriveTime
|
||||
alias Microwaveprop.Rover.Elevation
|
||||
alias Microwaveprop.Rover.LinkMargin
|
||||
alias Microwaveprop.Rover.PathTerrain
|
||||
|
||||
@avg_speed_kmh 65.0
|
||||
@drive_penalty_db_per_hour 2.0
|
||||
@elev_bonus_db_per_100m 1.0
|
||||
@elev_bonus_cap_db 5.0
|
||||
@top_n 5
|
||||
|
||||
# Per-link terrain advantage (dB) is `clearance_m / @clearance_m_per_db`,
|
||||
# clamped to [-@clearance_cap_db, +@clearance_cap_db]. ~30 m matches a
|
||||
# mature tree canopy so a rover one canopy-height above the worst
|
||||
# mid-path terrain earns +1 dB.
|
||||
@clearance_m_per_db 30.0
|
||||
@clearance_cap_db 10.0
|
||||
|
||||
@tier_excellent 10.0
|
||||
@tier_good 3.0
|
||||
@tier_marginal 0.0
|
||||
|
|
@ -46,6 +52,7 @@ defmodule Microwaveprop.Rover.Compute do
|
|||
def run(args, deps \\ []) do
|
||||
scores_at = Keyword.get(deps, :scores_at, &Propagation.scores_at/3)
|
||||
elev_lookup = Keyword.get(deps, :elev_lookup, &Elevation.lookup_many/1)
|
||||
clearance_lookup = Keyword.get(deps, :clearance_lookup, &PathTerrain.clearance_map/2)
|
||||
|
||||
%{
|
||||
home: home,
|
||||
|
|
@ -69,14 +76,17 @@ defmodule Microwaveprop.Rover.Compute do
|
|||
DriveTime.haversine_km({home.lat, home.lon}, {cell.lat, cell.lon}) <= radius_km
|
||||
end)
|
||||
|
||||
# Bulk elevation lookup for surviving cells
|
||||
# Bulk SRTM lookup for cell centers, then per-link path-terrain clearance.
|
||||
points = Enum.map(in_radius, &{&1.lat, &1.lon})
|
||||
elev_map = elev_lookup.(points)
|
||||
clearance_map = clearance_lookup.(in_radius, selected_stations)
|
||||
home_elev = home.elev_m || 0
|
||||
|
||||
cells =
|
||||
in_radius
|
||||
|> 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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
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, <<elev::signed-big-integer-size(16)>>} when elev == @void -> nil
|
||||
{:ok, <<elev::signed-big-integer-size(16)>>} -> elev
|
||||
_ -> nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
77
lib/microwaveprop/rover/path_terrain.ex
Normal file
77
lib/microwaveprop/rover/path_terrain.ex
Normal file
|
|
@ -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
|
||||
24
mix.exs
24
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 —
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
58
test/microwaveprop/rover/path_terrain_test.exs
Normal file
58
test/microwaveprop/rover/path_terrain_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue