prop/lib/microwaveprop/canopy.ex
Graham McIntire 4f647e7894
feat(canopy): add tree-canopy height layer for path obstruction analysis
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.
2026-04-26 12:51:48 -05:00

99 lines
3.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (0255). 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