Files
MoundHunters/lib/mound_hunters/ohio_lidar.ex
2026-01-24 10:23:37 +01:00

197 lines
5.4 KiB
Elixir

defmodule MoundHunters.OhioLidar do
@moduledoc """
Functions for querying Ohio's ArcGIS tile service and coordinate conversions.
"""
require Logger
# Ohio ArcGIS tile service
@tile_service_url "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query"
# OGRIP download URL template
@download_url_template "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip"
@doc """
Convert lon/lat (WGS84) to Web Mercator (EPSG:3857).
Formula from: https://en.wikipedia.org/wiki/Web_Mercator_projection
"""
def lonlat_to_webmercator(lon, lat) do
# Earth radius in meters
r = 6378137.0
# Convert to radians
lon_rad = lon * :math.pi() / 180.0
lat_rad = lat * :math.pi() / 180.0
# Web Mercator formulas
x = r * lon_rad
y = r * :math.log(:math.tan(:math.pi() / 4.0 + lat_rad / 2.0))
{x, y}
end
@doc """
Query Ohio ArcGIS service for tile information at given coordinates.
Returns {:ok, tile_info} or {:error, reason}
tile_info contains: %{
tile_name: "BS19820747",
county: "LIC",
year: "2020",
block: "4",
note: "..."
}
"""
def query_tile_info(lon, lat) do
{x, y} = lonlat_to_webmercator(lon, lat)
geometry =
Jason.encode!(%{
x: x,
y: y,
spatialReference: %{wkid: 3857}
})
params = %{
"f" => "json",
"returnGeometry" => "false",
"spatialRel" => "esriSpatialRelIntersects",
"geometry" => geometry,
"geometryType" => "esriGeometryPoint",
"inSR" => "3857",
"outFields" => "*",
"outSR" => "3857"
}
Logger.debug("Querying ArcGIS for tile at (#{lon}, #{lat}) -> WebMercator (#{x}, #{y})")
case HTTPoison.get(@tile_service_url, [], params: params, timeout: 10_000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
parse_tile_response(body)
{:ok, %HTTPoison.Response{status_code: status}} ->
Logger.error("ArcGIS returned status #{status}")
{:error, "ArcGIS service returned status #{status}"}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to query ArcGIS: #{inspect(reason)}")
{:error, "Network error: #{inspect(reason)}"}
end
end
defp parse_tile_response(body) do
case Jason.decode(body) do
{:ok, %{"features" => []}} ->
{:error, :no_tile_found}
{:ok, %{"features" => [feature | _]}} ->
attrs = feature["attributes"]
tile_info = %{
tile_name: attrs["TileName"],
county: attrs["County"],
year: attrs["Year"],
block: attrs["Block"],
note: attrs["note"]
}
{:ok, tile_info}
{:ok, _} ->
{:error, :invalid_response}
{:error, _} ->
{:error, :json_parse_error}
end
end
@doc """
Get the download URL for a tile.
"""
def get_download_url(tile_name, county) do
@download_url_template
|> String.replace("{county}", county)
|> String.replace("{tile_name}", tile_name)
end
@doc """
Download a tile ZIP file from OGRIP.
Returns {:ok, file_path} or {:error, reason}
"""
def download_tile(tile_name, county, output_path) do
url = get_download_url(tile_name, county)
Logger.info("Downloading #{tile_name} from #{url}")
# Ensure output directory exists
output_path
|> Path.dirname()
|> File.mkdir_p!()
case HTTPoison.get(url, [], timeout: 60_000, recv_timeout: 60_000, stream_to: self()) do
{:ok, %HTTPoison.AsyncResponse{id: id}} ->
receive_download(id, output_path, 0)
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to start download: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"}
end
end
defp receive_download(id, output_path, bytes_received) do
receive do
%HTTPoison.AsyncStatus{id: ^id, code: 200} ->
Logger.debug("Download started, status 200")
receive_download(id, output_path, bytes_received)
%HTTPoison.AsyncStatus{id: ^id, code: status} ->
Logger.error("Download failed with status #{status}")
{:error, "HTTP status #{status}"}
%HTTPoison.AsyncHeaders{id: ^id} ->
# Start writing to file
File.open(output_path, [:write, :binary], fn file ->
receive_download_chunks(id, file, bytes_received)
end)
%HTTPoison.AsyncEnd{id: ^id} ->
Logger.error("Download ended prematurely")
{:error, :unexpected_end}
{:error, reason} ->
Logger.error("Download error: #{inspect(reason)}")
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
defp receive_download_chunks(id, file, bytes_received) do
receive do
%HTTPoison.AsyncChunk{id: ^id, chunk: chunk} ->
IO.binwrite(file, chunk)
new_bytes = bytes_received + byte_size(chunk)
# Log progress every 10MB
if div(new_bytes, 10_485_760) > div(bytes_received, 10_485_760) do
Logger.debug("Downloaded #{div(new_bytes, 1_048_576)} MB")
end
receive_download_chunks(id, file, new_bytes)
%HTTPoison.AsyncEnd{id: ^id} ->
size_mb = bytes_received / 1_048_576
Logger.info("Download complete: #{Float.round(size_mb, 2)} MB")
{:ok, bytes_received}
{:error, reason} ->
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
end