diff --git a/assets/js/hooks/coverage_hooks.ts b/assets/js/hooks/coverage_hooks.ts index 93b41995..c692bc14 100644 --- a/assets/js/hooks/coverage_hooks.ts +++ b/assets/js/hooks/coverage_hooks.ts @@ -258,8 +258,11 @@ type CoveragePayload = { tx: { lat: number | null; lon: number | null; azimuth: number | null } site: string | null height_tiers: Record + nlos_tiers: Record } +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 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 diff --git a/lib/towerops/coverages/buildings.ex b/lib/towerops/coverages/buildings.ex new file mode 100644 index 00000000..c3693894 --- /dev/null +++ b/lib/towerops/coverages/buildings.ex @@ -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 diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index e55034e5..7e1eb073 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -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) diff --git a/lib/towerops/coverages/profile.ex b/lib/towerops/coverages/profile.ex index dd4c4baf..901492ff 100644 --- a/lib/towerops/coverages/profile.ex +++ b/lib/towerops/coverages/profile.ex @@ -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 -> diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex index ac424dd5..74640924 100644 --- a/lib/towerops/workers/coverage_worker.ex +++ b/lib/towerops/workers/coverage_worker.ex @@ -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}") diff --git a/lib/towerops/workers/ms_buildings_import_worker.ex b/lib/towerops/workers/ms_buildings_import_worker.ex new file mode 100644 index 00000000..58d92ad5 --- /dev/null +++ b/lib/towerops/workers/ms_buildings_import_worker.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/map.ex b/lib/towerops_web/live/coverage_live/map.ex index 33e5d4c0..d6b8e1e0 100644 --- a/lib/towerops_web/live/coverage_live/map.ex +++ b/lib/towerops_web/live/coverage_live/map.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/map.html.heex b/lib/towerops_web/live/coverage_live/map.html.heex index 4ba78b3e..fa79a088 100644 --- a/lib/towerops_web/live/coverage_live/map.html.heex +++ b/lib/towerops_web/live/coverage_live/map.html.heex @@ -55,8 +55,31 @@ class="pointer-events-none fixed top-32 left-1/2 -translate-x-[640px] z-[1100]" id="coverage-height-control" > -
- +
+ <%!-- LOS vs NLOS mode toggle. The label and reference frame + of the height slider switch with the mode. --%> +
+ + +
+ {t("Height above clutter")}