Prod logs showed road_proximity took 186 s on a single Calculate. Req's receive_timeout only catches TCP idle, not slow streaming, so a trickling Overpass response could not be cancelled. Wrap the call in a Task.yield/shutdown deadline and cache successful results by 0.05°-rounded bbox so adjacent Calculates skip Overpass entirely.
149 lines
4.6 KiB
Elixir
149 lines
4.6 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.
|
|
"""
|
|
|
|
alias Microwaveprop.Cache
|
|
|
|
require Logger
|
|
|
|
@overpass_url "https://overpass-api.de/api/interpreter"
|
|
@road_types ~w(motorway trunk primary secondary tertiary unclassified residential)
|
|
@overpass_query_timeout_s 6
|
|
@overpass_receive_timeout_ms 8_000
|
|
@overpass_hard_deadline_ms 8_000
|
|
@cache_ttl_ms 6 * 60 * 60 * 1_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" => _, "west" => _, "north" => _, "east" => _} = bbox) do
|
|
key = {:rover_road_segments, cache_key(bbox)}
|
|
|
|
case Cache.fetch_or_store(key, @cache_ttl_ms, fn -> do_fetch_segments(bbox) end) do
|
|
{:ok, _segments} = ok ->
|
|
ok
|
|
|
|
{:error, _} = err ->
|
|
# Don't poison the cache with errors — subsequent Calculates retry.
|
|
Cache.invalidate(key)
|
|
err
|
|
end
|
|
end
|
|
|
|
defp do_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;
|
|
"""
|
|
|
|
task =
|
|
Task.async(fn ->
|
|
Req.post(@overpass_url,
|
|
form: [data: query],
|
|
receive_timeout: @overpass_receive_timeout_ms,
|
|
retry: false
|
|
)
|
|
end)
|
|
|
|
case Task.yield(task, @overpass_hard_deadline_ms) || Task.shutdown(task, :brutal_kill) do
|
|
{:ok, {:ok, %{status: 200, body: %{"elements" => elements}}}} ->
|
|
{:ok, segments_from_elements(elements)}
|
|
|
|
{:ok, {:ok, %{status: status}}} ->
|
|
{:error, {:http, status}}
|
|
|
|
{:ok, {:error, reason}} ->
|
|
{:error, reason}
|
|
|
|
nil ->
|
|
{:error, :overpass_deadline_exceeded}
|
|
end
|
|
end
|
|
|
|
# Round to 0.05° (~5 km) so adjacent Calculate calls share cache hits.
|
|
defp cache_key(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
|
|
{round_to(s, 0.05), round_to(w, 0.05), round_to(n, 0.05), round_to(e, 0.05)}
|
|
end
|
|
|
|
defp round_to(v, step), do: Float.round(v / step) * step
|
|
|
|
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
|