defmodule Microwaveprop.Buildings.Index do @moduledoc """ In-memory spatial index over Microsoft building footprint records. Buckets buildings into a ~1.1 km grid so that a max-height query in a small radius scans only the 9 nearest buckets. Records (`Microwaveprop.Buildings.Parser.building_record`) are bucketed by centroid; queries widen by the building's `max_radius_m` so a long warehouse straddling two buckets is still found by points in either. Loading is one-shot per tile (idempotent via the `:loaded_tiles` bucket). Queries hit ETS directly with no GenServer involvement. """ use GenServer alias Microwaveprop.Buildings.Parser @table :buildings_spatial_index @loaded_tiles_table :buildings_loaded_tiles # 0.01° ≈ 1.1 km at our latitude — strikes a balance between # bucket scan size and number of empty buckets allocated. @bucket_step 0.01 @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) @impl true def init(_opts) do ensure_tables() {:ok, %{}} end @doc """ Returns the list of building records within `radius_m` of `(lat, lon)`. Same scan path as `max_height_near/3` — useful for surfacing the individual obstacles to a UI layer. """ @spec records_near(float(), float(), number()) :: [Parser.record()] def records_near(lat, lon, radius_m) do ensure_tables() radius_deg = radius_m / 111_000.0 cos_lat = :math.cos(lat * :math.pi() / 180.0) {min_bx, max_bx} = bucket_range(lon - radius_deg / max(cos_lat, 0.1), lon + radius_deg / max(cos_lat, 0.1)) {min_by, max_by} = bucket_range(lat - radius_deg, lat + radius_deg) for by <- min_by..max_by, bx <- min_bx..max_bx, {_, list} <- :ets.lookup(@table, {bx, by}), rec <- list, within?(rec, lat, lon, cos_lat, radius_m) do rec end end defp within?(rec, lat, lon, cos_lat, radius_m) do dlat_m = (rec.centroid_lat - lat) * 111_000.0 dlon_m = (rec.centroid_lon - lon) * 111_000.0 * cos_lat dist_m = :math.sqrt(dlat_m * dlat_m + dlon_m * dlon_m) dist_m - rec.max_radius_m <= radius_m end @doc """ Returns the maximum building `height_m` within `radius_m` of `(lat, lon)`. `0.0` if no buildings indexed within the radius. """ @spec max_height_near(float(), float(), number()) :: float() def max_height_near(lat, lon, radius_m) do ensure_tables() radius_deg = radius_m / 111_000.0 cos_lat = :math.cos(lat * :math.pi() / 180.0) {min_bx, max_bx} = bucket_range(lon - radius_deg / max(cos_lat, 0.1), lon + radius_deg / max(cos_lat, 0.1)) {min_by, max_by} = bucket_range(lat - radius_deg, lat + radius_deg) for by <- min_by..max_by, bx <- min_bx..max_bx, {_, list} <- :ets.lookup(@table, {bx, by}), reduce: 0.0 do acc -> max(acc, scan_bucket(list, lat, lon, cos_lat, radius_m)) end end defp scan_bucket(list, lat, lon, cos_lat, radius_m) do Enum.reduce(list, 0.0, fn rec, acc -> dlat_m = (rec.centroid_lat - lat) * 111_000.0 dlon_m = (rec.centroid_lon - lon) * 111_000.0 * cos_lat dist_m = :math.sqrt(dlat_m * dlat_m + dlon_m * dlon_m) if dist_m - rec.max_radius_m <= radius_m, do: max(acc, rec.height_m), else: acc end) end @doc """ Insert a list of `Parser.record/0` maps into the spatial index. Idempotent at the tile level (callers gate via `loaded?/1`); within a tile, inserting the same record twice will produce a duplicate entry but will not affect query correctness. """ @spec insert_records([Parser.record()]) :: :ok def insert_records(records) when is_list(records) do ensure_tables() records |> Enum.group_by(fn r -> {bucket_x(r.centroid_lon), bucket_y(r.centroid_lat)} end) |> Enum.each(fn {key, recs} -> existing = case :ets.lookup(@table, key) do [{_, list}] -> list [] -> [] end :ets.insert(@table, {key, recs ++ existing}) end) :ok end @doc "Mark a quadkey as loaded so callers can skip re-parsing its tile." @spec mark_loaded(String.t()) :: :ok def mark_loaded(quadkey) do ensure_tables() :ets.insert(@loaded_tiles_table, {quadkey, true}) :ok end @spec loaded?(String.t()) :: boolean() def loaded?(quadkey) do ensure_tables() :ets.member(@loaded_tiles_table, quadkey) end @doc "Wipe all index state (test helper / dev convenience)." @spec clear() :: :ok def clear do ensure_tables() :ets.delete_all_objects(@table) :ets.delete_all_objects(@loaded_tiles_table) :ok end defp ensure_tables do if :ets.whereis(@table) == :undefined do :ets.new(@table, [:set, :named_table, :public, read_concurrency: true]) end if :ets.whereis(@loaded_tiles_table) == :undefined do :ets.new(@loaded_tiles_table, [:set, :named_table, :public, read_concurrency: true]) end end defp bucket_range(min, max) do {bucket_index(min), bucket_index(max)} end defp bucket_x(lon), do: bucket_index(lon) defp bucket_y(lat), do: bucket_index(lat) defp bucket_index(v), do: trunc(Float.floor(v / @bucket_step)) end