197 lines
5.4 KiB
Elixir
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
|