prop/lib/microwaveprop/terrain/viewshed.ex
Graham McIntire 418f6426e6
fix(path): handle ProfilesFile cell shape correctly + log async exits
The /path calculator showed "0 / 9 HRRR points" in production despite
the on-disk profile store being current. Root cause: profile_from_cell/2
treated the cell's :profile key as a wrapper sub-map and called
Map.put_new on it — but the :profile key actually holds the vertical
pressure-level LIST. Every point sample crashed with BadMapError, the
crash propagated as {:exit, _} through Task.async_stream, and the
consumer silently dropped all 9 results.

Fix: stop wrapping. Cells are already flat HrrrProfile-shaped maps;
just stamp lat/lon (from the caller, since cells don't carry their
own coords — those are the map key) and valid_time onto the cell.

Audit + log every other async error path so the next silent failure
isn't invisible:
- PathLive HRRR point lookup
- Propagation.point_forecast per-hour reads
- Viewshed ray crashes
- IemClient ASOS network fetches
- RtmaClient range-download tasks
- Recalibrator factor-vector batches (positive + negative samples)
- MapLive forecast preload tasks
- RoverLive station resolution

LiveViews already had handle_async/3 exit clauses with logging. The
gap was always in Task.async_stream consumers that wrote {:exit, _} -> []
without surfacing the reason.

Add the rule to CLAUDE.md and project memory so this never repeats.

Also fix a pre-existing skewt_svg.ex compiler warning where
@critical_label_min_dy was used before being defined.
2026-04-25 15:57:20 -05:00

