prop/lib/microwaveprop/buildings/parser.ex
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

80 lines
2.5 KiB
Elixir

defmodule Microwaveprop.Buildings.Parser do
@moduledoc """
Streaming parser for Microsoft Building Footprint csv.gz tiles.
Each line is a GeoJSON `Feature` whose `geometry.coordinates` is a
Polygon (or MultiPolygon) and whose `properties.height` is meters
above ground. We collapse each polygon to a centroid + bounding
radius — that's all the rover path-clearance scorer needs, and it
keeps the in-memory footprint to ~32 bytes per building.
Polygons with `height = -1.0` (no estimate from the ML model) are
dropped — including them would just add noise.
"""
@type building_record :: %{
centroid_lat: float(),
centroid_lon: float(),
max_radius_m: float(),
height_m: float()
}
@doc """
Returns a `Stream` of `t:building_record/0` for every polygon in `path` whose
height is known. Lazy; suitable for tiles with millions of rows.
"""
@dialyzer {:no_return, parse_tile: 1}
@spec parse_tile(Path.t()) :: Stream.t([building_record()])
def parse_tile(path) do
path
|> File.stream!([:read, :compressed])
|> Stream.flat_map(&parse_line/1)
end
defp parse_line(line) do
case Jason.decode(String.trim(line)) do
{:ok, %{"properties" => %{"height" => h}, "geometry" => geom}} when h > 0.0 ->
case ring_from_geometry(geom) do
[] -> []
ring -> [build_record(ring, h)]
end
_ ->
[]
end
end
defp ring_from_geometry(%{"type" => "Polygon", "coordinates" => [outer | _]}), do: outer
defp ring_from_geometry(%{"type" => "MultiPolygon", "coordinates" => [[outer | _] | _]}), do: outer
defp ring_from_geometry(_), do: []
defp build_record(ring, height) do
{sum_lat, sum_lon, n, min_lat, min_lon, max_lat, max_lon} =
Enum.reduce(ring, {0.0, 0.0, 0, 90.0, 180.0, -90.0, -180.0}, fn
[lon, lat], {sl, slon, n, mnla, mnlo, mxla, mxlo} ->
{
sl + lat,
slon + lon,
n + 1,
min(mnla, lat),
min(mnlo, lon),
max(mxla, lat),
max(mxlo, lon)
}
end)
centroid_lat = sum_lat / n
centroid_lon = sum_lon / n
# Approximate max radius: half-diagonal of the bbox in meters.
dlat_m = (max_lat - min_lat) * 111_000.0 / 2.0
dlon_m = (max_lon - min_lon) * 111_000.0 * :math.cos(centroid_lat * :math.pi() / 180.0) / 2.0
%{
centroid_lat: centroid_lat,
centroid_lon: centroid_lon,
max_radius_m: :math.sqrt(dlat_m * dlat_m + dlon_m * dlon_m),
height_m: height * 1.0
}
end
end