Adds Microwaveprop.Canopy module that reads 1° × 1° uint8 per-degree
canopy-height tiles (mirrors SRTM .hgt naming/layout, 30 m resolution).
Returns 0 m for areas with no tile on disk so the lookup is safe to
thread through the existing path-clearance pipeline before any data
lands.
Wires canopy into:
- PathTerrain.obstacle_top: per-sample blocker = ground +
max(building_m, canopy_m), so the rover ranks paths through forests
as obstructed even when no buildings sit on the line
- CandidateDetail profile: each sample now carries canopy_m alongside
building_m
- Rover-detail SVG: green "trees" polygon stacked on terrain, under
the red building polygon
- Path calculator elevation chart: green Tree Canopy dataset between
terrain and buildings
Data prep (download Potapov 2019 / Lang 2022 GeoTIFF, slice to per-degree
uint8 tiles, drop in /data/canopy/) is a separate one-shot operation —
without tiles, lookups return 0 and the existing behavior is unchanged.
66 lines
2.2 KiB
Elixir
66 lines
2.2 KiB
Elixir
defmodule Microwaveprop.CanopyTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Canopy
|
|
|
|
@samples 4
|
|
|
|
setup do
|
|
tiles_dir = Path.join(System.tmp_dir!(), "canopy_test_#{:erlang.unique_integer([:positive])}")
|
|
File.mkdir_p!(tiles_dir)
|
|
on_exit(fn -> File.rm_rf!(tiles_dir) end)
|
|
{:ok, tiles_dir: tiles_dir}
|
|
end
|
|
|
|
defp write_tile(tiles_dir, lat, lon, heights) do
|
|
path = Path.join(tiles_dir, Canopy.tile_filename(lat, lon))
|
|
File.write!(path, IO.iodata_to_binary(heights))
|
|
path
|
|
end
|
|
|
|
test "tile_filename/2 follows SRTM naming convention" do
|
|
assert Canopy.tile_filename(32.5, -97.5) == "N32W098.canopy"
|
|
assert Canopy.tile_filename(33.0, -96.0) == "N33W096.canopy"
|
|
end
|
|
|
|
test "lookup/3 returns 0 when tile is missing", %{tiles_dir: tiles_dir} do
|
|
assert Canopy.lookup(32.5, -97.5, tiles_dir, samples: @samples) == 0
|
|
end
|
|
|
|
test "lookup/3 returns the height at the matching pixel", %{tiles_dir: tiles_dir} do
|
|
# 4x4 grid, row 0 = north edge, so:
|
|
# row 0 = lat 33 (N edge)
|
|
# row 3 = lat 32 (S edge)
|
|
heights =
|
|
List.flatten([
|
|
[10, 11, 12, 13],
|
|
[20, 21, 22, 23],
|
|
[30, 31, 32, 33],
|
|
[40, 41, 42, 43]
|
|
])
|
|
|
|
write_tile(tiles_dir, 32.5, -97.5, heights)
|
|
|
|
# Interior near NW (row 0, col 0) -> 10
|
|
assert Canopy.lookup(32.999, -97.999, tiles_dir, samples: @samples) == 10
|
|
# Interior near NE (row 0, col 3) -> 13
|
|
assert Canopy.lookup(32.999, -97.001, tiles_dir, samples: @samples) == 13
|
|
# Interior near SW (row 3, col 0) -> 40
|
|
assert Canopy.lookup(32.001, -97.999, tiles_dir, samples: @samples) == 40
|
|
# Interior near SE (row 3, col 3) -> 43
|
|
assert Canopy.lookup(32.001, -97.001, tiles_dir, samples: @samples) == 43
|
|
end
|
|
|
|
test "lookup_many/2 batches lookups grouping by tile", %{tiles_dir: tiles_dir} do
|
|
heights = List.duplicate(25, @samples * @samples)
|
|
write_tile(tiles_dir, 32.5, -97.5, heights)
|
|
|
|
points = [{32.5, -97.5}, {32.1, -97.9}]
|
|
result = Canopy.lookup_many(points, tiles_dir, samples: @samples)
|
|
|
|
assert result[{32.5, -97.5}] == 25
|
|
assert result[{32.1, -97.9}] == 25
|
|
# Point in a tile that doesn't exist is 0.
|
|
assert Canopy.lookup_many([{40.0, -90.0}], tiles_dir, samples: @samples)[{40.0, -90.0}] == 0
|
|
end
|
|
end
|