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
223 lines
6.7 KiB
Elixir
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
|