diff --git a/assets/vendor/leaflet/images/layers-2x.png b/assets/vendor/leaflet/images/layers-2x.png new file mode 100644 index 00000000..200c333d Binary files /dev/null and b/assets/vendor/leaflet/images/layers-2x.png differ diff --git a/assets/vendor/leaflet/images/layers.png b/assets/vendor/leaflet/images/layers.png new file mode 100644 index 00000000..1a72e578 Binary files /dev/null and b/assets/vendor/leaflet/images/layers.png differ diff --git a/lib/microwaveprop/rover/compute.ex b/lib/microwaveprop/rover/compute.ex index 2e08bb4f..ee105dc2 100644 --- a/lib/microwaveprop/rover/compute.ex +++ b/lib/microwaveprop/rover/compute.ex @@ -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 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