feat(coverage): LOS/NLOS dual compute + buildings clutter + MS importer

Closes the cnHeat-parity gap on modes and clutter:

Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
  query that returns each building polygon as a compact
  %{height_m, coords} map ready for in-memory point-in-polygon
  testing, so the per-pixel hot loop never round-trips to
  Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
  :clutter list and adds the building rooftop height to terrain
  whenever a sample point falls inside a polygon. Pure ray-cast
  PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
  job and computes BOTH LOS and NLOS variants per SM-height
  tier (12 PNGs total). LOS passes clutter: [] (height-above-
  clutter view), NLOS passes the prefetched list (height-above-
  ground view with rooftop diffraction). Pixel-loop signature
  bundled into a ctx map to keep the function arity within
  Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
  GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
  upserts into coverage_buildings in 500-row batches via
  insert_all + ON CONFLICT (source, ms_footprint_id). Handles
  Polygon and MultiPolygon geometries; stable-hashes the bbox
  when an explicit feature id is missing so re-imports are
  idempotent. Public import_file/2 lets ops drive a state at
  a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
  whitelist, payload exposed via list_ready_for_organization.

Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
  Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
  the matching nlos_tiers PNG, drops the cached pixel data, and
  re-decodes for the RSSI filter; falls back to the other
  mode's tiers when one is empty so older coverages still
  render. coverage_hooks chunk: 22 KB → 25 KB.
This commit is contained in:
Graham McIntire 2026-05-06 16:50:05 -05:00
parent 434f5782d0
commit ce9bd744c7
9 changed files with 510 additions and 57 deletions

View file

