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.
237 lines
8.1 KiB
Elixir
237 lines
8.1 KiB
Elixir
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.0–1.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
|