towerops/test/towerops/lidar_test.exs
Graham McIntire 158ee9d323 feat: add Texas LIDAR DEM catalog with on-demand elevation reads
Catalog-only system for LIDAR-derived 1m DEMs covering Texas. We do not
mirror raster data — the DB stores tile metadata (URL + footprint
geometry) and elevation values are streamed on-demand from public USGS
3DEP / TNRIS Cloud-Optimized GeoTIFFs via GDAL /vsicurl/ HTTP byte-range
reads. Used for future tower coverage line-of-sight calculations.

- Enable PostGIS extension; tile footprints indexed via GiST
- Catalog sync from USGS 3DEP (primary) and TNRIS (fallback for gaps)
- Tile dedup by ST_Contains so TNRIS doesn't duplicate 3DEP coverage
- Single-point reads via gdallocationinfo with tile fallthrough on nodata
- Grid retrieval via gdal_translate AAIGrid with multi-tile mosaicing
- Monthly Oban cron worker keeps catalog fresh
- gdal-bin added to runtime image; geo_postgis dep added
2026-05-04 13:03:28 -05:00

223 lines
6.7 KiB
Elixir

defmodule Towerops.LidarTest do
use Towerops.DataCase
alias Towerops.Lidar
alias Towerops.Lidar.Tile
alias Towerops.Repo
# Build a small square polygon footprint around (lat, lon) of size deg.
defp square_footprint(lat, lon, deg \\ 0.05) do
coords = [
{lon - deg, lat - deg},
{lon + deg, lat - deg},
{lon + deg, lat + deg},
{lon - deg, lat + deg},
{lon - deg, lat - deg}
]
%Geo.Polygon{coordinates: [coords], srid: 4326}
end
defp insert_tile!(attrs) do
defaults = %{
source: "3dep",
project_name: "TX_Test_2024",
tile_name: "tile_#{System.unique_integer([:positive])}",
year: 2024,
resolution_m: Decimal.new("1.0"),
url: "https://prd-tnm.s3.amazonaws.com/example.tif",
crs: "EPSG:6343",
footprint: square_footprint(30.27, -97.74),
availability: "available",
metadata: %{},
inserted_at: DateTime.truncate(DateTime.utc_now(), :second),
updated_at: DateTime.truncate(DateTime.utc_now(), :second)
}
%Tile{}
|> Tile.changeset(Map.merge(defaults, attrs))
|> Repo.insert!()
end
describe "list_tiles_for_point/2" do
test "returns tiles whose footprint contains the point" do
tile = insert_tile!(%{footprint: square_footprint(30.27, -97.74, 0.1)})
_other = insert_tile!(%{footprint: square_footprint(40.0, -100.0, 0.1)})
[result] = Lidar.list_tiles_for_point(30.27, -97.74)
assert result.id == tile.id
end
test "returns empty list when no tile covers the point" do
_tile = insert_tile!(%{footprint: square_footprint(40.0, -100.0, 0.05)})
assert Lidar.list_tiles_for_point(30.27, -97.74) == []
end
test "orders by resolution_m ascending then year descending (best tile first)" do
coarse_old =
insert_tile!(%{
tile_name: "coarse_old",
resolution_m: Decimal.new("3.0"),
year: 2018
})
coarse_new =
insert_tile!(%{
tile_name: "coarse_new",
resolution_m: Decimal.new("3.0"),
year: 2024
})
fine_old =
insert_tile!(%{
tile_name: "fine_old",
resolution_m: Decimal.new("1.0"),
year: 2018
})
fine_new =
insert_tile!(%{
tile_name: "fine_new",
resolution_m: Decimal.new("1.0"),
year: 2024
})
result = Lidar.list_tiles_for_point(30.27, -97.74)
ids = Enum.map(result, & &1.id)
assert ids == [fine_new.id, fine_old.id, coarse_new.id, coarse_old.id]
end
test "excludes tiles with availability != \"available\"" do
_missing =
insert_tile!(%{tile_name: "missing", availability: "missing"})
ok = insert_tile!(%{tile_name: "ok", availability: "available"})
[result] = Lidar.list_tiles_for_point(30.27, -97.74)
assert result.id == ok.id
end
end
describe "list_tiles_for_area/1" do
test "returns tiles intersecting the bbox" do
inside = insert_tile!(%{footprint: square_footprint(30.27, -97.74, 0.05)})
_outside = insert_tile!(%{footprint: square_footprint(40.0, -100.0, 0.05)})
bbox = {-97.8, 30.2, -97.7, 30.3}
[result] = Lidar.list_tiles_for_area(bbox)
assert result.id == inside.id
end
test "returns multiple tiles ordered best-first" do
_coarse =
insert_tile!(%{
tile_name: "coarse",
resolution_m: Decimal.new("3.0"),
footprint: square_footprint(30.27, -97.74, 0.1)
})
fine =
insert_tile!(%{
tile_name: "fine",
resolution_m: Decimal.new("1.0"),
footprint: square_footprint(30.27, -97.74, 0.1)
})
bbox = {-97.8, 30.2, -97.7, 30.3}
[first | _] = Lidar.list_tiles_for_area(bbox)
assert first.id == fine.id
end
end
describe "get_elevation/2" do
test "returns :no_tile when no catalog tile covers the point" do
assert {:error, :no_tile} = Lidar.get_elevation(30.27, -97.74)
end
test "returns the reader's value when a tile covers the point" do
_ = insert_tile!(%{footprint: square_footprint(30.27, -97.74, 0.1)})
Application.put_env(:towerops, :lidar_runner, fn _cmd, _args, _opts ->
{"152.5\n", 0}
end)
on_exit(fn -> Application.delete_env(:towerops, :lidar_runner) end)
assert {:ok, 152.5} = Lidar.get_elevation(30.27, -97.74)
end
test "falls through to the next-best tile on nodata" do
_ = insert_tile!(%{tile_name: "best", resolution_m: Decimal.new("1.0")})
_ = insert_tile!(%{tile_name: "fallback", resolution_m: Decimal.new("3.0")})
1 |> :counters.new([:atomics]) |> tap(&Process.put(:c, &1))
Application.put_env(:towerops, :lidar_runner, fn _cmd, _args, _opts ->
c = Process.get(:c)
:counters.add(c, 1, 1)
n = :counters.get(c, 1)
if n == 1, do: {"\n", 0}, else: {"42.0\n", 0}
end)
on_exit(fn -> Application.delete_env(:towerops, :lidar_runner) end)
assert {:ok, 42.0} = Lidar.get_elevation(30.27, -97.74)
end
test "falls through to next tile on non-nodata reader errors" do
_ = insert_tile!(%{tile_name: "first"})
_ = insert_tile!(%{tile_name: "second"})
1 |> :counters.new([:atomics]) |> tap(&Process.put(:c, &1))
Application.put_env(:towerops, :lidar_runner, fn _cmd, _args, _opts ->
c = Process.get(:c)
:counters.add(c, 1, 1)
n = :counters.get(c, 1)
if n == 1 do
{"network down", 1}
else
{"77.0\n", 0}
end
end)
on_exit(fn -> Application.delete_env(:towerops, :lidar_runner) end)
assert {:ok, 77.0} = Lidar.get_elevation(30.27, -97.74)
end
test "returns the last error when only one tile and it returns a non-nodata error" do
_ = insert_tile!(%{})
Application.put_env(:towerops, :lidar_runner, fn _cmd, _args, _opts ->
{"boom", 5}
end)
on_exit(fn -> Application.delete_env(:towerops, :lidar_runner) end)
assert {:error, {:gdal, 5, "boom"}} = Lidar.get_elevation(30.27, -97.74)
end
test "returns :nodata when every covering tile lacks data" do
_ = insert_tile!(%{tile_name: "a"})
_ = insert_tile!(%{tile_name: "b"})
Application.put_env(:towerops, :lidar_runner, fn _cmd, _args, _opts ->
{"\n", 0}
end)
on_exit(fn -> Application.delete_env(:towerops, :lidar_runner) end)
assert {:error, :nodata} = Lidar.get_elevation(30.27, -97.74)
end
end
describe "get_elevation_grid/3" do
test "returns :no_tile when no catalog tile intersects the bbox" do
bbox = {-97.8, 30.2, -97.7, 30.3}
assert {:error, :no_tile} = Lidar.get_elevation_grid(bbox, 0.05)
end
end
end