@ -258,8 +258,11 @@ type CoveragePayload = {
tx: { lat: number | null; lon: number | null; azimuth: number | null }
site: string | null
height_tiers: Record<string, string>
nlos_tiers: Record<string, string>
}
type CoverageMode = "los" | "nlos"
// SM install heights (ft) the worker pre-renders. Index = slider step.
const HEIGHT_TIERS_FT = [6, 10, 15, 20, 30, 40]
const HEIGHT_TIERS_M = HEIGHT_TIERS_FT.map((ft) => +(ft * 0.3048).toFixed(2))
@ -283,9 +286,11 @@ type MultiCoverageMapState = PhxHookContext & {
filters: Map<string, CoverageFilterState>
rssiThreshold: number
heightTierIdx: number
mode: CoverageMode
opacityListener: ((e: Event) => void) | null
rssiListener: ((e: Event) => void) | null
heightListener: ((e: Event) => void) | null
modeListeners: Array<{ el: HTMLElement; fn: (e: Event) => void }>
searchListener: ((e: KeyboardEvent) => void) | null
probeMarker: any | null
searchMarker: any | null
@ -297,8 +302,10 @@ type MultiCoverageMapState = PhxHookContext & {
bindOpacitySlider: () => void
bindRssiSlider: () => void
bindHeightSlider: () => void
bindModeToggle: () => void
bindBaseToggle: () => void
bindSearch: () => void
setMode: (mode: CoverageMode) => void
currentOpacity: () => number
pngForCoverage: (c: CoveragePayload) => string
placeProbeMarker: (lat: number, lon: number) => void
@ -345,9 +352,11 @@ export const MultiCoverageMap = {
filters: new Map(),
rssiThreshold: -100,
heightTierIdx: 1,
mode: "los",
opacityListener: null,
rssiListener: null,
heightListener: null,
modeListeners: [],
searchListener: null,
probeMarker: null,
searchMarker: null,
@ -374,6 +383,8 @@ export const MultiCoverageMap = {
if (slider) slider.removeEventListener("input", this.heightListener)
this.heightListener = null
}
this.modeListeners.forEach(({ el, fn }) => el.removeEventListener("click", fn))
this.modeListeners = []
if (this.searchListener) {
const input = document.getElementById("coverage-map-search")
if (input) input.removeEventListener("keydown", this.searchListener)
@ -429,6 +440,7 @@ export const MultiCoverageMap = {
this.bindOpacitySlider()
this.bindRssiSlider()
this.bindHeightSlider()
this.bindModeToggle()
this.bindBaseToggle()
this.bindSearch()
@ -502,12 +514,15 @@ export const MultiCoverageMap = {
void this.loadCoverageImage(c)
},
// Picks the PNG URL for the currently-selected SM-height tier,
// falling back to the legacy single-height png when the coverage
// hasn't been recomputed since the multi-tier worker shipped.
// Picks the PNG URL for the currently-selected mode + SM-height
// tier, falling back through (mode tiers → other-mode tiers → the
// legacy single-height png) so older coverages still render.
pngForCoverage(this: MultiCoverageMapState, c: CoveragePayload): string {
const tiers = c.height_tiers || {}
const primary = this.mode === "los" ? c.height_tiers : c.nlos_tiers
const fallback = this.mode === "los" ? c.nlos_tiers : c.height_tiers
const tiers = (primary && Object.keys(primary).length > 0) ? primary : fallback
const targetM = HEIGHT_TIERS_M[this.heightTierIdx]
if (!tiers || Object.keys(tiers).length === 0) return c.png
const direct = tiers[String(targetM)] || tiers[targetM.toFixed(2)]
@ -654,6 +669,58 @@ export const MultiCoverageMap = {
slider.addEventListener("input", this.rssiListener)
},
// LOS / NLOS mode toggle. cnHeat-style: LOS treats clutter as
// ground baseline (predictions assume install-above-roof), NLOS
// treats clutter as obstacles (height-above-ground SM install).
bindModeToggle(this: MultiCoverageMapState) {
const buttons = ["coverage-mode-los", "coverage-mode-nlos"]
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
buttons.forEach((btn) => {
const fn = () => {
const mode = (btn.dataset.mode as CoverageMode) || "los"
this.setMode(mode)
}
btn.addEventListener("click", fn)
this.modeListeners.push({ el: btn, fn })
})
},
setMode(this: MultiCoverageMapState, mode: CoverageMode) {
if (mode === this.mode) return
this.mode = mode
const losBtn = document.getElementById("coverage-mode-los")
const nlosBtn = document.getElementById("coverage-mode-nlos")
const label = document.getElementById("coverage-mode-label")
const activeCls = ["text-gray-900", "dark:text-white", "bg-blue-100", "dark:bg-blue-900/40"]
const inactiveCls = ["text-gray-700", "dark:text-gray-300", "hover:bg-gray-100", "dark:hover:bg-gray-800"]
if (losBtn && nlosBtn) {
const active = mode === "los" ? losBtn : nlosBtn
const inactive = mode === "los" ? nlosBtn : losBtn
active.classList.add(...activeCls)
active.classList.remove(...inactiveCls)
inactive.classList.add(...inactiveCls)
inactive.classList.remove(...activeCls)
}
if (label) {
label.textContent = mode === "los" ? "Height above clutter" : "Height above ground"
}
this.filters.forEach((s) => {
if (s.blobUrl) URL.revokeObjectURL(s.blobUrl)
})
this.filters.clear()
this.coverages.forEach((c) => {
const overlay = this.overlays.get(c.id) as { setUrl: (url: string) => void } | undefined
if (overlay) overlay.setUrl(this.pngForCoverage(c))
void this.loadCoverageImage(c)
})
},
// Vertical SM-height slider (cnHeat-style). On change we re-fetch
// each coverage's PNG at the new tier, re-decode for the RSSI
// filter cache, and re-render. Cheap if the height_tiers map is

View file

@ -0,0 +1,63 @@
defmodule Towerops.Coverages.Buildings do
@moduledoc """
Spatial query helpers for the cached `coverage_buildings` table.
Used by `Towerops.Workers.CoverageWorker` to pull every building
whose footprint overlaps the coverage bbox once per job, then
pass the result to `Towerops.Coverages.Profile.sample/5` so per
pixel propagation runs see clutter heights without re-querying
PostGIS.
"""
import Ecto.Query
alias Towerops.Coverages.Building
alias Towerops.Repo
@typedoc """
Compact, JSON-friendly building shape consumed by Profile.sample.
"""
@type clutter :: %{height_m: float(), coords: [{number(), number()}]}
@doc """
Returns every building footprint intersecting the WGS84 bbox
`{west, south, east, north}` as a list of `clutter` maps. The
height defaults to `Building.default_height_m/0` when the source
row lacks one.
"""
@spec for_bbox({number(), number(), number(), number()}) :: [clutter()]
def for_bbox({west, south, east, north}) do
envelope = %Geo.Polygon{
coordinates: [
[
{west, south},
{east, south},
{east, north},
{west, north},
{west, south}
]
],
srid: 4326
}
Building
|> where([b], fragment("ST_Intersects(?, ?)", b.geom, ^envelope))
|> Repo.all()
|> Enum.map(&to_clutter/1)
end
@doc """
Converts a `Towerops.Coverages.Building` row into the compact
shape Profile.sample/5 expects. Public for tests.
"""
@spec to_clutter(Building.t()) :: clutter()
def to_clutter(%Building{} = b) do
%{
height_m: Building.rooftop_height_m(b),
coords: polygon_coords(b.geom)
}
end
defp polygon_coords(%Geo.Polygon{coordinates: [outer | _]}) when is_list(outer), do: outer
defp polygon_coords(_), do: []
end

View file

@ -75,8 +75,11 @@ defmodule Towerops.Coverages.Coverage do
# Map of receiver-height-in-metres (string key) → relative PNG path.
# Populated by the worker for cnHeat-style "what coverage do I get
# at SM height X?" exploration. Always includes the default tier
# whose path also lives at `png_path`.
# whose path also lives at `png_path`. `height_tiers` is the LOS
# ("Height above clutter") variant; `nlos_tiers` is the NLOS
# ("Height above ground") variant where buildings act as obstacles.
field :height_tiers, :map, default: %{}
field :nlos_tiers, :map, default: %{}
belongs_to :site, Site
belongs_to :organization, Organization
@ -227,7 +230,8 @@ defmodule Towerops.Coverages.Coverage do
:bbox_max_lon,
:raster_path,
:png_path,
:height_tiers
:height_tiers,
:nlos_tiers
])
|> validate_inclusion(:status, @statuses)
|> validate_number(:progress_pct, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)

View file

@ -55,16 +55,33 @@ defmodule Towerops.Coverages.Profile do
list always has length `n` and is safe to feed into propagation.
"""
@spec sample(map(), {number(), number()}, {number(), number()}, pos_integer()) :: [float()]
def sample(grid, {from_lat, from_lon}, _to, 1) do
[elevation_or_zero(grid, from_lat, from_lon)]
def sample(grid, from, to, n), do: sample(grid, from, to, n, [])
@doc """
Same as `sample/4` but accepts `:clutter` (a list of building polygons
the caller has prefetched for this path). Each polygon must look like
`%{height_m: float(), coords: [{lon, lat}, ...]}` closed ring,
WGS84. Sample points that fall inside a polygon get the building's
rooftop height added to terrain so the diffraction model "sees" the
obstacle.
This is the cnHeat NLOS-mode input. Pass `clutter: []` (or omit) for
bare-terrain LOS sampling.
"""
@spec sample(map(), {number(), number()}, {number(), number()}, pos_integer(), keyword()) ::
[float()]
def sample(grid, {from_lat, from_lon}, _to, 1, opts) do
[dsm_height(grid, from_lat, from_lon, Keyword.get(opts, :clutter, []))]
end
def sample(grid, {from_lat, from_lon}, {to_lat, to_lon}, n) when n >= 2 do
def sample(grid, {from_lat, from_lon}, {to_lat, to_lon}, n, opts) when n >= 2 do
clutter = Keyword.get(opts, :clutter, [])
Enum.map(0..(n - 1), fn i ->
t = i / (n - 1)
lat = from_lat + (to_lat - from_lat) * t
lon = from_lon + (to_lon - from_lon) * t
elevation_or_zero(grid, lat, lon)
dsm_height(grid, lat, lon, clutter)
end)
end
@ -95,6 +112,42 @@ defmodule Towerops.Coverages.Profile do
end
end
# Returns the DSM height at (lat, lon) — terrain plus the rooftop
# of any building polygon that contains the point. Tree canopy
# could plug in here once a height-keyed source is wired up.
defp dsm_height(grid, lat, lon, []), do: elevation_or_zero(grid, lat, lon)
defp dsm_height(grid, lat, lon, clutter) do
base = elevation_or_zero(grid, lat, lon)
base + clutter_at(clutter, lat, lon)
end
defp clutter_at([], _lat, _lon), do: 0.0
defp clutter_at(polygons, lat, lon) do
Enum.reduce_while(polygons, 0.0, fn %{height_m: h, coords: coords}, _acc ->
if point_in_polygon?(coords, lon, lat) do
{:halt, h}
else
{:cont, 0.0}
end
end)
end
# Standard ray-casting point-in-polygon. `coords` is a list of
# {lon, lat} tuples forming a closed ring (last point == first).
defp point_in_polygon?(coords, x, y) do
coords
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(false, fn [{x1, y1}, {x2, y2}], inside ->
crosses =
y1 > y != y2 > y and
x < (x2 - x1) * (y - y1) / (y2 - y1) + x1
if crosses, do: not inside, else: inside
end)
end
defp read_cell(cells, row_idx, col_idx, nodata) do
case Enum.at(cells, row_idx) do
nil ->

View file

@ -31,6 +31,7 @@ defmodule Towerops.Workers.CoverageWorker do
alias Towerops.Coverages
alias Towerops.Coverages.Antenna
alias Towerops.Coverages.Buildings
alias Towerops.Coverages.Coverage
alias Towerops.Coverages.Profile
alias Towerops.Coverages.Propagation
@ -79,39 +80,50 @@ defmodule Towerops.Workers.CoverageWorker do
{:ok, bbox} <- compute_bbox(coverage, lat, lon),
_ = update_progress(coverage, "computing", 5),
{:ok, grid} <- fetch_terrain(bbox, coverage.cell_size_m, lat),
clutter = Buildings.for_bbox(bbox),
_ = update_progress(coverage, "computing", 15),
{:ok, tiers} <- compute_all_tiers(coverage, antenna, lat, lon, bbox, grid) do
{:ok, tiers} <- compute_all_tiers(coverage, antenna, lat, lon, bbox, grid, clutter) do
finalize(coverage, tiers)
else
{:error, reason} -> fail(coverage, reason)
end
end
# Runs compute_pixels once per SM-height tier and writes a
# per-tier raster + PNG. Returns a map keyed by the same data the
# finalize step persists onto the coverage row.
defp compute_all_tiers(coverage, antenna, lat, lon, bbox, grid) do
total = length(@sm_height_tiers_m)
# Runs compute_pixels twice per SM-height tier (LOS / NLOS) and
# writes per-tier raster + PNG sets. LOS treats clutter as ground
# baseline (cnHeat "Height above clutter"); NLOS samples the
# prefetched building list into the path profile so rooftop
# diffraction shadows show up ("Height above ground").
defp compute_all_tiers(coverage, antenna, lat, lon, bbox, grid, clutter) do
indexed = Enum.with_index(@sm_height_tiers_m)
{results, error} =
Enum.reduce_while(indexed, {%{}, nil}, fn {height_m, idx}, {acc, _last} ->
cov = %{coverage | receiver_height_m: height_m}
progress = 15 + round((idx + 1) / total * 75)
initial = {%{los: %{}, nlos: %{}}, nil}
with {:ok, output} <- compute_pixels(cov, antenna, lat, lon, bbox, grid),
suffix = tier_suffix(height_m),
{:ok, paths} <- Raster.write(coverage, output.pixels, output.dims, suffix) do
_ = update_progress(coverage, "computing", progress)
{:cont, {Map.put(acc, height_m, paths), nil}}
else
{:error, reason} -> {:halt, {acc, reason}}
{results, error} =
Enum.reduce_while(indexed, initial, fn {height_m, idx}, {acc, _last} ->
case compute_tier(coverage, antenna, lat, lon, bbox, grid, clutter, height_m) do
{:ok, %{los: los_paths, nlos: nlos_paths}} ->
update_progress(
coverage,
"computing",
15 + round((idx + 1) / length(@sm_height_tiers_m) * 75)
)
new_acc = %{
los: Map.put(acc.los, height_m, los_paths),
nlos: Map.put(acc.nlos, height_m, nlos_paths)
}
{:cont, {new_acc, nil}}
{:error, reason} ->
{:halt, {acc, reason}}
end
end)
case {error, Map.get(results, @default_tier_m)} do
case {error, Map.get(results.los, @default_tier_m)} do
{nil, %{} = default} ->
{:ok, %{tiers: results, default: default}}
{:ok, %{tiers: results.los, nlos_tiers: results.nlos, default: default}}
{nil, nil} ->
{:error, :default_tier_missing}
@ -121,9 +133,29 @@ defmodule Towerops.Workers.CoverageWorker do
end
end
defp tier_suffix(height_m) do
defp compute_tier(coverage, antenna, lat, lon, bbox, grid, clutter, height_m) do
cov = %{coverage | receiver_height_m: height_m}
los_ctx = %{antenna: antenna, grid: grid, bbox: bbox, clutter: []}
nlos_ctx = %{antenna: antenna, grid: grid, bbox: bbox, clutter: clutter}
with {:ok, los_out} <- compute_pixels(cov, los_ctx, lat, lon),
{:ok, los_paths} <-
Raster.write(coverage, los_out.pixels, los_out.dims, tier_suffix(height_m, :los)),
{:ok, nlos_out} <- compute_pixels(cov, nlos_ctx, lat, lon),
{:ok, nlos_paths} <-
Raster.write(coverage, nlos_out.pixels, nlos_out.dims, tier_suffix(height_m, :nlos)) do
{:ok, %{los: los_paths, nlos: nlos_paths}}
end
end
defp tier_suffix(height_m, mode) do
cm = round(height_m * 100)
"_h#{cm}cm"
case mode do
:los -> "_h#{cm}cm"
:nlos -> "_h#{cm}cm_nlos"
end
end
defp resolve_antenna(%Coverage{antenna_slug: slug}) do
@ -163,31 +195,31 @@ defmodule Towerops.Workers.CoverageWorker do
Application.get_env(:towerops, :coverage_terrain_module, Towerops.Lidar)
end
defp compute_pixels(coverage, antenna, lat, lon, bbox, grid) do
defp compute_pixels(coverage, ctx, lat, lon) do
%{antenna: antenna, grid: grid, bbox: bbox} = ctx
%{ncols: ncols, nrows: nrows} = grid
{min_lon, min_lat, max_lon, max_lat} = bbox
cell_lat = (max_lat - min_lat) / nrows
cell_lon = (max_lon - min_lon) / ncols
# TX position: site/coverage centre, plus tower-mount height above ground.
base_elev = elevation_at_or_zero(grid, lat, lon)
tx_height = (coverage.height_agl_m || 0.0) + (coverage.height_above_rooftop_m || 0.0)
tx_z = base_elev + tx_height
eirp = Coverage.eirp_dbm(coverage, antenna.gain_dbi)
pixel_ctx = Map.put(ctx, :eirp, eirp)
tx = {{lat, lon}, tx_z, tx_height}
rows =
0..(nrows - 1)
|> Task.async_stream(
fn r ->
row_lat = max_lat - (r + 0.5) * cell_lat
tx = {{lat, lon}, tx_z, tx_height}
for c <- 0..(ncols - 1) do
pixel_lon = min_lon + (c + 0.5) * cell_lon
compute_pixel(coverage, antenna, tx, {row_lat, pixel_lon}, grid, eirp)
compute_pixel(coverage, pixel_ctx, tx, {row_lat, pixel_lon})
end
end,
ordered: true,
@ -209,33 +241,33 @@ defmodule Towerops.Workers.CoverageWorker do
}}
end
defp compute_pixel(coverage, antenna, {tx_latlon, _tx_z, _tx_height} = tx, rx_latlon, grid, eirp) do
defp compute_pixel(coverage, ctx, {tx_latlon, _tx_z, _tx_height} = tx, rx_latlon) do
distance_m = Profile.great_circle_distance_m(tx_latlon, rx_latlon)
cond do
distance_m < 1.0 ->
eirp
ctx.eirp
distance_m > coverage.radius_m * 1.05 ->
:nan
true ->
do_compute_pixel(coverage, antenna, tx, rx_latlon, grid, eirp, distance_m)
do_compute_pixel(coverage, ctx, tx, rx_latlon, distance_m)
end
end
defp do_compute_pixel(
coverage,
antenna,
ctx,
{{tx_lat, tx_lon} = tx_latlon, tx_z, tx_height},
{rx_lat, rx_lon} = rx_latlon,
grid,
eirp,
distance_m
) do
# 1. Path profile (terrain heights from TX → RX).
%{antenna: antenna, grid: grid, clutter: clutter, eirp: eirp} = ctx
# 1. Path profile (terrain + building clutter from TX → RX).
samples = max(3, min(@profile_samples, ceil(distance_m / coverage.cell_size_m) + 2))
profile = Profile.sample(grid, tx_latlon, rx_latlon, samples)
profile = Profile.sample(grid, tx_latlon, rx_latlon, samples, clutter: clutter)
# Replace TX endpoint with the ground elevation under the antenna (so the
# path loss model sees the correct antenna height above terrain).
@ -320,14 +352,9 @@ defmodule Towerops.Workers.CoverageWorker do
defp status_atom("ready"), do: :ready
defp status_atom("failed"), do: :failed
defp finalize(coverage, %{tiers: tiers, default: default}) do
defp finalize(coverage, %{tiers: tiers, nlos_tiers: nlos_tiers, default: default}) do
%{tif: tif, png: png, bbox: {min_lat, min_lon, max_lat, max_lon}} = default
height_tiers =
Map.new(tiers, fn {height_m, %{png: tier_png}} ->
{Float.to_string(height_m * 1.0), tier_png}
end)
{:ok, updated} =
Coverages.mark_status(coverage, "ready", %{
progress_pct: 100,
@ -335,7 +362,8 @@ defmodule Towerops.Workers.CoverageWorker do
computed_at: DateTime.truncate(DateTime.utc_now(), :second),
raster_path: tif,
png_path: png,
height_tiers: height_tiers,
height_tiers: tiers_map(tiers),
nlos_tiers: tiers_map(nlos_tiers),
bbox_min_lat: min_lat,
bbox_min_lon: min_lon,
bbox_max_lat: max_lat,
@ -346,6 +374,12 @@ defmodule Towerops.Workers.CoverageWorker do
:ok
end
defp tiers_map(tiers) do
Map.new(tiers, fn {height_m, %{png: tier_png}} ->
{Float.to_string(height_m * 1.0), tier_png}
end)
end
defp fail(coverage, reason) do
message = describe_error(reason)
Logger.warning("CoverageWorker: failed for #{coverage.id}: #{message}")

View file

@ -0,0 +1,191 @@
defmodule Towerops.Workers.MsBuildingsImportWorker do
@moduledoc """
Imports Microsoft GlobalMLBuildingFootprints (or any GeoJSONSeq /
newline-delimited GeoJSON) into `coverage_buildings`.
Usage from IEx:
Towerops.Workers.MsBuildingsImportWorker.new(%{
path: "/data/Texas.geojsonl",
source: "ms_global_ml"
})
|> Oban.insert()
The worker streams the file line-by-line so even multi-GB state
exports stay flat in memory. Each line is a Feature whose
`geometry.coordinates` form a closed Polygon ring and whose
`properties.height` (when present) is the rooftop height in
metres. Rows missing height fall back to the schema's default.
Imports are idempotent: rows are upserted on
`(source, ms_footprint_id)`; if the import file lacks a stable id
we hash the geometry's bounding box so re-running with the same
data is a no-op.
"""
use Oban.Worker, queue: :default, max_attempts: 1
alias Towerops.Coverages.Building
alias Towerops.Repo
require Logger
@batch_size 500
@impl Oban.Worker
def perform(%Oban.Job{args: %{"path" => path} = args}) do
source = Map.get(args, "source", "ms_global_ml")
if File.exists?(path) do
stream_features(path, source)
:ok
else
Logger.error("MsBuildingsImportWorker: file not found at #{path}")
{:error, :file_missing}
end
end
@doc """
Imports a GeoJSONSeq file synchronously. Public so an admin task
/ IEx can drive the import without the Oban round-trip.
"""
@spec import_file(String.t(), String.t()) ::
{:ok, %{inserted: non_neg_integer(), skipped: non_neg_integer()}}
| {:error, term()}
def import_file(path, source \\ "ms_global_ml") do
if File.exists?(path) do
result = stream_features(path, source)
{:ok, result}
else
{:error, :file_missing}
end
end
defp stream_features(path, source) do
initial = %{batch: [], inserted: 0, skipped: 0}
state =
path
|> File.stream!(:line, [:read, :line])
|> Enum.reduce(initial, &accumulate(&1, &2, source))
final_inserted =
if state.batch == [] do
state.inserted
else
state.inserted + flush(state.batch)
end
Logger.info(
"MsBuildingsImportWorker: imported #{final_inserted} buildings " <>
"from #{path} (skipped #{state.skipped})"
)
%{inserted: final_inserted, skipped: state.skipped}
end
# Per-line reducer: feature → either flush a full batch or extend it.
# Pulls the nested case out of the Enum.reduce so credo's max-depth-2
# check stays happy.
defp accumulate(line, acc, source) do
case decode_feature(line, source) do
{:ok, attrs} -> add_to_batch(acc, attrs)
:skip -> %{acc | skipped: acc.skipped + 1}
end
end
defp add_to_batch(acc, attrs) do
batch = [attrs | acc.batch]
if length(batch) >= @batch_size do
%{acc | batch: [], inserted: acc.inserted + flush(batch)}
else
%{acc | batch: batch}
end
end
defp decode_feature(line, source) do
line = String.trim(line)
if line == "" do
:skip
else
try do
case Jason.decode(line) do
{:ok, %{"type" => "Feature"} = feature} ->
extract_attrs(feature, source)
_ ->
:skip
end
rescue
_ -> :skip
end
end
end
defp extract_attrs(%{"geometry" => geom, "properties" => props}, source) when is_map(geom) and is_map(props) do
case to_polygon(geom) do
{:ok, polygon} ->
height = parse_height(props)
derived = Map.get(props, "derived_height", false)
ms_id = Map.get(props, "id") || Map.get(props, "FID") || hash_polygon(polygon)
{:ok,
%{
source: source,
geom: polygon,
height_m: height,
derived_height: !!derived,
ms_footprint_id: to_string(ms_id),
inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
}}
:error ->
:skip
end
end
defp extract_attrs(_feature, _source), do: :skip
defp to_polygon(%{"type" => "Polygon", "coordinates" => [outer | inner]}) when is_list(outer) do
coords = [Enum.map(outer, fn [lon, lat | _] -> {lon, lat} end) | rings(inner)]
{:ok, %Geo.Polygon{coordinates: coords, srid: 4326}}
end
defp to_polygon(%{"type" => "MultiPolygon", "coordinates" => [first | _rest]}) when is_list(first) do
[outer | inner] = first
coords = [Enum.map(outer, fn [lon, lat | _] -> {lon, lat} end) | rings(inner)]
{:ok, %Geo.Polygon{coordinates: coords, srid: 4326}}
end
defp to_polygon(_), do: :error
defp rings(rings), do: Enum.map(rings, fn ring -> Enum.map(ring, fn [lon, lat | _] -> {lon, lat} end) end)
defp parse_height(props) do
cond do
h = props["height"] -> normalize_height(h)
h = props["height_m"] -> normalize_height(h)
h = props["HEIGHT"] -> normalize_height(h)
true -> nil
end
end
defp normalize_height(h) when is_number(h), do: h * 1.0
defp normalize_height(_), do: nil
defp hash_polygon(%Geo.Polygon{coordinates: [outer | _]}) do
bytes = :erlang.term_to_binary(outer)
:sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower) |> binary_part(0, 16)
end
defp flush(batch) do
{count, _} =
Repo.insert_all(Building, Enum.reverse(batch),
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:source, :ms_footprint_id]
)
count
end
end

View file

@ -133,9 +133,13 @@ defmodule ToweropsWeb.CoverageLive.Map do
bbox: [c.bbox_min_lat, c.bbox_min_lon, c.bbox_max_lat, c.bbox_max_lon],
tx: %{lat: tx_lat, lon: tx_lon, azimuth: c.azimuth_deg},
site: c.site && c.site.name,
# Map of "height_m" string → relative PNG path. Empty for
# coverages computed before the multi-height pipeline.
height_tiers: c.height_tiers || %{}
# cnHeat-style mode/height tier maps. `height_tiers` is the
# LOS view (height-above-clutter), `nlos_tiers` the NLOS view
# (height-above-ground, buildings as obstacles). Both empty
# for coverages that haven't been recomputed since the
# multi-mode pipeline shipped.
height_tiers: c.height_tiers || %{},
nlos_tiers: c.nlos_tiers || %{}
}
end)
end

View file

@ -55,8 +55,31 @@
class="pointer-events-none fixed top-32 left-1/2 -translate-x-[640px] z-[1100]"
id="coverage-height-control"
>
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-3 flex flex-col items-center gap-2 w-20">
<span class="text-[10px] font-medium text-gray-700 dark:text-gray-300 text-center leading-tight">
<div class="pointer-events-auto rounded-lg border border-gray-200 dark:border-white/10 bg-white/95 dark:bg-gray-900/95 backdrop-blur shadow-lg p-3 flex flex-col items-center gap-2 w-24">
<%!-- LOS vs NLOS mode toggle. The label and reference frame
of the height slider switch with the mode. --%>
<div class="flex w-full text-[10px] font-medium border border-gray-200 dark:border-white/10 rounded overflow-hidden">
<button
type="button"
id="coverage-mode-los"
data-mode="los"
class="flex-1 px-1 py-1 text-gray-900 dark:text-white bg-blue-100 dark:bg-blue-900/40"
>
LOS
</button>
<button
type="button"
id="coverage-mode-nlos"
data-mode="nlos"
class="flex-1 px-1 py-1 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
>
NLOS
</button>
</div>
<span
id="coverage-mode-label"
class="text-[10px] font-medium text-gray-700 dark:text-gray-300 text-center leading-tight"
>
{t("Height above clutter")}
</span>
<input

View file

@ -0,0 +1,14 @@
defmodule Towerops.Repo.Migrations.AddNlosTiersToCoverages do
use Ecto.Migration
def change do
alter table(:coverages) do
# cnHeat-style "Height above ground" (NLOS) variant of the per
# SM-height heatmap set. Same shape as `height_tiers`: receiver
# height (m, string key) → relative PNG path. Buildings act as
# obstacles in this mode whereas `height_tiers` (LOS) treats
# rooftop as ground baseline.
add :nlos_tiers, :map, default: %{}, null: false
end
end
end