Type spec fixes: - duct_usable_* return boolean→float (delegate to duct_usable_for_band) - sanitize/1 spec includes :unicode error tuples - match_delete/1 broadened from :ets.match_spec() - telemetry_event local type replaces :telemetry.event/0 - wgrib2 parse_lon_val_segment corrected to tuple spec - preloaded Ecto assoc types in get_mission/get_contact! specs Unmatched returns: - _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe, :ets.new, and if-expression returns across 18 files Pattern match fixes: - markdown: restructure acc!=[] guard as direct pattern match - path_compute/pskr/skewt_location_resolver: remove dead clauses - calibrate.aprs_144: remove unreachable format_float catch-all - unused.ex: suppress MapSet.union no_opaque Also: remove unused unicode_util_compat from mix.lock
168 lines
5.2 KiB
Elixir
168 lines
5.2 KiB
Elixir
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.building_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.building_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
|
|
|
|
:ok
|
|
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
|