prop/lib/microwaveprop/rover/road_proximity.ex
Graham McIntire af2ab3c969
fix(rover): bound Overpass with hard 8s deadline + 6h ETS cache by bbox
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.
2026-04-26 09:25:08 -05:00

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