prop/lib/microwaveprop/rover/road_proximity.ex
Graham McIntire 4bfcff4e32
perf(rover): tighten Overpass timeout and log per-step compute timing
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.
2026-04-25 18:05:46 -05:00

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