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.
1609 lines
54 KiB
Elixir
1609 lines
54 KiB
Elixir
defmodule MicrowavepropWeb.PathLive do
|
||
@moduledoc "`/path` terrain-profile + propagation analysis for a pair of grids/coords."
|
||
use MicrowavepropWeb, :live_view
|
||
|
||
import MicrowavepropWeb.Components.SkewTChart
|
||
|
||
alias Microwaveprop.Ionosphere
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Propagation.ProfilesFile
|
||
alias Microwaveprop.Propagation.Scorer
|
||
alias Microwaveprop.Propagation.SporadicE
|
||
alias Microwaveprop.Radio.CallsignClient
|
||
alias Microwaveprop.Radio.Maidenhead
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Terrain.ElevationClient
|
||
alias Microwaveprop.Terrain.TerrainAnalysis
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.Station
|
||
|
||
require Logger
|
||
|
||
@band_options BandConfig.band_options()
|
||
|
||
@url_params ~w(source destination band src_height_ft dst_height_ft tx_power_dbm src_gain_dbi dst_gain_dbi)
|
||
@defaults %{
|
||
"source" => "",
|
||
"destination" => "",
|
||
"band" => "10000",
|
||
"src_height_ft" => "30",
|
||
"dst_height_ft" => "30",
|
||
"tx_power_dbm" => "20",
|
||
"src_gain_dbi" => "30",
|
||
"dst_gain_dbi" => "30"
|
||
}
|
||
|
||
@impl true
|
||
def mount(_params, _session, socket) do
|
||
_ =
|
||
if connected?(socket) do
|
||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "propagation:updated")
|
||
end
|
||
|
||
{:ok,
|
||
assign(socket,
|
||
page_title: "Path Calculator",
|
||
band_options: @band_options,
|
||
result: nil,
|
||
error: nil,
|
||
computing: false,
|
||
source: "",
|
||
destination: "",
|
||
band: "10000",
|
||
src_height_ft: "30",
|
||
dst_height_ft: "30",
|
||
tx_power_dbm: "20",
|
||
src_gain_dbi: "30",
|
||
dst_gain_dbi: "30",
|
||
source_is_gps: false
|
||
)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_params(params, _uri, socket) do
|
||
p = Map.merge(@defaults, Map.take(params, @url_params))
|
||
|
||
is_gps = p["source"] == "gps"
|
||
|
||
socket =
|
||
assign(socket,
|
||
source: if(is_gps, do: socket.assigns.source, else: p["source"]),
|
||
destination: p["destination"],
|
||
band: p["band"],
|
||
src_height_ft: p["src_height_ft"],
|
||
dst_height_ft: p["dst_height_ft"],
|
||
tx_power_dbm: p["tx_power_dbm"],
|
||
src_gain_dbi: p["src_gain_dbi"],
|
||
dst_gain_dbi: p["dst_gain_dbi"],
|
||
source_is_gps: is_gps
|
||
)
|
||
|
||
# If source=gps and we already have coords from a previous fix, just recalculate.
|
||
# Only request GPS from the device on the initial page load (no coords yet).
|
||
if p["source"] == "gps" and connected?(socket) do
|
||
if socket.assigns.source != "" and socket.assigns.source != nil do
|
||
auto_calculate(socket, Map.put(p, "source", socket.assigns.source))
|
||
else
|
||
{:noreply, push_event(socket, "request_gps", %{})}
|
||
end
|
||
else
|
||
auto_calculate(socket, p)
|
||
end
|
||
end
|
||
|
||
defp auto_calculate(socket, p) do
|
||
if p["source"] != "" and p["source"] != "gps" and p["destination"] != "" and
|
||
not socket.assigns.computing and is_nil(socket.assigns.result) do
|
||
src_ht = parse_float(p["src_height_ft"], 30.0)
|
||
dst_ht = parse_float(p["dst_height_ft"], 30.0)
|
||
tx_dbm = parse_float(p["tx_power_dbm"], 20.0)
|
||
|
||
station_params = %{
|
||
src_height_ft: src_ht,
|
||
dst_height_ft: dst_ht,
|
||
src_height_m: src_ht * 0.3048,
|
||
dst_height_m: dst_ht * 0.3048,
|
||
tx_power_dbm: tx_dbm,
|
||
tx_power_mw: :math.pow(10, tx_dbm / 10),
|
||
src_gain_dbi: parse_float(p["src_gain_dbi"], 30.0),
|
||
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
|
||
}
|
||
|
||
send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
|
||
{:noreply, assign(socket, computing: true)}
|
||
else
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
@impl true
|
||
def handle_event("update_form", params, socket) do
|
||
{:noreply,
|
||
assign(socket,
|
||
source: params["source"] || "",
|
||
destination: params["destination"] || "",
|
||
band: params["band"] || "10000",
|
||
src_height_ft: params["src_height_ft"] || "30",
|
||
dst_height_ft: params["dst_height_ft"] || "30",
|
||
tx_power_dbm: params["tx_power_dbm"] || "20",
|
||
src_gain_dbi: params["src_gain_dbi"] || "30",
|
||
dst_gain_dbi: params["dst_gain_dbi"] || "30"
|
||
)}
|
||
end
|
||
|
||
def handle_event("calculate", params, socket) do
|
||
src_height_ft = parse_float(params["src_height_ft"], 30.0)
|
||
dst_height_ft = parse_float(params["dst_height_ft"], 30.0)
|
||
|
||
tx_power_dbm = parse_float(params["tx_power_dbm"], 20.0)
|
||
tx_power_mw = :math.pow(10, tx_power_dbm / 10)
|
||
|
||
station_params = %{
|
||
src_height_ft: src_height_ft,
|
||
dst_height_ft: dst_height_ft,
|
||
src_height_m: src_height_ft * 0.3048,
|
||
dst_height_m: dst_height_ft * 0.3048,
|
||
tx_power_dbm: tx_power_dbm,
|
||
tx_power_mw: tx_power_mw,
|
||
src_gain_dbi: parse_float(params["src_gain_dbi"], 30.0),
|
||
dst_gain_dbi: parse_float(params["dst_gain_dbi"], 30.0)
|
||
}
|
||
|
||
socket =
|
||
assign(socket,
|
||
source: params["source"],
|
||
destination: params["destination"],
|
||
band: params["band"],
|
||
src_height_ft: params["src_height_ft"],
|
||
dst_height_ft: params["dst_height_ft"],
|
||
tx_power_dbm: params["tx_power_dbm"],
|
||
src_gain_dbi: params["src_gain_dbi"],
|
||
dst_gain_dbi: params["dst_gain_dbi"],
|
||
error: nil,
|
||
computing: true,
|
||
result: nil
|
||
)
|
||
|
||
send(
|
||
self(),
|
||
{:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
|
||
)
|
||
|
||
url_params =
|
||
params
|
||
|> Map.take(@url_params)
|
||
|> then(fn p -> if socket.assigns.source_is_gps, do: Map.put(p, "source", "gps"), else: p end)
|
||
|> Enum.reject(fn {_k, v} -> v == "" end)
|
||
|> Map.new()
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
||
end
|
||
|
||
# Hover on a forecast-chart dot — fetch and push back the per-factor
|
||
# breakdown for that hour at the path's midpoint, mirroring the
|
||
# weighted-criteria panel the main /map popup builds when the user
|
||
# clicks a grid cell. Cached client-side so the server only does the
|
||
# ProfilesFile read once per hour per session.
|
||
def handle_event("path_forecast_detail", %{"iso" => iso}, socket) do
|
||
with %{result: %{band_mhz: band_mhz, source: src, destination: dst, band_config: band_config}} <-
|
||
socket.assigns,
|
||
{:ok, valid_time, _} <- DateTime.from_iso8601(iso) do
|
||
midlat = (src.lat + dst.lat) / 2
|
||
midlon = (src.lon + dst.lon) / 2
|
||
|
||
payload =
|
||
case Propagation.point_detail(band_mhz, midlat, midlon, valid_time) do
|
||
%{score: score, factors: factors} when factors != %{} ->
|
||
%{
|
||
iso: iso,
|
||
score: score,
|
||
factors: factors,
|
||
band_label: band_config.label,
|
||
humidity_effect: humidity_effect_string(band_config)
|
||
}
|
||
|
||
_ ->
|
||
%{iso: iso, unavailable: true}
|
||
end
|
||
|
||
{:noreply, push_event(socket, "path_forecast_detail", payload)}
|
||
else
|
||
_ -> {:noreply, socket}
|
||
end
|
||
end
|
||
|
||
def handle_event("gps_location", %{"lat" => lat, "lon" => lon}, socket) do
|
||
coords = "#{Float.round(lat / 1, 6)},#{Float.round(lon / 1, 6)}"
|
||
|
||
# Show coords in the input but keep source=gps in the URL
|
||
socket = assign(socket, source: coords, source_is_gps: true, result: nil)
|
||
|
||
url_params =
|
||
%{
|
||
"source" => "gps",
|
||
"destination" => socket.assigns.destination,
|
||
"band" => socket.assigns.band,
|
||
"src_height_ft" => socket.assigns.src_height_ft,
|
||
"dst_height_ft" => socket.assigns.dst_height_ft,
|
||
"tx_power_dbm" => socket.assigns.tx_power_dbm,
|
||
"src_gain_dbi" => socket.assigns.src_gain_dbi,
|
||
"dst_gain_dbi" => socket.assigns.dst_gain_dbi
|
||
}
|
||
|> Enum.reject(fn {_k, v} -> v == "" end)
|
||
|> Map.new()
|
||
|
||
{:noreply, push_patch(socket, to: ~p"/path?#{url_params}", replace: true)}
|
||
end
|
||
|
||
@impl true
|
||
def handle_info({:compute_path, source, dest, band_mhz, station_params}, socket) do
|
||
case compute_path(source, dest, band_mhz, station_params) do
|
||
{:ok, result} ->
|
||
{:noreply, assign(socket, result: result, computing: false)}
|
||
|
||
{:error, reason} ->
|
||
{:noreply, assign(socket, error: reason, computing: false)}
|
||
end
|
||
end
|
||
|
||
def handle_info({:propagation_updated, _valid_times}, socket) do
|
||
case socket.assigns.result do
|
||
%{band_mhz: band_mhz, source: src, destination: dst} = result ->
|
||
midlat = (src.lat + dst.lat) / 2
|
||
midlon = (src.lon + dst.lon) / 2
|
||
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
|
||
{:noreply, assign(socket, result: %{result | forecast: forecast})}
|
||
|
||
_ ->
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
defp compute_path(source, dest, band_mhz, station_params) do
|
||
with {:ok, src} <- resolve_location(source),
|
||
{:ok, dst} <- resolve_location(dest) do
|
||
band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000)
|
||
freq_ghz = band_mhz / 1000
|
||
|
||
dist_km = haversine_km(src.lat, src.lon, dst.lat, dst.lon)
|
||
bearing = bearing_deg(src.lat, src.lon, dst.lat, dst.lon)
|
||
|
||
# Terrain profile with antenna heights
|
||
terrain_result =
|
||
case ElevationClient.fetch_elevation_profile(src.lat, src.lon, dst.lat, dst.lon, 64, download: true) do
|
||
{:ok, profile} ->
|
||
analysis =
|
||
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
|
||
ant_ht_a: station_params.src_height_m,
|
||
ant_ht_b: station_params.dst_height_m
|
||
)
|
||
|
||
%{profile: profile, analysis: analysis}
|
||
|
||
{:error, _} ->
|
||
nil
|
||
end
|
||
|
||
# HRRR profiles along path — 9 evenly-spaced samples so mid-path
|
||
# ducts that endpoint-only queries miss show up in the duct count.
|
||
# Source preference: the on-disk grid profile store (full-CONUS
|
||
# coverage from the hourly chain) is checked first; the per-QSO
|
||
# `hrrr_profiles` DB table is only the fallback for points the
|
||
# current chain hasn't published yet.
|
||
now = DateTime.utc_now()
|
||
midlat = (src.lat + dst.lat) / 2
|
||
midlon = (src.lon + dst.lon) / 2
|
||
|
||
profile_valid_time = latest_profile_valid_time(now)
|
||
|
||
sample_points = path_sample_points(src, dst, 9)
|
||
|
||
hrrr_points =
|
||
sample_points
|
||
|> Task.async_stream(&label_hrrr_point(&1, now, profile_valid_time),
|
||
max_concurrency: 9,
|
||
timeout: 10_000,
|
||
on_timeout: :kill_task
|
||
)
|
||
|> Enum.zip(sample_points)
|
||
|> Enum.flat_map(fn
|
||
{{:ok, nil}, _} ->
|
||
[]
|
||
|
||
{{:ok, point}, _} ->
|
||
[point]
|
||
|
||
{{:exit, reason}, {label, lat, lon}} ->
|
||
Logger.error(
|
||
"PathLive HRRR point lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} valid_time=#{inspect(profile_valid_time)} reason=#{inspect(reason)}"
|
||
)
|
||
|
||
[]
|
||
end)
|
||
|
||
hrrr_profiles = Enum.map(hrrr_points, & &1.profile)
|
||
|
||
# Independent duct signal: the nearest RAOB within 300 km / ±3 h of
|
||
# the midpoint. HRRR pressure levels systematically under-read thin
|
||
# surface ducts that sounding data resolves.
|
||
sounding = build_sounding_readout(midlat, midlon, now)
|
||
|
||
# Native-resolution HRRR duct band + Bulk Richardson near the
|
||
# midpoint. Resolves thin trapping layers HRRR's 13 pressure levels
|
||
# miss; feeds the 1.15× boost in Scorer.score_refractivity, gated on
|
||
# Richardson so turbulent duct readings don't inflate the score.
|
||
native_duct =
|
||
case Weather.nearest_native_duct_info(midlat, midlon, now) do
|
||
{:ok, info} -> info
|
||
{:error, _} -> %{best_duct_band_ghz: nil, bulk_richardson: nil}
|
||
end
|
||
|
||
# Build conditions and score
|
||
{conditions, scoring} =
|
||
build_scoring(hrrr_profiles, src, dst, now, band_config, native_duct)
|
||
|
||
# Loss and power budgets
|
||
loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions)
|
||
power_budget = compute_power_budget(station_params, loss_budget)
|
||
|
||
# 18-hour forecast from propagation grid (midpoint of path)
|
||
forecast = Propagation.point_forecast(band_mhz, midlat, midlon)
|
||
|
||
# Ionosphere readout at the midpoint (sporadic-E potential).
|
||
# Only relevant for VHF where Es propagation is physically possible.
|
||
ionosphere = build_ionosphere_readout(band_mhz, midlat, midlon, dist_km)
|
||
|
||
{:ok,
|
||
%{
|
||
source: src,
|
||
destination: dst,
|
||
station_params: station_params,
|
||
band_config: band_config,
|
||
band_mhz: band_mhz,
|
||
freq_ghz: freq_ghz,
|
||
dist_km: dist_km,
|
||
bearing: bearing,
|
||
terrain: terrain_result,
|
||
conditions: conditions,
|
||
scoring: scoring,
|
||
loss_budget: loss_budget,
|
||
power_budget: power_budget,
|
||
forecast: forecast,
|
||
hrrr_count: length(hrrr_profiles),
|
||
hrrr_points: hrrr_points,
|
||
ionosphere: ionosphere,
|
||
sounding: sounding
|
||
}}
|
||
end
|
||
end
|
||
|
||
# Returns nil when there's no usable readout, or a map describing the
|
||
# nearest ionosonde's current foEs + the computed Es score for
|
||
# (band, distance). Only VHF bands get the readout.
|
||
defp build_ionosphere_readout(band_mhz, midlat, midlon, dist_km) when band_mhz in [50, 144, 222, 432] do
|
||
case Ionosphere.nearest_foes(midlat, midlon) do
|
||
{:ok, obs} ->
|
||
es_score = SporadicE.es_score(obs.fo_es_mhz, band_mhz, dist_km)
|
||
muf = SporadicE.single_hop_muf(obs.fo_es_mhz, dist_km)
|
||
|
||
%{
|
||
station_code: obs.station_code,
|
||
valid_time: obs.valid_time,
|
||
fo_es_mhz: obs.fo_es_mhz,
|
||
fo_f2_mhz: obs.fo_f2_mhz,
|
||
mufd_mhz: obs.mufd_mhz,
|
||
es_score: es_score,
|
||
es_muf_mhz: muf,
|
||
es_in_range?: dist_km >= 500 and dist_km <= 2500
|
||
}
|
||
|
||
{:error, _reason} ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp build_ionosphere_readout(_band_mhz, _lat, _lon, _dist), do: nil
|
||
|
||
defp resolve_location(input) do
|
||
input = String.trim(input)
|
||
|
||
cond do
|
||
input == "" ->
|
||
{:error, "Location is required"}
|
||
|
||
coordinate_pair?(input) ->
|
||
[lat_s, lon_s] = String.split(input, ",", parts: 2)
|
||
{lat, _} = Float.parse(String.trim(lat_s))
|
||
{lon, _} = Float.parse(String.trim(lon_s))
|
||
{:ok, %{lat: lat, lon: lon, label: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}", type: :coordinates}}
|
||
|
||
maidenhead?(input) ->
|
||
case Maidenhead.to_latlon(input) do
|
||
{:ok, {lat, lon}} -> {:ok, %{lat: lat, lon: lon, label: String.upcase(input), type: :grid}}
|
||
:error -> {:error, "Invalid grid square: #{input}"}
|
||
end
|
||
|
||
true ->
|
||
case CallsignClient.locate(input) do
|
||
{:ok, info} ->
|
||
{:ok,
|
||
%{lat: info.lat, lon: info.lon, label: String.upcase(info.callsign), grid: info.gridsquare, type: :callsign}}
|
||
|
||
{:error, reason} ->
|
||
{:error, "Could not find #{input}: #{reason}"}
|
||
end
|
||
end
|
||
end
|
||
|
||
defp coordinate_pair?(input) do
|
||
case String.split(input, ",", parts: 2) do
|
||
[a, b] ->
|
||
match?({_, _}, Float.parse(String.trim(a))) and match?({_, _}, Float.parse(String.trim(b)))
|
||
|
||
_ ->
|
||
false
|
||
end
|
||
end
|
||
|
||
defp maidenhead?(input) do
|
||
String.length(input) >= 4 and String.length(input) <= 10 and
|
||
Regex.match?(~r/^[A-Ra-r]{2}[0-9]{2}/i, input)
|
||
end
|
||
|
||
defp humidity_effect_string(%{humidity_effect: :beneficial}), do: "beneficial"
|
||
defp humidity_effect_string(%{humidity_effect: :harmful}), do: "harmful"
|
||
defp humidity_effect_string(_), do: "neutral"
|
||
|
||
defp label_hrrr_point({label, lat, lon}, now, profile_valid_time) do
|
||
case profile_valid_time && ProfilesFile.read_point(profile_valid_time, lat, lon) do
|
||
%{} = cell ->
|
||
%{label: label, profile: profile_from_cell(cell, lat, lon, profile_valid_time)}
|
||
|
||
_ ->
|
||
case Weather.find_nearest_hrrr(lat, lon, now) do
|
||
nil -> nil
|
||
profile -> %{label: label, profile: profile}
|
||
end
|
||
end
|
||
end
|
||
|
||
# The most recent on-disk profile file at or before `now`. Falls back
|
||
# to the earliest available file if everything cached is in the future
|
||
# (cold-start state during a missed chain). Returns `nil` when the
|
||
# store is empty — caller then drops to the DB-table fallback.
|
||
defp latest_profile_valid_time(now) do
|
||
case ProfilesFile.list_valid_times() do
|
||
[] -> nil
|
||
times -> pick_latest_at_or_before(times, now)
|
||
end
|
||
end
|
||
|
||
defp pick_latest_at_or_before(times, now) do
|
||
case Enum.filter(times, fn t -> DateTime.compare(t, now) != :gt end) do
|
||
[] -> List.first(times)
|
||
past -> Enum.max(past, DateTime)
|
||
end
|
||
end
|
||
|
||
# ProfilesFile cells are already flat HrrrProfile-shaped maps — the
|
||
# `:profile` key holds the vertical pressure-level list, NOT a wrapper
|
||
# around the surface fields. Stamp `lat`/`lon` (from the sample point,
|
||
# since cells don't carry their own coordinates — they're the map key)
|
||
# and `valid_time` so downstream template + scoring code is shape-agnostic.
|
||
defp profile_from_cell(cell, lat, lon, valid_time) do
|
||
cell
|
||
|> Map.put_new(:lat, lat)
|
||
|> Map.put_new(:lon, lon)
|
||
|> Map.put_new(:valid_time, valid_time)
|
||
end
|
||
|
||
# Linear interpolation along the great-circle-approximate path. Good
|
||
# enough at the path-calculator's typical range (<2000 km); for longer
|
||
# paths the path deviates from a rhumb line but we're picking
|
||
# HRRR grid cells, not doing precise bearing.
|
||
defp path_sample_points(src, dst, count) when count >= 2 do
|
||
Enum.map(1..count, fn i ->
|
||
t = (i - 1) / (count - 1)
|
||
lat = src.lat + (dst.lat - src.lat) * t
|
||
lon = src.lon + (dst.lon - src.lon) * t
|
||
|
||
label =
|
||
cond do
|
||
i == 1 -> "Source"
|
||
i == count -> "Destination"
|
||
i * 2 == count + 1 -> "Midpoint"
|
||
true -> "#{round(t * 100)}%"
|
||
end
|
||
|
||
{label, lat, lon}
|
||
end)
|
||
end
|
||
|
||
defp build_sounding_readout(midlat, midlon, now) do
|
||
case Weather.nearest_sounding_to(midlat, midlon, now) do
|
||
{:ok, sounding} ->
|
||
station = if Ecto.assoc_loaded?(sounding.station), do: sounding.station
|
||
station = station || Repo.get(Station, sounding.station_id)
|
||
|
||
distance_km =
|
||
if station, do: haversine_km(midlat, midlon, station.lat, station.lon)
|
||
|
||
%{
|
||
station_code: station && station.station_code,
|
||
station_name: station && station.name,
|
||
observed_at: sounding.observed_at,
|
||
ducting_detected: sounding.ducting_detected,
|
||
min_refractivity_gradient: sounding.min_refractivity_gradient,
|
||
boundary_layer_depth_m: sounding.boundary_layer_depth_m,
|
||
precipitable_water_mm: sounding.precipitable_water_mm,
|
||
distance_km: distance_km
|
||
}
|
||
|
||
{:error, :not_found} ->
|
||
nil
|
||
end
|
||
end
|
||
|
||
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
|
||
|
||
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
|
||
temps = profiles |> Enum.map(& &1.surface_temp_c) |> Enum.reject(&is_nil/1)
|
||
dewpoints = profiles |> Enum.map(& &1.surface_dewpoint_c) |> Enum.reject(&is_nil/1)
|
||
|
||
if temps == [] or dewpoints == [] do
|
||
{nil, nil}
|
||
else
|
||
avg_temp_c = Enum.sum(temps) / length(temps)
|
||
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
|
||
|
||
pressures = profiles |> Enum.map(& &1.surface_pressure_mb) |> Enum.reject(&is_nil/1)
|
||
gradients = profiles |> Enum.map(& &1.min_refractivity_gradient) |> Enum.reject(&is_nil/1)
|
||
bl_depths = profiles |> Enum.map(& &1.hpbl_m) |> Enum.reject(&is_nil/1)
|
||
pwats = profiles |> Enum.map(& &1.pwat_mm) |> Enum.reject(&is_nil/1)
|
||
|
||
conditions = %{
|
||
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
||
temp_f: Scorer.c_to_f(avg_temp_c),
|
||
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
|
||
temp_c: avg_temp_c,
|
||
dewpoint_c: avg_dewpoint_c,
|
||
wind_speed_kts: nil,
|
||
sky_cover_pct: nil,
|
||
utc_hour: now.hour,
|
||
utc_minute: now.minute,
|
||
month: now.month,
|
||
latitude: (src.lat + dst.lat) / 2,
|
||
longitude: src.lon,
|
||
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
|
||
prev_pressure_mb: nil,
|
||
rain_rate_mmhr: 0.0,
|
||
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
|
||
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
|
||
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
|
||
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
|
||
bulk_richardson: native_duct[:bulk_richardson]
|
||
}
|
||
|
||
scoring = Scorer.composite_score(conditions, band_config)
|
||
{conditions, scoring}
|
||
end
|
||
end
|
||
|
||
defp compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
|
||
freq_mhz = freq_ghz * 1000
|
||
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
||
|
||
o2_loss = band_config.o2_db_km * dist_km
|
||
h2o_coeff = band_config.h2o_coeff
|
||
|
||
abs_humidity =
|
||
if conditions do
|
||
conditions.abs_humidity
|
||
else
|
||
7.5
|
||
end
|
||
|
||
h2o_loss = h2o_coeff * abs_humidity * dist_km
|
||
|
||
rain_loss =
|
||
if conditions && conditions.rain_rate_mmhr > 0 do
|
||
gamma = band_config.rain_k * :math.pow(conditions.rain_rate_mmhr, band_config.rain_alpha)
|
||
gamma * dist_km
|
||
else
|
||
0.0
|
||
end
|
||
|
||
diffraction_loss =
|
||
if terrain_result do
|
||
# TerrainAnalysis.deygout_diffraction returns integer 0 for clear
|
||
# paths; coerce to float so Float.round/2 below is happy.
|
||
terrain_result.analysis.diffraction_db * 1.0
|
||
else
|
||
0.0
|
||
end
|
||
|
||
total = fspl + o2_loss + h2o_loss + rain_loss + diffraction_loss
|
||
|
||
%{
|
||
fspl: Float.round(fspl, 1),
|
||
o2: Float.round(o2_loss, 2),
|
||
h2o: Float.round(h2o_loss, 2),
|
||
rain: Float.round(rain_loss, 2),
|
||
diffraction: Float.round(diffraction_loss, 1),
|
||
total: Float.round(total, 1)
|
||
}
|
||
end
|
||
|
||
defp compute_power_budget(station_params, loss_budget) do
|
||
tx_power_dbm = station_params.tx_power_dbm
|
||
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
|
||
rx_power_dbm = eirp_dbm - loss_budget.total + station_params.dst_gain_dbi
|
||
|
||
# Typical receiver sensitivities by mode (dBm)
|
||
# CW ~-140, SSB ~-130, FM ~-120
|
||
rx_sensitivity_cw = -140.0
|
||
rx_sensitivity_ssb = -130.0
|
||
margin_cw = rx_power_dbm - rx_sensitivity_cw
|
||
margin_ssb = rx_power_dbm - rx_sensitivity_ssb
|
||
|
||
%{
|
||
tx_power_dbm: Float.round(tx_power_dbm, 1),
|
||
eirp_dbm: Float.round(eirp_dbm, 1),
|
||
rx_power_dbm: Float.round(rx_power_dbm, 1),
|
||
margin_cw: Float.round(margin_cw, 1),
|
||
margin_ssb: Float.round(margin_ssb, 1)
|
||
}
|
||
end
|
||
|
||
defp parse_float(nil, default), do: default
|
||
defp parse_float("", default), do: default
|
||
|
||
defp parse_float(str, default) do
|
||
case Float.parse(str) do
|
||
{val, _} -> val
|
||
:error -> default
|
||
end
|
||
end
|
||
|
||
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
||
defdelegate bearing_deg(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
|
||
|
||
# ── Score tier helpers ──
|
||
|
||
defp tier_label(score) when score >= 80, do: "EXCELLENT"
|
||
defp tier_label(score) when score >= 65, do: "GOOD"
|
||
defp tier_label(score) when score >= 50, do: "MARGINAL"
|
||
defp tier_label(score) when score >= 33, do: "POOR"
|
||
defp tier_label(_), do: "NEGLIGIBLE"
|
||
|
||
defp tier_color(score) when score >= 80, do: "#059669"
|
||
defp tier_color(score) when score >= 65, do: "#0d9488"
|
||
defp tier_color(score) when score >= 50, do: "#ca8a04"
|
||
defp tier_color(score) when score >= 33, do: "#ea580c"
|
||
defp tier_color(_), do: "#dc2626"
|
||
|
||
defp terrain_verdict_class("CLEAR"), do: "badge badge-success"
|
||
defp terrain_verdict_class("FRESNEL_MINOR"), do: "badge badge-warning"
|
||
defp terrain_verdict_class("FRESNEL_PARTIAL"), do: "badge badge-warning"
|
||
defp terrain_verdict_class("BLOCKED"), do: "badge badge-error"
|
||
defp terrain_verdict_class(_), do: "badge"
|
||
|
||
defp format_number(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 1)
|
||
defp format_number(n), do: to_string(n)
|
||
|
||
defp format_km_mi(km), do: Microwaveprop.Format.distance_km(km)
|
||
|
||
defp format_utc_hour(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M")
|
||
defp format_utc_hour(_), do: "?"
|
||
|
||
defp format_mw(mw) when mw >= 1000, do: "#{format_number(mw / 1000)} W"
|
||
defp format_mw(mw) when mw >= 1, do: "#{format_number(mw)} mW"
|
||
defp format_mw(mw), do: "#{format_number(mw * 1000)} uW"
|
||
|
||
# ── Render ──
|
||
|
||
@impl true
|
||
def render(assigns) do
|
||
~H"""
|
||
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
|
||
<.header>
|
||
Path Calculator
|
||
<:subtitle>Analyze microwave propagation between two points</:subtitle>
|
||
</.header>
|
||
|
||
<form phx-submit="calculate" phx-change="update_form" class="bg-base-200 rounded-box p-4 mb-6">
|
||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">Source</label>
|
||
<div class="flex gap-1">
|
||
<input
|
||
type="text"
|
||
name="source"
|
||
id="source-input"
|
||
value={@source}
|
||
placeholder="W5ISP, EM13sf, or lat,lon"
|
||
class="input input-bordered flex-1"
|
||
/>
|
||
<button
|
||
type="button"
|
||
id="locate-me"
|
||
phx-hook="LocateMe"
|
||
class="btn btn-sm btn-outline gap-1"
|
||
title="Use device GPS"
|
||
>
|
||
<.icon name="hero-map-pin" class="size-3.5" /> My Location
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">
|
||
Destination (<span
|
||
class="tooltip tooltip-top underline decoration-dotted cursor-help"
|
||
data-tip="Callsign lookup queries QRZ for the licensee's mailing address, then geocodes that address — so the position may be off from where the station actually operates."
|
||
>callsign</span> or grid)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="destination"
|
||
value={@destination}
|
||
placeholder="K5TR or EM00cd"
|
||
class="input input-bordered w-full"
|
||
required
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">Band</label>
|
||
<select name="band" class="select select-bordered w-full">
|
||
<%= for {label, value} <- @band_options do %>
|
||
<option value={value} selected={value == @band}>{label}</option>
|
||
<% end %>
|
||
</select>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Power (dBm)</label>
|
||
<input
|
||
type="number"
|
||
name="tx_power_dbm"
|
||
value={@tx_power_dbm}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 items-end mt-3">
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Height (ft)</label>
|
||
<input
|
||
type="number"
|
||
name="src_height_ft"
|
||
value={@src_height_ft}
|
||
min="0"
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">TX Gain (dBi)</label>
|
||
<input
|
||
type="number"
|
||
name="src_gain_dbi"
|
||
value={@src_gain_dbi}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">RX Height (ft)</label>
|
||
<input
|
||
type="number"
|
||
name="dst_height_ft"
|
||
value={@dst_height_ft}
|
||
min="0"
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
<div class="fieldset">
|
||
<label class="label text-sm font-medium">RX Gain (dBi)</label>
|
||
<input
|
||
type="number"
|
||
name="dst_gain_dbi"
|
||
value={@dst_gain_dbi}
|
||
step="any"
|
||
class="input input-bordered w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4">
|
||
<button type="submit" class="btn btn-primary" disabled={@computing}>
|
||
<%= if @computing do %>
|
||
<span class="loading loading-spinner loading-sm"></span> Computing...
|
||
<% else %>
|
||
Calculate Path
|
||
<% end %>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<%= if @error do %>
|
||
<div class="alert alert-error mb-6">{@error}</div>
|
||
<% end %>
|
||
|
||
<%= if @result do %>
|
||
<.path_results result={@result} />
|
||
<% end %>
|
||
</Layouts.app>
|
||
"""
|
||
end
|
||
|
||
defp path_results(assigns) do
|
||
~H"""
|
||
<%!-- Map --%>
|
||
<div
|
||
id="path-map"
|
||
phx-hook="ContactMap"
|
||
phx-update="ignore"
|
||
data-pos1={Jason.encode!(%{lat: @result.source.lat, lon: @result.source.lon})}
|
||
data-pos2={Jason.encode!(%{lat: @result.destination.lat, lon: @result.destination.lon})}
|
||
data-station1={@result.source.label}
|
||
data-station2={@result.destination.label}
|
||
class="w-full h-64 md:h-80 rounded-box overflow-hidden mb-4"
|
||
>
|
||
</div>
|
||
|
||
<%!-- Terrain profile --%>
|
||
<%= if @result.terrain do %>
|
||
<div
|
||
id="path-elevation"
|
||
phx-hook="ElevationProfile"
|
||
phx-update="ignore"
|
||
data-profile={Jason.encode!(elevation_data(@result))}
|
||
class="w-full h-48 md:h-56 rounded-box overflow-hidden mb-6 bg-base-200 p-2"
|
||
>
|
||
<canvas></canvas>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- Link Summary --%>
|
||
<div class="bg-base-200 rounded-box p-4 mb-4">
|
||
<div class="text-xs opacity-60 mb-2">
|
||
Link Summary · {@result.band_config.label}
|
||
</div>
|
||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
|
||
<div>
|
||
<span class="opacity-60">Distance</span>
|
||
<div class="font-mono text-lg">{format_km_mi(@result.dist_km)}</div>
|
||
</div>
|
||
<div>
|
||
<span class="opacity-60">Bearing</span>
|
||
<div class="font-mono text-lg">{format_number(@result.bearing)}°</div>
|
||
</div>
|
||
<%= if @result.terrain do %>
|
||
<div>
|
||
<span class="opacity-60">Terrain</span>
|
||
<div>
|
||
<span class={terrain_verdict_class(@result.terrain.analysis.verdict)}>
|
||
{@result.terrain.analysis.verdict}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
<%= if @result.scoring do %>
|
||
<div>
|
||
<span class="opacity-60">Propagation</span>
|
||
<div class="font-mono text-lg" style={"color: #{tier_color(@result.scoring.score)}"}>
|
||
{@result.scoring.score}/100
|
||
</div>
|
||
<div class="text-xs" style={"color: #{tier_color(@result.scoring.score)}"}>
|
||
{tier_label(@result.scoring.score)}
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
<div>
|
||
<span class="opacity-60">HRRR Data</span>
|
||
<div class="font-mono">{@result.hrrr_count} / 9 points</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<%!-- Loss Budget --%>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-2">
|
||
Loss Budget · {@result.band_config.label}
|
||
</div>
|
||
<div class="space-y-1 text-sm">
|
||
<.budget_row label="FSPL (free-space)" value={@result.loss_budget.fspl} unit="dB" />
|
||
<.budget_row
|
||
label="Gaseous (O₂+H₂O)"
|
||
value={Float.round(@result.loss_budget.o2 + @result.loss_budget.h2o, 2)}
|
||
unit="dB"
|
||
/>
|
||
<%= if @result.loss_budget.diffraction > 0 do %>
|
||
<.budget_row
|
||
label="Diffraction loss"
|
||
value={@result.loss_budget.diffraction}
|
||
unit="dB"
|
||
highlight
|
||
/>
|
||
<% end %>
|
||
<%= if @result.loss_budget.rain > 0 do %>
|
||
<.budget_row label="Rain attenuation" value={@result.loss_budget.rain} unit="dB" />
|
||
<% end %>
|
||
<div class="divider my-1"></div>
|
||
<.budget_row
|
||
label="TOTAL PATH LOSS"
|
||
value={@result.loss_budget.total}
|
||
unit="dB"
|
||
bold
|
||
/>
|
||
</div>
|
||
<div class="mt-3 text-xs opacity-50 space-y-0.5">
|
||
<div>
|
||
Gas rate: {format_number(
|
||
Float.round(
|
||
(@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001),
|
||
4
|
||
)
|
||
)} dB/km
|
||
</div>
|
||
<div>
|
||
H₂O: {format_number(@result.band_config.h2o_coeff)} dB/km/g ·
|
||
O₂: {format_number(@result.band_config.o2_db_km)} dB/km
|
||
</div>
|
||
<%= if @result.conditions do %>
|
||
<div>Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m³</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
|
||
<%!-- Power Budget --%>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-2">Power Budget</div>
|
||
<div class="space-y-1 text-sm">
|
||
<.budget_row
|
||
label="TX Power"
|
||
value={@result.power_budget.tx_power_dbm}
|
||
unit="dBm"
|
||
note={"(#{format_mw(@result.station_params.tx_power_mw)})"}
|
||
/>
|
||
<.budget_row
|
||
label="TX Ant Gain"
|
||
value={@result.station_params.src_gain_dbi}
|
||
unit="dBi"
|
||
positive
|
||
/>
|
||
<.budget_row label="Feed Loss (×2)" value={-2.0} unit="dB" />
|
||
<div class="divider my-1"></div>
|
||
<.budget_row label="EIRP" value={@result.power_budget.eirp_dbm - 2.0} unit="dBm" bold />
|
||
<.budget_row
|
||
label="Path Loss (w/FF)"
|
||
value={-@result.loss_budget.total}
|
||
unit="dB"
|
||
highlight
|
||
/>
|
||
<.budget_row
|
||
label="RX Ant Gain"
|
||
value={@result.station_params.dst_gain_dbi}
|
||
unit="dBi"
|
||
positive
|
||
/>
|
||
<div class="divider my-1"></div>
|
||
<.budget_row
|
||
label="RX Power"
|
||
value={@result.power_budget.rx_power_dbm - 2.0}
|
||
unit="dBm"
|
||
bold
|
||
/>
|
||
<.budget_row label="Sensitivity (CW)" value={-141.0} unit="dBm" />
|
||
<div class="divider my-1"></div>
|
||
<% margin = @result.power_budget.margin_cw - 2.0 %>
|
||
<.budget_row
|
||
label="MARGIN"
|
||
value={Float.round(margin, 1)}
|
||
unit="dB"
|
||
bold
|
||
color={if margin > 0, do: "text-success", else: "text-error"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<%!-- Path Weather --%>
|
||
<%= if @result.conditions do %>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-3">Path Weather Avg</div>
|
||
<div class="grid grid-cols-2 gap-x-6 gap-y-3">
|
||
<div>
|
||
<span class="text-xs opacity-60">Temperature</span>
|
||
<div class="font-mono text-xl">{format_number(@result.conditions.temp_f)}°F</div>
|
||
<div class="font-mono text-xs opacity-50">
|
||
{format_number(@result.conditions.temp_c)}°C
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Dewpoint</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.dewpoint_f)}°F
|
||
</div>
|
||
<div class="font-mono text-xs opacity-50">
|
||
{format_number(@result.conditions.dewpoint_c)}°C
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">T/Td Gap</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}°F
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Abs Humidity</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.abs_humidity)} g/m³
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Pressure</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.pressure_mb || 0)} mb
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">PWAT</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.pwat_mm || 0)} mm
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">BL Depth</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.bl_depth_m || 0)} m
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<span class="text-xs opacity-60">Refrac Gradient</span>
|
||
<div class="font-mono text-xl">
|
||
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%!-- Propagation Factors --%>
|
||
<%= if @result.scoring do %>
|
||
<div class="bg-base-200 rounded-box p-4">
|
||
<div class="text-xs opacity-60 mb-3">
|
||
Propagation Factors · {@result.band_config.label}
|
||
</div>
|
||
<.factor_bars scoring={@result.scoring} />
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
|
||
<%!-- Atmospheric Profile (HRRR) --%>
|
||
<%= if @result.hrrr_points != [] do %>
|
||
<.atmospheric_profile hrrr_points={@result.hrrr_points} sounding={@result.sounding} />
|
||
<% end %>
|
||
|
||
<%!-- Ionosphere (GIRO sporadic-E readout) --%>
|
||
<%= if @result.ionosphere do %>
|
||
<.ionosphere_readout readout={@result.ionosphere} band_mhz={@result.band_mhz} />
|
||
<% end %>
|
||
|
||
<%!-- 18-Hour Forecast --%>
|
||
<%= if @result.forecast != [] do %>
|
||
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
|
||
<% end %>
|
||
|
||
<%!-- Share link --%>
|
||
<div class="mb-6">
|
||
<button
|
||
type="button"
|
||
phx-hook="CopyLink"
|
||
id="copy-link"
|
||
class="btn btn-sm btn-outline gap-1"
|
||
>
|
||
<.icon name="hero-link" class="size-3.5" /> Copy Link
|
||
</button>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
attr :hrrr_points, :list, required: true
|
||
attr :sounding, :any, default: nil
|
||
|
||
defp atmospheric_profile(assigns) do
|
||
mid = Enum.find(assigns.hrrr_points, &(&1.label == "Midpoint")) || hd(assigns.hrrr_points)
|
||
valid_time = mid.profile.valid_time
|
||
run_time = Map.get(mid.profile, :run_time)
|
||
duct_count = Enum.count(assigns.hrrr_points, & &1.profile.ducting_detected)
|
||
total_points = length(assigns.hrrr_points)
|
||
|
||
assigns =
|
||
assigns
|
||
|> assign(:mid, mid)
|
||
|> assign(:valid_time, valid_time)
|
||
|> assign(:run_time, run_time)
|
||
|> assign(:duct_count, duct_count)
|
||
|> assign(:total_points, total_points)
|
||
|
||
~H"""
|
||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 mb-3">
|
||
<h3 class="text-base font-semibold">Atmospheric Profile</h3>
|
||
<span class="badge badge-outline badge-sm whitespace-nowrap">HRRR (3 km)</span>
|
||
<span class="text-xs opacity-60 whitespace-nowrap">
|
||
Valid {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")}
|
||
</span>
|
||
<%= if @run_time do %>
|
||
<span class="text-xs opacity-60 whitespace-nowrap">
|
||
Run {Calendar.strftime(@run_time, "%Y-%m-%d %H:%M UTC")}
|
||
</span>
|
||
<% end %>
|
||
<span class="text-xs opacity-60 whitespace-nowrap">
|
||
{@total_points} path samples
|
||
</span>
|
||
<%= if @duct_count > 0 do %>
|
||
<span class="badge badge-warning badge-sm whitespace-nowrap">
|
||
Duct at {@duct_count} / {@total_points} HRRR points
|
||
</span>
|
||
<% end %>
|
||
<%= if @sounding && @sounding.ducting_detected do %>
|
||
<span class="badge badge-warning badge-sm whitespace-nowrap">
|
||
RAOB {@sounding.station_code} confirms duct
|
||
</span>
|
||
<% end %>
|
||
</div>
|
||
|
||
<%= if @sounding do %>
|
||
<div class="text-xs opacity-70 mb-3">
|
||
Nearest sounding: <span class="font-mono">{@sounding.station_code}</span>
|
||
<%= if @sounding.station_name do %>
|
||
({@sounding.station_name})
|
||
<% end %>
|
||
<%= if @sounding.distance_km do %>
|
||
· <span class="font-mono">{format_km_mi(@sounding.distance_km)}</span> from midpoint
|
||
<% end %>
|
||
· obs
|
||
<span class="font-mono">
|
||
{Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")}
|
||
</span>
|
||
<%= if @sounding.min_refractivity_gradient do %>
|
||
· dN/dh
|
||
<span class="font-mono">
|
||
{format_number(@sounding.min_refractivity_gradient)} N/km
|
||
</span>
|
||
<% end %>
|
||
<%= if @sounding.precipitable_water_mm do %>
|
||
· PWAT <span class="font-mono">{format_number(@sounding.precipitable_water_mm)} mm</span>
|
||
<% end %>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="overflow-x-auto">
|
||
<table class="table table-xs table-zebra">
|
||
<thead>
|
||
<tr>
|
||
<th>Point</th>
|
||
<th>Lat, Lon</th>
|
||
<th>Temp</th>
|
||
<th>Dewpt</th>
|
||
<th>Press</th>
|
||
<th>HPBL</th>
|
||
<th>PWAT</th>
|
||
<th>N<sub>s</sub></th>
|
||
<th>dN/dh</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<%= for pt <- @hrrr_points do %>
|
||
<tr>
|
||
<td class="font-semibold">{pt.label}</td>
|
||
<td class="font-mono opacity-70">
|
||
{format_number(pt.profile.lat)}, {format_number(pt.profile.lon)}
|
||
</td>
|
||
<td>{format_number(pt.profile.surface_temp_c)}°C</td>
|
||
<td>{format_number(pt.profile.surface_dewpoint_c)}°C</td>
|
||
<td>{format_number(pt.profile.surface_pressure_mb)} mb</td>
|
||
<td>{format_number(pt.profile.hpbl_m)} m</td>
|
||
<td>{format_number(pt.profile.pwat_mm)} mm</td>
|
||
<td>{format_number(pt.profile.surface_refractivity)}</td>
|
||
<td>{format_number(pt.profile.min_refractivity_gradient)}</td>
|
||
</tr>
|
||
<% end %>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<%= if @mid.profile.profile && @mid.profile.profile != [] do %>
|
||
<div class="text-xs opacity-60 mt-4">
|
||
Midpoint vertical profile
|
||
</div>
|
||
<.skew_t_chart profile={@mid.profile.profile} />
|
||
<% end %>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
attr :readout, :map, required: true
|
||
attr :band_mhz, :integer, required: true
|
||
|
||
defp ionosphere_readout(assigns) do
|
||
~H"""
|
||
<div class="bg-base-200 rounded-box p-4 mb-6">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="text-xs opacity-60">Ionosphere · Sporadic-E Potential</div>
|
||
<div class="text-[10px] opacity-50">
|
||
{@readout.station_code} · {Calendar.strftime(@readout.valid_time, "%Y-%m-%d %H:%M UTC")}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-wider opacity-60">foEs</div>
|
||
<div class="font-bold text-lg">
|
||
{format_foes(@readout.fo_es_mhz)}
|
||
<span class="text-xs font-normal opacity-60">MHz</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-wider opacity-60">foF2</div>
|
||
<div class="font-bold text-lg">
|
||
{format_foes(@readout.fo_f2_mhz)}
|
||
<span class="text-xs font-normal opacity-60">MHz</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-wider opacity-60">Es MUF (path)</div>
|
||
<div class="font-bold text-lg">
|
||
{format_foes(@readout.es_muf_mhz)}
|
||
<span class="text-xs font-normal opacity-60">MHz</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-wider opacity-60">
|
||
Es Score ({@band_mhz} MHz)
|
||
</div>
|
||
<div class="font-bold text-lg" style={"color: #{tier_color(@readout.es_score)}"}>
|
||
{@readout.es_score}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="text-[11px] opacity-70 mt-3 leading-snug">
|
||
<%= cond do %>
|
||
<% not @readout.es_in_range? -> %>
|
||
Path length is outside the single-hop Es window (500–2500 km) — tropo only for this distance.
|
||
<% @readout.es_score == 0 -> %>
|
||
No sporadic-E propagation predicted: the layer is not ionised enough at {@readout.station_code} to reflect {@band_mhz} MHz at this path length.
|
||
<% @readout.es_score >= 80 -> %>
|
||
Strong sporadic-E opening predicted — the Es MUF exceeds {@band_mhz} MHz for this path length.
|
||
<% @readout.es_score >= 50 -> %>
|
||
Marginal sporadic-E opening: MUF is near the band threshold, worth monitoring.
|
||
<% true -> %>
|
||
Weak sporadic-E fringe — the layer is ionised but not quite enough to reliably reflect {@band_mhz} MHz.
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp format_foes(nil), do: "—"
|
||
defp format_foes(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
|
||
defp format_foes(v), do: to_string(v)
|
||
|
||
attr :forecast, :list, required: true
|
||
attr :band_config, :map, required: true
|
||
|
||
defp forecast_chart(assigns) do
|
||
points = build_forecast_points(assigns.forecast)
|
||
|
||
assigns =
|
||
assigns
|
||
|> assign(:points, points)
|
||
|> assign(:polyline, Enum.map_join(points, " ", fn p -> "#{p.x},#{p.y}" end))
|
||
|> assign(:now_pt, Enum.find(points, & &1.now?) || hd(points))
|
||
|> assign(:best_pt, Enum.max_by(points, & &1.score))
|
||
|> assign(:worst_pt, Enum.min_by(points, & &1.score))
|
||
|> assign(:trend, forecast_trend(points))
|
||
|> assign(:x_labels, forecast_x_labels(points))
|
||
|
||
~H"""
|
||
<div
|
||
id="path-forecast-chart"
|
||
phx-hook="PathForecast"
|
||
class="bg-base-200 rounded-box p-4 mb-6"
|
||
>
|
||
<div class="flex items-center justify-between mb-2">
|
||
<div class="text-xs opacity-60">
|
||
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
|
||
</div>
|
||
<div class="text-xs font-bold">
|
||
{Phoenix.HTML.raw(@trend)}
|
||
</div>
|
||
</div>
|
||
<svg viewBox="0 0 1200 240" class="w-full aspect-[5/1]">
|
||
<%= for s <- [0, 25, 50, 75, 100] do %>
|
||
<% y = 20 + (100 - s) / 100 * 185 %>
|
||
<line
|
||
x1="55"
|
||
y1={y}
|
||
x2="1180"
|
||
y2={y}
|
||
stroke="currentColor"
|
||
class="opacity-10"
|
||
stroke-width="1"
|
||
/>
|
||
<text
|
||
x="48"
|
||
y={y + 5}
|
||
font-size="14"
|
||
text-anchor="end"
|
||
fill="currentColor"
|
||
class="opacity-40"
|
||
>
|
||
{s}
|
||
</text>
|
||
<% end %>
|
||
<polyline
|
||
points={@polyline}
|
||
fill="none"
|
||
stroke={tier_color(@best_pt.score)}
|
||
stroke-width="3"
|
||
stroke-linejoin="round"
|
||
stroke-linecap="round"
|
||
/>
|
||
<%= for pt <- @points do %>
|
||
<circle
|
||
cx={pt.x}
|
||
cy={pt.y}
|
||
r="6"
|
||
fill={tier_color(pt.score)}
|
||
data-iso={DateTime.to_iso8601(pt.valid_time)}
|
||
style="cursor:pointer;"
|
||
/>
|
||
<% end %>
|
||
<circle
|
||
cx={@now_pt.x}
|
||
cy={@now_pt.y}
|
||
r="8"
|
||
fill="white"
|
||
stroke={tier_color(@now_pt.score)}
|
||
stroke-width="3"
|
||
data-iso={DateTime.to_iso8601(@now_pt.valid_time)}
|
||
style="cursor:pointer;"
|
||
/>
|
||
<circle
|
||
cx={@best_pt.x}
|
||
cy={@best_pt.y}
|
||
r="8"
|
||
fill={tier_color(@best_pt.score)}
|
||
data-iso={DateTime.to_iso8601(@best_pt.valid_time)}
|
||
style="cursor:pointer;"
|
||
stroke="white"
|
||
stroke-width="2"
|
||
/>
|
||
<text
|
||
x={@best_pt.x}
|
||
y={@best_pt.y - 12}
|
||
font-size="16"
|
||
text-anchor="middle"
|
||
font-weight="700"
|
||
fill={tier_color(@best_pt.score)}
|
||
>
|
||
{@best_pt.score}
|
||
</text>
|
||
<%= for {x, label} <- @x_labels do %>
|
||
<text
|
||
x={x}
|
||
y="230"
|
||
font-size="14"
|
||
text-anchor="middle"
|
||
fill="currentColor"
|
||
class="opacity-40"
|
||
>
|
||
{label}
|
||
</text>
|
||
<% end %>
|
||
</svg>
|
||
<div class="flex justify-between text-xs mt-2">
|
||
<span>
|
||
Best:
|
||
<span class="font-bold" style={"color: #{tier_color(@best_pt.score)}"}>
|
||
{@best_pt.score}
|
||
</span>
|
||
at {@best_pt.label} UTC
|
||
</span>
|
||
<span>
|
||
Worst:
|
||
<span class="font-bold" style={"color: #{tier_color(@worst_pt.score)}"}>
|
||
{@worst_pt.score}
|
||
</span>
|
||
at {@worst_pt.label} UTC
|
||
</span>
|
||
</div>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp build_forecast_points(forecast) do
|
||
n = length(forecast)
|
||
now = DateTime.utc_now()
|
||
|
||
# Map each forecast point to SVG coords (viewBox 0 0 1200 240)
|
||
# plot area: x in [55, 1180], y in [20, 205]
|
||
indexed = Enum.with_index(forecast)
|
||
|
||
now_idx =
|
||
indexed
|
||
|> Enum.min_by(fn {p, _i} -> abs(DateTime.diff(p.valid_time, now, :second)) end)
|
||
|> elem(1)
|
||
|
||
Enum.map(indexed, fn {p, i} ->
|
||
x =
|
||
if n <= 1 do
|
||
617
|
||
else
|
||
55 + i / (n - 1) * 1125
|
||
end
|
||
|
||
y = 20 + (100 - p.score) / 100 * 185
|
||
|
||
%{
|
||
x: Float.round(x * 1.0, 2),
|
||
y: Float.round(y * 1.0, 2),
|
||
score: p.score,
|
||
valid_time: p.valid_time,
|
||
label: format_utc_hour(p.valid_time),
|
||
now?: i == now_idx
|
||
}
|
||
end)
|
||
end
|
||
|
||
defp forecast_trend(points) do
|
||
now_idx = Enum.find_index(points, & &1.now?) || 0
|
||
future = Enum.drop(points, now_idx)
|
||
|
||
case future do
|
||
[first | _] ->
|
||
last = List.last(future)
|
||
diff = last.score - first.score
|
||
|
||
cond do
|
||
diff > 5 -> ~s(<span class="text-success">▲ Improving</span>)
|
||
diff < -5 -> ~s(<span class="text-error">▼ Declining</span>)
|
||
true -> ~s(<span class="text-warning">→ Steady</span>)
|
||
end
|
||
|
||
_ ->
|
||
~s(<span class="opacity-50">→ Steady</span>)
|
||
end
|
||
end
|
||
|
||
defp forecast_x_labels(points) do
|
||
n = length(points)
|
||
|
||
indices =
|
||
cond do
|
||
n <= 1 -> [0]
|
||
n <= 7 -> Enum.to_list(0..(n - 1))
|
||
true -> label_indices(n, 7)
|
||
end
|
||
|
||
Enum.map(indices, fn i ->
|
||
p = Enum.at(points, i)
|
||
{p.x, p.label}
|
||
end)
|
||
end
|
||
|
||
defp label_indices(n, target) do
|
||
step = (n - 1) / (target - 1)
|
||
|
||
0..(target - 1)
|
||
|> Enum.map(fn k -> round(k * step) end)
|
||
|> Enum.uniq()
|
||
end
|
||
|
||
attr :label, :string, required: true
|
||
attr :value, :any, required: true
|
||
attr :unit, :string, required: true
|
||
attr :note, :string, default: nil
|
||
attr :bold, :boolean, default: false
|
||
attr :highlight, :boolean, default: false
|
||
attr :positive, :boolean, default: false
|
||
attr :color, :string, default: nil
|
||
|
||
defp budget_row(assigns) do
|
||
~H"""
|
||
<div class={["flex justify-between", @bold && "font-bold", @color]}>
|
||
<span class={[@highlight && "text-warning"]}>{@label}</span>
|
||
<span class="font-mono">
|
||
<span class={[@positive && "text-success", @highlight && "text-warning"]}>
|
||
{format_value(@value)}
|
||
</span>
|
||
<span class="opacity-60 text-xs">{@unit}</span>
|
||
<%= if @note do %>
|
||
<span class="opacity-50 text-xs ml-1">{@note}</span>
|
||
<% end %>
|
||
</span>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp format_value(v) when is_float(v), do: :erlang.float_to_binary(v, decimals: 1)
|
||
defp format_value(v), do: to_string(v)
|
||
|
||
defp factor_bars(assigns) do
|
||
weights = BandConfig.weights()
|
||
|
||
rows =
|
||
assigns.scoring.factors
|
||
|> Enum.map(fn {key, score} ->
|
||
%{name: factor_name(key), score: score, weight: Map.get(weights, key, 0)}
|
||
end)
|
||
|> Enum.sort_by(& &1.score, :desc)
|
||
|
||
assigns = assign(assigns, :rows, rows)
|
||
|
||
~H"""
|
||
<div class="space-y-2">
|
||
<%= for row <- @rows do %>
|
||
<div>
|
||
<div class="flex justify-between text-xs mb-0.5">
|
||
<span class="opacity-70">{row.name}</span>
|
||
<span class="font-mono font-bold">{row.score}</span>
|
||
</div>
|
||
<div class="w-full bg-base-300 rounded-full h-2">
|
||
<div
|
||
class="h-2 rounded-full"
|
||
style={"width: #{row.score}%; background-color: #{score_bar_color(row.score)}"}
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
"""
|
||
end
|
||
|
||
defp score_bar_color(s) when s >= 80, do: "#059669"
|
||
defp score_bar_color(s) when s >= 65, do: "#0d9488"
|
||
defp score_bar_color(s) when s >= 50, do: "#ca8a04"
|
||
defp score_bar_color(s) when s >= 33, do: "#ea580c"
|
||
defp score_bar_color(_), do: "#dc2626"
|
||
|
||
defp factor_name(:humidity), do: "Humidity"
|
||
defp factor_name(:time_of_day), do: "Time of Day"
|
||
defp factor_name(:td_depression), do: "T-Td Depression"
|
||
defp factor_name(:refractivity), do: "Refractivity"
|
||
defp factor_name(:sky), do: "Sky Cover"
|
||
defp factor_name(:season), do: "Season"
|
||
defp factor_name(:wind), do: "Wind"
|
||
defp factor_name(:rain), do: "Rain"
|
||
defp factor_name(:pwat), do: "PWAT"
|
||
defp factor_name(:pressure), do: "Pressure"
|
||
|
||
defp elevation_data(result) do
|
||
# ElevationProfile hook expects: points with dist_km, elev, beam, r1
|
||
points =
|
||
Enum.map(result.terrain.analysis.points, fn p ->
|
||
%{
|
||
dist_km: p.dist_km,
|
||
elev: p.elev,
|
||
beam: p[:beam] || 0,
|
||
r1: p[:r1] || 0
|
||
}
|
||
end)
|
||
|
||
%{
|
||
points: points,
|
||
freq_mhz: result.band_mhz,
|
||
dist_km: result.dist_km,
|
||
verdict: result.terrain.analysis.verdict,
|
||
ducts: []
|
||
}
|
||
end
|
||
end
|