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