create backend
This commit is contained in:
112
lib/mound_hunters/application.ex
Normal file
112
lib/mound_hunters/application.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule MoundHunters.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
# Ensure log directory exists
|
||||
ensure_log_dir()
|
||||
|
||||
# Initialize Mnesia before starting supervision tree
|
||||
setup_mnesia()
|
||||
|
||||
children = [
|
||||
# Tile processing GenServer
|
||||
MoundHunters.TileProcessor,
|
||||
# HTTP server
|
||||
{Plug.Cowboy, scheme: :http, plug: MoundHuntersWeb.Router, options: [port: http_port()]}
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: MoundHunters.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
defp ensure_log_dir do
|
||||
log_dir = Application.get_env(:mound_hunters, :log_dir, "priv/logs")
|
||||
File.mkdir_p!(log_dir)
|
||||
end
|
||||
|
||||
defp http_port do
|
||||
Application.get_env(:mound_hunters, :http_port, 4000)
|
||||
end
|
||||
|
||||
defp setup_mnesia do
|
||||
mnesia_dir = Application.get_env(:mound_hunters, :mnesia_dir)
|
||||
|
||||
# Ensure mnesia directory exists
|
||||
mnesia_dir
|
||||
|> to_string()
|
||||
|> File.mkdir_p!()
|
||||
|
||||
# Stop mnesia if running
|
||||
:mnesia.stop()
|
||||
|
||||
# Create schema if it doesn't exist
|
||||
case :mnesia.create_schema([node()]) do
|
||||
:ok ->
|
||||
Logger.info("Created Mnesia schema")
|
||||
|
||||
{:error, {_node1, {:already_exists, _node2}}} ->
|
||||
Logger.debug("Mnesia schema already exists")
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to create Mnesia schema: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Start Mnesia
|
||||
:ok = :mnesia.start()
|
||||
Logger.info("Mnesia started")
|
||||
|
||||
# Create tables if they don't exist
|
||||
create_tables()
|
||||
end
|
||||
|
||||
defp create_tables do
|
||||
# Tiles table
|
||||
case :mnesia.create_table(:tiles,
|
||||
attributes: [
|
||||
:id,
|
||||
:min_lat,
|
||||
:max_lat,
|
||||
:min_lng,
|
||||
:max_lng,
|
||||
:status,
|
||||
:error_message,
|
||||
:created_at,
|
||||
:updated_at
|
||||
],
|
||||
disc_copies: [node()],
|
||||
type: :set
|
||||
) do
|
||||
{:atomic, :ok} ->
|
||||
Logger.info("Created :tiles table")
|
||||
|
||||
{:aborted, {:already_exists, :tiles}} ->
|
||||
Logger.debug(":tiles table already exists")
|
||||
|
||||
{:aborted, reason} ->
|
||||
Logger.error("Failed to create :tiles table: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Geometries table
|
||||
case :mnesia.create_table(:geometries,
|
||||
attributes: [:id, :geojson, :created_at],
|
||||
disc_copies: [node()],
|
||||
type: :set
|
||||
) do
|
||||
{:atomic, :ok} ->
|
||||
Logger.info("Created :geometries table")
|
||||
|
||||
{:aborted, {:already_exists, :geometries}} ->
|
||||
Logger.debug(":geometries table already exists")
|
||||
|
||||
{:aborted, reason} ->
|
||||
Logger.error("Failed to create :geometries table: #{inspect(reason)}")
|
||||
end
|
||||
|
||||
# Wait for tables to be ready
|
||||
:mnesia.wait_for_tables([:tiles, :geometries], 5000)
|
||||
end
|
||||
end
|
||||
121
lib/mound_hunters/boundary.ex
Normal file
121
lib/mound_hunters/boundary.ex
Normal file
@@ -0,0 +1,121 @@
|
||||
defmodule MoundHunters.Boundary do
|
||||
@moduledoc """
|
||||
Ohio state boundary checking using point-in-polygon algorithm.
|
||||
"""
|
||||
|
||||
# Simplified Ohio bounding box for fast preliminary check
|
||||
# More precise polygon would be loaded from GeoJSON
|
||||
@ohio_bbox %{
|
||||
min_lat: 38.403,
|
||||
max_lat: 42.327,
|
||||
min_lng: -84.820,
|
||||
max_lng: -80.519
|
||||
}
|
||||
|
||||
# Ohio state boundary polygon (simplified)
|
||||
# Source: Github -> PublicaMundi/MappingAPI us-states.json
|
||||
# Coordinates are [lng, lat] pairs per GeoJSON spec
|
||||
@ohio_polygon [
|
||||
{-80.518598, 41.978802},
|
||||
{-80.518598, 40.636951},
|
||||
{-80.666475, 40.582182},
|
||||
{-80.595275, 40.472643},
|
||||
{-80.600752, 40.319289},
|
||||
{-80.737675, 40.078303},
|
||||
{-80.830783, 39.711348},
|
||||
{-81.219646, 39.388209},
|
||||
{-81.345616, 39.344393},
|
||||
{-81.455155, 39.410117},
|
||||
{-81.570170, 39.267716},
|
||||
{-81.685186, 39.273193},
|
||||
{-81.811156, 39.081500},
|
||||
{-81.783771, 38.966484},
|
||||
{-81.887833, 38.873376},
|
||||
{-82.035710, 39.026731},
|
||||
{-82.221926, 38.785745},
|
||||
{-82.172634, 38.632391},
|
||||
{-82.293127, 38.577622},
|
||||
{-82.331465, 38.446175},
|
||||
{-82.594358, 38.424267},
|
||||
{-82.731282, 38.561191},
|
||||
{-82.846298, 38.588575},
|
||||
{-82.890113, 38.758361},
|
||||
{-83.032514, 38.725499},
|
||||
{-83.142052, 38.626914},
|
||||
{-83.519961, 38.703591},
|
||||
{-83.678792, 38.632391},
|
||||
{-83.903347, 38.769315},
|
||||
{-84.215533, 38.807653},
|
||||
{-84.231963, 38.895284},
|
||||
{-84.434610, 39.103408},
|
||||
{-84.817996, 39.103408},
|
||||
{-84.801565, 40.500028},
|
||||
{-84.807042, 41.694001},
|
||||
{-83.454238, 41.732339},
|
||||
{-83.065375, 41.595416},
|
||||
{-82.933929, 41.513262},
|
||||
{-82.835344, 41.589939},
|
||||
{-82.616266, 41.431108},
|
||||
{-82.479343, 41.381815},
|
||||
{-82.013803, 41.513262},
|
||||
{-81.739956, 41.485877},
|
||||
{-81.444201, 41.672093},
|
||||
{-81.011523, 41.852832},
|
||||
{-80.518598, 41.978802},
|
||||
{-80.518598, 41.978802}
|
||||
]
|
||||
|
||||
@doc """
|
||||
Check if coordinates are within Ohio boundaries.
|
||||
Returns :ok or {:error, reason}
|
||||
"""
|
||||
def check_bounds(lat, lng) when is_number(lat) and is_number(lng) do
|
||||
cond do
|
||||
not in_bounding_box?(lat, lng) ->
|
||||
{:error, "Coordinates outside Ohio bounding box"}
|
||||
|
||||
not in_polygon?(lng, lat, @ohio_polygon) ->
|
||||
{:error, "Coordinates outside Ohio boundary"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
def check_bounds(_lat, _lng) do
|
||||
{:error, "Invalid coordinates"}
|
||||
end
|
||||
|
||||
defp in_bounding_box?(lat, lng) do
|
||||
lat >= @ohio_bbox.min_lat and lat <= @ohio_bbox.max_lat and
|
||||
lng >= @ohio_bbox.min_lng and lng <= @ohio_bbox.max_lng
|
||||
end
|
||||
|
||||
# Ray casting algorithm for point-in-polygon test
|
||||
# Returns true if point (x, y) is inside the polygon
|
||||
defp in_polygon?(x, y, polygon) do
|
||||
n = length(polygon)
|
||||
|
||||
polygon
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce(false, fn {{xi, yi}, i}, inside ->
|
||||
j = rem(i + n - 1, n)
|
||||
{xj, yj} = Enum.at(polygon, j)
|
||||
|
||||
intersects =
|
||||
yi > y != yj > y and
|
||||
x < (xj - xi) * (y - yi) / (yj - yi) + xi
|
||||
|
||||
if intersects, do: not inside, else: inside
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Format coordinates to 6 decimal places for consistent lookups.
|
||||
"""
|
||||
def format_lookup_id(lat, lng) do
|
||||
lat_str = :erlang.float_to_binary(lat / 1.0, decimals: 6)
|
||||
lng_str = :erlang.float_to_binary(lng / 1.0, decimals: 6)
|
||||
"#{lat_str},#{lng_str}"
|
||||
end
|
||||
end
|
||||
196
lib/mound_hunters/ohio_lidar.ex
Normal file
196
lib/mound_hunters/ohio_lidar.ex
Normal file
@@ -0,0 +1,196 @@
|
||||
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
|
||||
107
lib/mound_hunters/repo.ex
Normal file
107
lib/mound_hunters/repo.ex
Normal file
@@ -0,0 +1,107 @@
|
||||
defmodule MoundHunters.Repo do
|
||||
@moduledoc """
|
||||
Mnesia database helpers for tiles and geometries.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
# Tile statuses
|
||||
@type tile_status :: :processing | :ready | :error
|
||||
|
||||
@doc """
|
||||
Get a tile by ID from Mnesia.
|
||||
"""
|
||||
def get_tile(tile_id) do
|
||||
case :mnesia.transaction(fn ->
|
||||
:mnesia.read(:tiles, tile_id)
|
||||
end) do
|
||||
{:atomic, [tile]} -> {:ok, tile_to_map(tile)}
|
||||
{:atomic, []} -> {:error, :not_found}
|
||||
{:aborted, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Insert or update a tile record.
|
||||
"""
|
||||
def upsert_tile(attrs) do
|
||||
tile_id = Map.fetch!(attrs, :id)
|
||||
now = System.system_time(:second)
|
||||
|
||||
tile_record =
|
||||
{:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat),
|
||||
Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status),
|
||||
Map.get(attrs, :error_message), Map.get(attrs, :created_at, now), now}
|
||||
|
||||
case :mnesia.transaction(fn ->
|
||||
:mnesia.write(tile_record)
|
||||
end) do
|
||||
{:atomic, :ok} -> {:ok, tile_to_map(tile_record)}
|
||||
{:aborted, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a shared geometry by ID.
|
||||
"""
|
||||
def get_geometry(geometry_id) do
|
||||
case :mnesia.transaction(fn ->
|
||||
:mnesia.read(:geometries, geometry_id)
|
||||
end) do
|
||||
{:atomic, [geometry]} -> {:ok, geometry_to_map(geometry)}
|
||||
{:atomic, []} -> {:error, :not_found}
|
||||
{:aborted, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create a new shared geometry.
|
||||
"""
|
||||
def create_geometry(geojson) do
|
||||
geometry_id = generate_id()
|
||||
now = System.system_time(:second)
|
||||
|
||||
geometry_record = {:geometries, geometry_id, geojson, now}
|
||||
|
||||
case :mnesia.transaction(fn ->
|
||||
:mnesia.write(geometry_record)
|
||||
end) do
|
||||
{:atomic, :ok} -> {:ok, %{id: geometry_id}}
|
||||
{:aborted, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
# Convert Mnesia tile record to map
|
||||
defp tile_to_map(
|
||||
{:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, created_at,
|
||||
updated_at}
|
||||
) do
|
||||
%{
|
||||
id: id,
|
||||
min_lat: min_lat,
|
||||
max_lat: max_lat,
|
||||
min_lng: min_lng,
|
||||
max_lng: max_lng,
|
||||
status: status,
|
||||
error_message: error_message,
|
||||
created_at: created_at,
|
||||
updated_at: updated_at
|
||||
}
|
||||
end
|
||||
|
||||
# Convert Mnesia geometry record to map
|
||||
defp geometry_to_map({:geometries, id, geojson, created_at}) do
|
||||
%{
|
||||
id: id,
|
||||
geojson: geojson,
|
||||
created_at: created_at
|
||||
}
|
||||
end
|
||||
|
||||
# Generate random 8-character alphanumeric ID
|
||||
defp generate_id do
|
||||
:crypto.strong_rand_bytes(6)
|
||||
|> Base.url_encode64(padding: false)
|
||||
|> binary_part(0, 8)
|
||||
end
|
||||
end
|
||||
335
lib/mound_hunters/tile_processor.ex
Normal file
335
lib/mound_hunters/tile_processor.ex
Normal file
@@ -0,0 +1,335 @@
|
||||
defmodule MoundHunters.TileProcessor do
|
||||
@moduledoc """
|
||||
GenServer that processes tile requests: lookup tile IDs and download/convert tiles.
|
||||
|
||||
Processing pipeline:
|
||||
1. :looking_up - Query ArcGIS for tile metadata
|
||||
2. :downloading - Download ZIP from OGRIP
|
||||
3. :extracting - Unzip the archive
|
||||
4. :converting - Run las2mound.py to create .mound file
|
||||
5. :done - Files ready in priv/tiles/
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias MoundHunters.OhioLidar
|
||||
|
||||
# Processing statuses
|
||||
@type lookup_status :: :pending | {:ok, String.t()} | {:error, String.t()}
|
||||
@type processing_status ::
|
||||
:queued | :downloading | :extracting | :converting | :done | {:error, String.t()}
|
||||
|
||||
# Client API
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Request processing for a lat/lng coordinate pair.
|
||||
Returns the lookup_id that can be used to poll progress.
|
||||
"""
|
||||
def request_tile(lat, lng) do
|
||||
lookup_id = MoundHunters.Boundary.format_lookup_id(lat, lng)
|
||||
GenServer.cast(__MODULE__, {:request_tile, lookup_id, lat, lng})
|
||||
{:ok, lookup_id}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the current lookup status for a coordinate pair.
|
||||
"""
|
||||
def get_lookup_status(lookup_id) do
|
||||
case :ets.lookup(:tile_lookups, lookup_id) do
|
||||
[{^lookup_id, status}] -> {:ok, status}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get the current processing status for a tile.
|
||||
"""
|
||||
def get_processing_status(tile_id) do
|
||||
case :ets.lookup(:tile_processing, tile_id) do
|
||||
[{^tile_id, status, _metadata}] -> {:ok, status}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get full metadata for a processing tile.
|
||||
"""
|
||||
def get_processing_metadata(tile_id) do
|
||||
case :ets.lookup(:tile_processing, tile_id) do
|
||||
[{^tile_id, status, metadata}] -> {:ok, status, metadata}
|
||||
[] -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# Server callbacks
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
# Create ETS tables
|
||||
:ets.new(:tile_lookups, [:set, :public, :named_table])
|
||||
:ets.new(:tile_processing, [:set, :public, :named_table])
|
||||
|
||||
# Ensure temp directory exists
|
||||
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
|
||||
File.mkdir_p!(temp_dir)
|
||||
|
||||
state = %{
|
||||
las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path),
|
||||
tile_output_dir: Application.get_env(:mound_hunters, :tile_output_dir),
|
||||
tile_temp_dir: temp_dir,
|
||||
processing_queue: :queue.new(),
|
||||
current_job: nil
|
||||
}
|
||||
|
||||
Logger.info("TileProcessor started")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:request_tile, lookup_id, lat, lng}, state) do
|
||||
# Check if we already have this lookup in progress or completed
|
||||
case :ets.lookup(:tile_lookups, lookup_id) do
|
||||
[] ->
|
||||
# New request - insert as pending and start lookup
|
||||
:ets.insert(:tile_lookups, {lookup_id, :pending})
|
||||
Logger.info("New tile request for #{lookup_id} (#{lat}, #{lng})")
|
||||
|
||||
# Start lookup asynchronously
|
||||
send(self(), {:lookup_tile, lookup_id, lat, lng})
|
||||
|
||||
[{^lookup_id, _status}] ->
|
||||
# Already in progress or completed, ignore duplicate request
|
||||
Logger.debug("Duplicate tile request for #{lookup_id}, ignoring")
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
|
||||
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
|
||||
|
||||
case OhioLidar.query_tile_info(lng, lat) do
|
||||
{:ok, tile_info} ->
|
||||
tile_name = tile_info.tile_name
|
||||
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
|
||||
|
||||
# Update lookup table with success
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
|
||||
|
||||
# Check if tile already exists
|
||||
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
|
||||
|
||||
if File.exists?(output_file) do
|
||||
Logger.info("Tile #{tile_name} already processed, marking as done")
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
||||
else
|
||||
# Queue for processing
|
||||
state = queue_tile_for_processing(state, tile_name, tile_info)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
{:error, :no_tile_found} ->
|
||||
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to lookup tile: #{inspect(reason)}")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:process_tile, tile_name, tile_info}, state) do
|
||||
Logger.info("Starting processing for tile #{tile_name}")
|
||||
|
||||
# Update status to downloading
|
||||
:ets.insert(:tile_processing, {tile_name, :downloading, %{tile_info: tile_info}})
|
||||
|
||||
# Download ZIP
|
||||
temp_zip = Path.join(state.tile_temp_dir, "#{tile_name}.zip")
|
||||
|
||||
case OhioLidar.download_tile(tile_name, tile_info.county, temp_zip) do
|
||||
{:ok, _bytes} ->
|
||||
send(self(), {:extract_tile, tile_name, tile_info, temp_zip})
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to download tile #{tile_name}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
# Clean up
|
||||
File.rm(temp_zip)
|
||||
|
||||
# Process next in queue
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:extract_tile, tile_name, tile_info, zip_path}, state) do
|
||||
Logger.info("Extracting tile #{tile_name}")
|
||||
:ets.insert(:tile_processing, {tile_name, :extracting, %{tile_info: tile_info}})
|
||||
|
||||
extract_dir = Path.join(state.tile_temp_dir, tile_name)
|
||||
File.mkdir_p!(extract_dir)
|
||||
|
||||
case unzip_file(zip_path, extract_dir) do
|
||||
:ok ->
|
||||
# Find the .las file
|
||||
case find_las_file(extract_dir) do
|
||||
{:ok, las_file} ->
|
||||
send(self(), {:convert_tile, tile_name, tile_info, las_file, extract_dir})
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to find LAS file in #{extract_dir}: #{reason}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "No LAS file found"}, %{}})
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(extract_dir)
|
||||
File.rm(zip_path)
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to extract #{zip_path}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(extract_dir)
|
||||
File.rm(zip_path)
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:convert_tile, tile_name, tile_info, las_file, temp_dir}, state) do
|
||||
Logger.info("Converting tile #{tile_name} to mound format")
|
||||
:ets.insert(:tile_processing, {tile_name, :converting, %{tile_info: tile_info}})
|
||||
|
||||
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
|
||||
File.mkdir_p!(state.tile_output_dir)
|
||||
|
||||
case run_las2mound(state.las2mound_script, las_file, output_file) do
|
||||
:ok ->
|
||||
Logger.info("Successfully converted #{tile_name}")
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
||||
|
||||
# Update Mnesia
|
||||
MoundHunters.Repo.upsert_tile(%{
|
||||
id: tile_name,
|
||||
status: :ready,
|
||||
min_lat: nil, # TODO: Extract from tile_info or LAS bounds
|
||||
max_lat: nil,
|
||||
min_lng: nil,
|
||||
max_lng: nil
|
||||
})
|
||||
|
||||
# Clean up temp files
|
||||
File.rm_rf!(temp_dir)
|
||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to convert #{tile_name}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(temp_dir)
|
||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp queue_tile_for_processing(state, tile_name, tile_info) do
|
||||
# Check if already in queue or processing
|
||||
case :ets.lookup(:tile_processing, tile_name) do
|
||||
[] ->
|
||||
# Add to queue
|
||||
:ets.insert(:tile_processing, {tile_name, :queued, %{tile_info: tile_info}})
|
||||
new_queue = :queue.in({tile_name, tile_info}, state.processing_queue)
|
||||
state = %{state | processing_queue: new_queue}
|
||||
|
||||
# If nothing currently processing, start now
|
||||
if state.current_job == nil do
|
||||
process_next_in_queue(state)
|
||||
else
|
||||
state
|
||||
end
|
||||
|
||||
[{^tile_name, _status, _metadata}] ->
|
||||
Logger.debug("Tile #{tile_name} already in processing queue")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp process_next_in_queue(state) do
|
||||
case :queue.out(state.processing_queue) do
|
||||
{{:value, {tile_name, tile_info}}, new_queue} ->
|
||||
Logger.info("Processing next tile in queue: #{tile_name}")
|
||||
send(self(), {:process_tile, tile_name, tile_info})
|
||||
%{state | processing_queue: new_queue, current_job: tile_name}
|
||||
|
||||
{:empty, _} ->
|
||||
Logger.debug("Processing queue empty")
|
||||
%{state | current_job: nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp unzip_file(zip_path, extract_dir) do
|
||||
case :zip.unzip(String.to_charlist(zip_path), cwd: String.to_charlist(extract_dir)) do
|
||||
{:ok, _files} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_las_file(dir) do
|
||||
case File.ls(dir) do
|
||||
{:ok, files} ->
|
||||
las_files = Enum.filter(files, &String.ends_with?(&1, ".las"))
|
||||
|
||||
case las_files do
|
||||
[las_file | _] -> {:ok, Path.join(dir, las_file)}
|
||||
[] -> {:error, :no_las_file}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp run_las2mound(script_path, input_las, output_mound) do
|
||||
Logger.info("Running: #{script_path} #{input_las} #{output_mound}")
|
||||
|
||||
case System.cmd("python3", [script_path, input_las, output_mound], stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
Logger.debug("las2mound output: #{output}")
|
||||
:ok
|
||||
|
||||
{output, exit_code} ->
|
||||
Logger.error("las2mound failed with exit code #{exit_code}: #{output}")
|
||||
{:error, "Exit code #{exit_code}"}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user