prop/lib/microwaveprop/rover/road_proximity.ex
Graham McIntire 079346a1b9
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m38s
fix: resolve all 281 credo issues across source and test files
- F-level (228): replace length/1 with Enum.count_until/2 or pattern
  matching; convert Enum.flat_map+if to Enum.filter+Enum.map; fix
  identity case in narr_client
- W-level (46): normalize dual atom/string key access in weather_layers,
  beacon_measurements, surface, skewt_live, contact_live/show; add
  :data_provider and weather-map assigns to ignored_assigns in credo
  config (consumed by child components credo can't trace); remove
  weak is_list assertion; remove explicit assert_receive timeout
- R-level (6): replace 'This module provides...' moduledocs with
  meaningful descriptions
- Also fix 4 compile-connected xref issues by deferring
  BandConfig.band_options() from module attribute to runtime

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:04:21 -05:00

149 lines
4.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.
"""
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} ->
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