237 lines
8.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Terrain.Viewshed do
@moduledoc false
alias Microwaveprop.Terrain.Srtm
alias Microwaveprop.Terrain.TerrainAnalysis
require Logger
@type boundary_point :: %{bearing: non_neg_integer(), reach_km: float(), lat: float(), lon: float()}
@type viewshed_result :: %{
origin: %{lat: float(), lon: float()},
boundary: [boundary_point()]
}
@earth_radius_km 6371.0
@default_angular_step 2
@default_max_range_km 50
@doc "Compute destination lat/lon given origin, bearing (degrees), and distance (km)."
@spec destination_point(float(), float(), float(), float()) :: {float(), float()}
def destination_point(lat, lon, bearing_deg, dist_km) do
lat_rad = deg_to_rad(lat)
lon_rad = deg_to_rad(lon)
brg_rad = deg_to_rad(bearing_deg)
d = dist_km / @earth_radius_km
lat2 =
:math.asin(
:math.sin(lat_rad) * :math.cos(d) +
:math.cos(lat_rad) * :math.sin(d) * :math.cos(brg_rad)
)
lon2 =
lon_rad +
:math.atan2(
:math.sin(brg_rad) * :math.sin(d) * :math.cos(lat_rad),
:math.cos(d) - :math.sin(lat_rad) * :math.sin(lat2)
)
{rad_to_deg(lat2), rad_to_deg(lon2)}
end
@doc """
Find the max clear distance along a ray from TerrainAnalysis points.
Skips endpoints (first/last), returns the dist_km of the last clear
interior point before the first obstruction.
"""
@spec find_reach_km([TerrainAnalysis.analysis_point()], float()) :: float()
def find_reach_km(points, max_range_km) do
interior = Enum.slice(points, 1..-2//1)
case find_first_obstructed_index(interior) do
nil ->
max_range_km
0 ->
0.0
idx ->
Enum.at(interior, idx - 1).dist_km
end
end
@doc """
Compute effective reach accounting for both terrain and propagation conditions.
Higher propagation scores indicate ducting potential, which lets signals
propagate beyond terrain obstructions via atmospheric waveguide.
"""
@spec effective_reach_km(TerrainAnalysis.analysis_result(), float(), number()) :: float()
def effective_reach_km(analysis, max_range_km, score) do
case analysis.verdict do
"CLEAR" ->
max_range_km
"FRESNEL_MINOR" ->
max_range_km * 0.9
"FRESNEL_PARTIAL" ->
max_range_km * 0.7
"BLOCKED" ->
terrain_factor = terrain_reach_factor(analysis.diffraction_db)
ducting_factor = ducting_reach_factor(score)
max_range_km * max(terrain_factor, ducting_factor)
end
end
# Convert diffraction loss to a range reduction factor (0.01.0).
# Mild diffraction still allows significant propagation;
# heavy blockage attenuates sharply.
defp terrain_reach_factor(db) when db <= 3, do: 0.8
defp terrain_reach_factor(db) when db <= 6, do: 0.5
defp terrain_reach_factor(db) when db <= 12, do: 0.3
defp terrain_reach_factor(db) when db <= 20, do: 0.15
defp terrain_reach_factor(_db), do: 0.05
# Higher propagation scores mean stronger ducting potential —
# signals can ride atmospheric layers over terrain obstacles.
# Returns a floor factor so blocked paths still get range
# proportional to atmospheric conditions.
defp ducting_reach_factor(score) when score >= 80, do: 0.7
defp ducting_reach_factor(score) when score >= 65, do: 0.5
defp ducting_reach_factor(score) when score >= 50, do: 0.3
defp ducting_reach_factor(score) when score >= 33, do: 0.15
defp ducting_reach_factor(_score), do: 0.05
@doc """
Compute a terrain viewshed from a point. Returns a map with :origin
and :boundary (list of %{bearing, reach_km, lat, lon}).
Options:
- :freq_ghz — frequency for Fresnel zone calc (default 10.0)
- :max_range_km — max ray distance (default 50)
- :ant_height_m — antenna height at both ends (default 2.4)
- :angular_step — degrees between rays (default 2)
- :tiles_dir — SRTM tiles directory (default from config)
"""
@spec compute(float(), float(), keyword()) :: viewshed_result()
def compute(lat, lon, opts \\ []) do
freq_ghz = Keyword.get(opts, :freq_ghz, 10.0)
max_range_km = Keyword.get(opts, :max_range_km, @default_max_range_km)
ant_height_m = Keyword.get(opts, :ant_height_m, 2.4)
score = Keyword.get(opts, :score, 50)
angular_step = Keyword.get(opts, :angular_step, @default_angular_step)
tiles_dir = Keyword.get(opts, :tiles_dir, srtm_tiles_dir())
bearings = Enum.to_list(0..359//angular_step)
boundary =
bearings
|> Task.async_stream(
fn bearing ->
compute_ray(lat, lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir)
end,
max_concurrency: System.schedulers_online(),
timeout: 30_000,
on_timeout: :kill_task
)
|> Enum.zip(bearings)
|> Enum.map(fn
{{:ok, result}, _bearing} ->
result
{{:exit, reason}, bearing} ->
Logger.error(
"Viewshed ray crash: lat=#{lat} lon=#{lon} bearing=#{bearing} freq=#{freq_ghz} reason=#{inspect(reason)}"
)
nil
end)
|> Enum.reject(&is_nil/1)
|> smooth_boundary(lat, lon)
%{origin: %{lat: lat, lon: lon}, boundary: boundary}
end
@doc """
Analyse a single ray's profile. Public for testing.
Returns %{reach_km: float, verdict: string}.
"""
@spec analyse_ray([TerrainAnalysis.elevation_point()], float(), float(), float(), float()) ::
%{reach_km: float(), verdict: String.t()}
def analyse_ray(profile, dist_km, freq_ghz, ant_ht_a_m, ant_ht_b_m) do
analysis = TerrainAnalysis.analyse(profile, dist_km, freq_ghz, ant_ht_a: ant_ht_a_m, ant_ht_b: ant_ht_b_m)
reach_km = find_reach_km(analysis.points, dist_km)
%{reach_km: reach_km, verdict: analysis.verdict}
end
defp compute_ray(origin_lat, origin_lon, bearing, freq_ghz, max_range_km, ant_height_m, score, tiles_dir) do
# Check terrain within the radio horizon where terrain features matter.
# Beyond this, propagation is atmospheric (ducting/scatter) and terrain
# doesn't block — earth curvature is handled by the atmosphere.
terrain_check_km = min(radio_horizon_km(ant_height_m) * 2.0, max_range_km)
{end_lat, end_lon} = destination_point(origin_lat, origin_lon, bearing, terrain_check_km)
{:ok, profile} =
Srtm.fetch_elevation_profile(origin_lat, origin_lon, end_lat, end_lon, tiles_dir, 64, download: true)
analysis =
TerrainAnalysis.analyse(profile, terrain_check_km, freq_ghz, ant_ht_a: ant_height_m, ant_ht_b: ant_height_m)
reach_km = effective_reach_km(analysis, max_range_km, score)
{reach_lat, reach_lon} = destination_point(origin_lat, origin_lon, bearing, reach_km)
%{
bearing: bearing,
reach_km: reach_km,
lat: reach_lat,
lon: reach_lon
}
end
# Radio horizon distance in km for a given antenna height (K=4/3 atmosphere)
defp radio_horizon_km(height_m) do
:math.sqrt(2 * (4 / 3) * @earth_radius_km * 1000 * height_m) / 1000
end
defp srtm_tiles_dir do
Application.get_env(:microwaveprop, :srtm_tiles_dir, Path.expand("~/srtm/tiles"))
end
# Smooth reach_km values with a circular moving average to remove spikes
# from SRTM elevation artifacts, then recompute boundary lat/lon.
defp smooth_boundary(points, _origin_lat, _origin_lon) when length(points) < 5, do: points
defp smooth_boundary(points, origin_lat, origin_lon) do
reaches = Enum.map(points, & &1.reach_km)
n = length(reaches)
arr = :array.from_list(reaches)
half = 2
smoothed_reaches =
Enum.map(0..(n - 1), fn i ->
window =
for offset <- -half..half do
:array.get(rem(i + offset + n, n), arr)
end
Enum.sum(window) / length(window)
end)
points
|> Enum.zip(smoothed_reaches)
|> Enum.map(fn {pt, reach} ->
{lat, lon} = destination_point(origin_lat, origin_lon, pt.bearing, reach)
%{pt | reach_km: reach, lat: lat, lon: lon}
end)
end
defp find_first_obstructed_index(interior) do
Enum.find_index(interior, & &1.obstructed)
end
defp deg_to_rad(deg), do: deg * :math.pi() / 180
defp rad_to_deg(rad), do: rad * 180 / :math.pi()
end