defmodule MicrowavepropWeb.PathLive do
@moduledoc "`/path` terrain-profile + propagation analysis for a pair of grids/coords."
use MicrowavepropWeb, :live_view
import MicrowavepropWeb.Components.SkewTChart
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2]
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
alias Microwaveprop.Canopy
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
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: BandConfig.humidity_effect_label(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, reason} ->
Logger.warning(
"PathLive terrain profile load failed: src=#{src.lat},#{src.lon} dst=#{dst.lat},#{dst.lon} reason=#{inspect(reason)}"
)
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)
# Load the full profile grid ONCE (single NFS read + gunzip + decode)
# instead of letting 9 async_stream workers each re-decode the same
# ~30 MB file. Each per-point lookup becomes a Map.get.
profile_grid = profile_grid_for(profile_valid_time)
sample_points = path_sample_points(src, dst, 9)
# Fast path: in-memory Map.get against the single decoded grid.
# Only points that miss the grid (e.g. just-rolled-over chain) take
# the slow DB-fallback path concurrently below.
{grid_hits, misses} = partition_grid_hits(sample_points, profile_grid, profile_valid_time)
fallback_hits =
misses
|> Enum.reverse()
|> Task.async_stream(&fallback_hrrr_point(&1, now), max_concurrency: 4, timeout: 5_000, on_timeout: :kill_task)
|> Enum.zip(Enum.reverse(misses))
|> Enum.flat_map(fn
{{:ok, nil}, _} ->
[]
{{:ok, point}, _} ->
[point]
{{:exit, reason}, {label, lat, lon}} ->
Logger.error(
"PathLive HRRR fallback lookup failed: label=#{inspect(label)} lat=#{lat} lon=#{lon} reason=#{inspect(reason)}"
)
[]
end)
hrrr_points = Enum.reverse(grid_hits, fallback_hits)
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, reason} ->
Logger.warning(
"PathLive nearest_native_duct_info failed: midpoint=#{midlat},#{midlon} reason=#{inspect(reason)}"
)
%{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
case MicrowavepropWeb.LocationResolver.resolve(input) do
:empty -> {:error, "Location is required"}
other -> other
end
end
# The grid is the single decoded output of ProfilesFile.read/1 — keys are
# `{snapped_lat, snapped_lon}` tuples produced by ProfilesFile.snap/2.
# We mirror the same snapping locally instead of round-tripping through
# ProfilesFile.read_point/3 (which would re-fetch the cached grid).
defp profile_grid_for(nil), do: nil
defp profile_grid_for(valid_time) do
case ProfilesFile.read(valid_time) do
{:ok, grid} ->
grid
{:error, reason} ->
Logger.warning("PathLive ProfilesFile.read failed: valid_time=#{inspect(valid_time)} reason=#{inspect(reason)}")
nil
other ->
Logger.warning("PathLive ProfilesFile.read unexpected: valid_time=#{inspect(valid_time)} got=#{inspect(other)}")
nil
end
end
defp lookup_profile_grid(grid, lat, lon) do
{snapped_lat, snapped_lon} = ProfilesFile.snap(lat, lon)
Map.get(grid, {snapped_lat, snapped_lon})
end
defp partition_grid_hits(sample_points, profile_grid, profile_valid_time) do
Enum.reduce(sample_points, {[], []}, fn pt, acc ->
classify_grid_hit(pt, profile_grid, profile_valid_time, acc)
end)
end
defp classify_grid_hit(pt, nil, _vt, {hits, mss}), do: {hits, [pt | mss]}
defp classify_grid_hit({label, lat, lon} = pt, grid, vt, {hits, mss}) do
case lookup_profile_grid(grid, lat, lon) do
nil -> {hits, [pt | mss]}
cell -> {[%{label: label, profile: profile_from_cell(cell, lat, lon, vt)} | hits], mss}
end
end
defp fallback_hrrr_point({label, lat, lon}, now) do
case Weather.find_nearest_hrrr(lat, lon, now) do
nil -> nil
profile -> %{label: label, profile: profile}
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)
|> Map.put_new(:min_refractivity_gradient, Map.get(cell, :native_min_gradient))
|> Map.put_new(:surface_refractivity, nil)
|> Map.put_new(:ducting_detected, Map.get(cell, :duct_count, 0) > 0)
|> Map.put_new(:duct_characteristics, nil)
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
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"""
| Point | Lat, Lon | Temp | Dewpt | Press | HPBL | PWAT | Ns | dN/dh |
|---|---|---|---|---|---|---|---|---|
| {pt.label} | {format_number(pt.profile.lat)}, {format_number(pt.profile.lon)} | {format_number(pt.profile.surface_temp_c)}°C | {format_number(pt.profile.surface_dewpoint_c)}°C | {format_number(pt.profile.surface_pressure_mb)} mb | {format_number(pt.profile.hpbl_m)} m | {format_number(pt.profile.pwat_mm)} mm | {format_number(pt.profile.surface_refractivity)} | {format_number(pt.profile.min_refractivity_gradient)} |