Calculate occasionally hung in production for ~30s. Adds per-phase timing logs to Compute.run so the slow step is visible in pod logs, and trims the Overpass road-proximity request to a 10s server-side timeout / 12s receive timeout (no Req retries) so a slow road API fails soft to no road penalty instead of stalling Calculate.
116 lines
3.5 KiB
Elixir
116 lines
3.5 KiB
Elixir
defmodule Microwaveprop.Rover.RoadProximity do
|
|
@moduledoc """
|
|
Approximate distance from each rover candidate cell to the nearest
|
|
drivable road, sourced from OpenStreetMap via the Overpass API.
|
|
|
|
One Overpass query is issued per Calculate, fetching all motorway,
|
|
trunk, primary, secondary, tertiary, unclassified, and residential
|
|
ways inside the candidate bounding box. We then scan road segments
|
|
per cell to find the minimum point-to-segment distance.
|
|
|
|
Failure (network down, Overpass throttled, no roads in box) returns
|
|
`:unavailable` and the scorer falls back to ignoring road proximity.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
@overpass_url "https://overpass-api.de/api/interpreter"
|
|
@road_types ~w(motorway trunk primary secondary tertiary unclassified residential)
|
|
@overpass_query_timeout_s 10
|
|
@overpass_receive_timeout_ms 12_000
|
|
|
|
@type latlon :: {float(), float()}
|
|
@type segment :: {latlon(), latlon()}
|
|
@type bbox :: %{required(String.t()) => float()}
|
|
|
|
@spec road_distances([map()], bbox(), keyword()) ::
|
|
{:ok, %{latlon() => float() | nil}} | {:error, term()}
|
|
def road_distances(cells, bbox, opts \\ []) do
|
|
fetch = Keyword.get(opts, :fetch, &fetch_segments/1)
|
|
|
|
case fetch.(bbox) do
|
|
{:ok, segments} when segments != [] ->
|
|
{:ok, Map.new(cells, fn c -> {{c.lat, c.lon}, nearest_road_km(c, segments)} end)}
|
|
|
|
{:ok, []} ->
|
|
{:error, :no_roads}
|
|
|
|
{:error, reason} ->
|
|
Logger.warning("rover road proximity fetch failed: #{inspect(reason)}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@spec fetch_segments(bbox()) :: {:ok, [segment()]} | {:error, term()}
|
|
def fetch_segments(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
|
|
types = Enum.join(@road_types, "|")
|
|
|
|
query = """
|
|
[out:json][timeout:#{@overpass_query_timeout_s}];
|
|
way["highway"~"^(#{types})$"](#{s},#{w},#{n},#{e});
|
|
out geom;
|
|
"""
|
|
|
|
case Req.post(@overpass_url,
|
|
form: [data: query],
|
|
receive_timeout: @overpass_receive_timeout_ms,
|
|
retry: false
|
|
) do
|
|
{:ok, %{status: 200, body: %{"elements" => elements}}} ->
|
|
{:ok, segments_from_elements(elements)}
|
|
|
|
{:ok, %{status: status}} ->
|
|
{:error, {:http, status}}
|
|
|
|
{:error, reason} ->
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
defp segments_from_elements(elements) do
|
|
Enum.flat_map(elements, fn
|
|
%{"type" => "way", "geometry" => geom} when is_list(geom) and length(geom) >= 2 ->
|
|
geom
|
|
|> Enum.map(fn %{"lat" => lat, "lon" => lon} -> {lat, lon} end)
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.map(fn [a, b] -> {a, b} end)
|
|
|
|
_ ->
|
|
[]
|
|
end)
|
|
end
|
|
|
|
defp nearest_road_km(%{lat: lat, lon: lon}, segments) do
|
|
pt = {lat, lon}
|
|
|
|
segments
|
|
|> Enum.map(&segment_distance_km(pt, &1))
|
|
|> Enum.min(fn -> nil end)
|
|
end
|
|
|
|
# Approximate point-to-segment distance using equirectangular projection
|
|
# (km), which is plenty accurate over the few-km segments we care about.
|
|
defp segment_distance_km({plat, plon}, {{alat, alon}, {blat, blon}}) do
|
|
cos_lat = :math.cos(plat * :math.pi() / 180.0)
|
|
px = plon * cos_lat * 111.0
|
|
py = plat * 111.0
|
|
ax = alon * cos_lat * 111.0
|
|
ay = alat * 111.0
|
|
bx = blon * cos_lat * 111.0
|
|
by = blat * 111.0
|
|
|
|
dx = bx - ax
|
|
dy = by - ay
|
|
seg_len_sq = dx * dx + dy * dy
|
|
|
|
t =
|
|
if seg_len_sq <= 0,
|
|
do: 0.0,
|
|
else: ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
|
|
|
|
t = t |> max(0.0) |> min(1.0)
|
|
cx = ax + t * dx
|
|
cy = ay + t * dy
|
|
:math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
|
|
end
|
|
end
|