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:
parent
434f5782d0
commit
ce9bd744c7
9 changed files with 510 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
63
lib/towerops/coverages/buildings.ex
Normal file
63
lib/towerops/coverages/buildings.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
191
lib/towerops/workers/ms_buildings_import_worker.ex
Normal file
191
lib/towerops/workers/ms_buildings_import_worker.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue