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""" <.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 raw_points = result.terrain.analysis.points _ = BuildingsLoader.ensure_loaded_for_bbox(profile_bbox(raw_points)) # ElevationProfile hook expects: points with dist_km, elev, beam, r1, building_m points = Enum.map(raw_points, fn p -> %{ dist_km: p.dist_km, elev: p.elev, beam: p[:beam] || 0, r1: p[:r1] || 0, building_m: BuildingsIndex.max_height_near(p.lat, p.lon, 80), canopy_m: Canopy.lookup(p.lat, p.lon) } end) %{ points: points, freq_mhz: result.band_mhz, dist_km: result.dist_km, verdict: result.terrain.analysis.verdict, ducts: [] } end defp profile_bbox([]), do: %{"south" => 0, "north" => 0, "west" => 0, "east" => 0} defp profile_bbox(points) do lats = Enum.map(points, & &1.lat) lons = Enum.map(points, & &1.lon) %{"south" => Enum.min(lats), "north" => Enum.max(lats), "west" => Enum.min(lons), "east" => Enum.max(lons)} end end