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.
99 lines
3.1 KiB
Elixir
99 lines
3.1 KiB
Elixir
defmodule Microwaveprop.Canopy do
|
||
@moduledoc """
|
||
Tree-canopy heights from a 30 m global canopy-height grid (Potapov et al.
|
||
2019, Lang et al. 2022, or any source able to produce 1° × 1° uint8
|
||
per-pixel rasters).
|
||
|
||
Tile format: `@samples × @samples` bytes, row 0 at the north edge,
|
||
one byte per pixel = canopy height in meters (0–255). Filename
|
||
`N{lat}W{lon}.canopy` mirrors the SRTM `.hgt` convention.
|
||
|
||
Returns 0 m for points without a tile on disk so the lookup can be
|
||
threaded through the path-clearance pipeline without raising on
|
||
un-fetched areas (treat unknown as treeless).
|
||
"""
|
||
|
||
require Logger
|
||
|
||
@samples 3600
|
||
|
||
@spec tile_filename(float(), float()) :: String.t()
|
||
def tile_filename(lat, lon) do
|
||
lat_floor = floor(lat)
|
||
lon_floor = floor(lon)
|
||
|
||
lat_prefix = if lat_floor >= 0, do: "N", else: "S"
|
||
lon_prefix = if lon_floor >= 0, do: "E", else: "W"
|
||
|
||
lat_str = lat_floor |> abs() |> Integer.to_string() |> String.pad_leading(2, "0")
|
||
lon_str = lon_floor |> abs() |> Integer.to_string() |> String.pad_leading(3, "0")
|
||
|
||
"#{lat_prefix}#{lat_str}#{lon_prefix}#{lon_str}.canopy"
|
||
end
|
||
|
||
@doc """
|
||
Returns the canopy height in meters at `lat, lon`, or 0 if no tile is
|
||
on disk for that 1° square.
|
||
"""
|
||
@spec lookup(float(), float(), String.t(), keyword()) :: non_neg_integer()
|
||
def lookup(lat, lon, tiles_dir \\ default_dir(), opts \\ []) do
|
||
samples = Keyword.get(opts, :samples, @samples)
|
||
path = Path.join(tiles_dir, tile_filename(lat, lon))
|
||
|
||
case :file.open(path, [:read, :binary, :raw]) do
|
||
{:ok, fd} ->
|
||
result = read_height(fd, lat, lon, samples)
|
||
_ = :file.close(fd)
|
||
result
|
||
|
||
{:error, _} ->
|
||
0
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Batch lookup. Groups points by tile so each tile is opened once.
|
||
Returns `%{ {lat, lon} => height_m }`. Missing tiles yield 0.
|
||
"""
|
||
@spec lookup_many([{float(), float()}], String.t(), keyword()) ::
|
||
%{{float(), float()} => non_neg_integer()}
|
||
def lookup_many(points, tiles_dir \\ default_dir(), opts \\ []) do
|
||
samples = Keyword.get(opts, :samples, @samples)
|
||
|
||
points
|
||
|> Enum.group_by(fn {lat, lon} -> {floor(lat), floor(lon)} end)
|
||
|> Enum.flat_map(fn {_tile_key, pts} -> read_tile_points(pts, tiles_dir, samples) end)
|
||
|> Map.new()
|
||
end
|
||
|
||
defp read_tile_points([{lat0, lon0} | _] = pts, tiles_dir, samples) do
|
||
path = Path.join(tiles_dir, tile_filename(lat0, lon0))
|
||
|
||
case :file.open(path, [:read, :binary, :raw]) do
|
||
{:ok, fd} ->
|
||
result =
|
||
Enum.map(pts, fn {lat, lon} = pt -> {pt, read_height(fd, lat, lon, samples)} end)
|
||
|
||
_ = :file.close(fd)
|
||
result
|
||
|
||
{:error, _} ->
|
||
Enum.map(pts, fn pt -> {pt, 0} end)
|
||
end
|
||
end
|
||
|
||
defp read_height(fd, lat, lon, samples) do
|
||
row = round((floor(lat) + 1 - lat) * (samples - 1))
|
||
col = round((lon - floor(lon)) * (samples - 1))
|
||
offset = row * samples + col
|
||
|
||
case :file.pread(fd, offset, 1) do
|
||
{:ok, <<h::unsigned-integer-size(8)>>} -> h
|
||
_ -> 0
|
||
end
|
||
end
|
||
|
||
defp default_dir do
|
||
Application.get_env(:microwaveprop, :canopy_tiles_dir, "/data/canopy")
|
||
end
|
||
end
|