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""" <.header> Path Calculator <:subtitle>Analyze microwave propagation between two points
<%= if @error do %>
{@error}
<% end %> <%= if @result do %> <.path_results result={@result} /> <% end %>
""" end defp path_results(assigns) do ~H""" <%!-- Map --%>
<%!-- Terrain profile --%> <%= if @result.terrain do %>
<% end %> <%!-- Link Summary --%>
Link Summary · {@result.band_config.label}
Distance
{format_km_mi(@result.dist_km)}
Bearing
{format_number(@result.bearing)}°
<%= if @result.terrain do %>
Terrain
{@result.terrain.analysis.verdict}
<% end %> <%= if @result.scoring do %>
Propagation
{@result.scoring.score}/100
{tier_label(@result.scoring.score)}
<% end %>
HRRR Data
{@result.hrrr_count} / 9 points
<%!-- Loss Budget --%>
Loss Budget · {@result.band_config.label}
<.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 %>
<.budget_row label="TOTAL PATH LOSS" value={@result.loss_budget.total} unit="dB" bold />
Gas rate: {format_number( Float.round( (@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001), 4 ) )} dB/km
H₂O: {format_number(@result.band_config.h2o_coeff)} dB/km/g · O₂: {format_number(@result.band_config.o2_db_km)} dB/km
<%= if @result.conditions do %>
Abs humidity avg: {format_number(@result.conditions.abs_humidity)} g/m³
<% end %>
<%!-- Power Budget --%>
Power Budget
<.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" />
<.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 />
<.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" />
<% 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"} />
<%!-- Path Weather --%> <%= if @result.conditions do %>
Path Weather Avg
Temperature
{format_number(@result.conditions.temp_f)}°F
{format_number(@result.conditions.temp_c)}°C
Dewpoint
{format_number(@result.conditions.dewpoint_f)}°F
{format_number(@result.conditions.dewpoint_c)}°C
T/Td Gap
{format_number(@result.conditions.temp_f - @result.conditions.dewpoint_f)}°F
Abs Humidity
{format_number(@result.conditions.abs_humidity)} g/m³
Pressure
{format_number(@result.conditions.pressure_mb || 0)} mb
PWAT
{format_number(@result.conditions.pwat_mm || 0)} mm
BL Depth
{format_number(@result.conditions.bl_depth_m || 0)} m
Refrac Gradient
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
<% end %> <%!-- Propagation Factors --%> <%= if @result.scoring do %>
Propagation Factors · {@result.band_config.label}
<.factor_bars scoring={@result.scoring} />
<% end %>
<%!-- 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 --%>
""" 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"""

Atmospheric Profile

HRRR (3 km) Valid {Calendar.strftime(@valid_time, "%Y-%m-%d %H:%M UTC")} <%= if @run_time do %> Run {Calendar.strftime(@run_time, "%Y-%m-%d %H:%M UTC")} <% end %> {@total_points} path samples <%= if @duct_count > 0 do %> Duct at {@duct_count} / {@total_points} HRRR points <% end %> <%= if @sounding && @sounding.ducting_detected do %> RAOB {@sounding.station_code} confirms duct <% end %>
<%= if @sounding do %>
Nearest sounding: {@sounding.station_code} <%= if @sounding.station_name do %> ({@sounding.station_name}) <% end %> <%= if @sounding.distance_km do %> · {format_km_mi(@sounding.distance_km)} from midpoint <% end %> · obs {Calendar.strftime(@sounding.observed_at, "%Y-%m-%d %H:%M UTC")} <%= if @sounding.min_refractivity_gradient do %> · dN/dh {format_number(@sounding.min_refractivity_gradient)} N/km <% end %> <%= if @sounding.precipitable_water_mm do %> · PWAT {format_number(@sounding.precipitable_water_mm)} mm <% end %>
<% end %>
<%= for pt <- @hrrr_points do %> <% end %>
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)}
<%= if @mid.profile.profile && @mid.profile.profile != [] do %>
Midpoint vertical profile
<.skew_t_chart profile={@mid.profile.profile} /> <% end %>
""" end attr :readout, :map, required: true attr :band_mhz, :integer, required: true defp ionosphere_readout(assigns) do ~H"""
Ionosphere · Sporadic-E Potential
{@readout.station_code} · {Calendar.strftime(@readout.valid_time, "%Y-%m-%d %H:%M UTC")}
foEs
{format_foes(@readout.fo_es_mhz)} MHz
foF2
{format_foes(@readout.fo_f2_mhz)} MHz
Es MUF (path)
{format_foes(@readout.es_muf_mhz)} MHz
Es Score ({@band_mhz} MHz)
{@readout.es_score}
<%= 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 %>
""" 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"""
{length(@forecast)}-Hour Propagation Forecast · {@band_config.label}
{Phoenix.HTML.raw(@trend)}
<%= for s <- [0, 25, 50, 75, 100] do %> <% y = 20 + (100 - s) / 100 * 185 %> {s} <% end %> <%= for pt <- @points do %> <% end %> {@best_pt.score} <%= for {x, label} <- @x_labels do %> {label} <% end %>
Best: {@best_pt.score} at {@best_pt.label} UTC Worst: {@worst_pt.score} at {@worst_pt.label} UTC
""" 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(▲ Improving) diff < -5 -> ~s(▼ Declining) true -> ~s(→ Steady) end _ -> ~s(→ Steady) 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"""
{@label} {format_value(@value)} {@unit} <%= if @note do %> {@note} <% end %>
""" 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"""
<%= for row <- @rows do %>
{row.name} {row.score}
<% end %>
""" 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