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, parse_int: 2] alias Microwaveprop.Buildings.Index, as: BuildingsIndex alias Microwaveprop.Buildings.Loader, as: BuildingsLoader alias Microwaveprop.Canopy alias Microwaveprop.Propagation alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.PathCompute alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.RoverPlanning.Path, as: RoverPath 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, compute_step: 0, compute_total: PathCompute.total_stages(), compute_label: nil, 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(%{"rover_path_id" => rover_path_id} = _params, _uri, socket) when is_binary(rover_path_id) and rover_path_id != "" do case load_cached_path(rover_path_id) do {:ok, path, result} -> params = params_from_cached(path, result) 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"], source_is_gps: false, result: result, computing: false, error: nil ) {:noreply, socket} {:error, :not_found} -> {:noreply, socket |> put_flash(:error, "Cached path profile not found.") |> push_navigate(to: ~p"/path")} {:error, :stale_term} -> # Cached blob can't be deserialized (e.g. old enrichment from # before this code shipped). Fall through to live recompute # using the path's stored source/dest/band. case load_cached_path_for_redirect(rover_path_id) do {:ok, params} -> {:noreply, push_patch(socket, to: ~p"/path?#{params}", replace: true)} {:error, _} -> {:noreply, push_navigate(socket, to: ~p"/path")} end end end def handle_params(params, _uri, socket) do p = Map.merge(@defaults, Map.take(params, @url_params)) is_gps = p["source"] == "gps" # Clear stale results when URL params change — otherwise a cached # result from a previous calculation persists when the user navigates # to a different source/destination/band combination. new_source = if(is_gps, do: socket.assigns.source, else: p["source"]) params_changed? = params_differ?(socket, p, new_source) socket = if params_changed? do assign(socket, result: nil, error: nil) else socket end socket = assign(socket, source: new_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 params_differ?(socket, p, new_source) do socket.assigns.result != nil and (new_source != socket.assigns.source or p["destination"] != socket.assigns.destination or p["band"] != socket.assigns.band or p["src_height_ft"] != socket.assigns.src_height_ft or p["dst_height_ft"] != socket.assigns.dst_height_ft or p["tx_power_dbm"] != socket.assigns.tx_power_dbm or p["src_gain_dbi"] != socket.assigns.src_gain_dbi or p["dst_gain_dbi"] != socket.assigns.dst_gain_dbi) end # Loads a cached rover-planning Path and rebuilds the @result map # PathLive's render expects. The blob lives under `result["term"]` # (Base64-encoded `:erlang.term_to_binary/1` of PathCompute's full # output). `:safe` mode rejects unknown atoms — every atom we # roundtrip is one this codebase already references at compile time, # so it's known. defp load_cached_path(rover_path_id) do with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id), %RoverPath{result: %{"term" => term}} <- Repo.get(RoverPath, uuid), {:ok, decoded} <- decode_term(term) do {:ok, nil, decoded} else %RoverPath{} -> {:error, :stale_term} nil -> {:error, :not_found} :error -> {:error, :not_found} {:error, _} = err -> err end end # Fall-back: when the cached term is missing/stale, redirect to # /path with the source/dest/band fields filled in from the Path's # rover_location + station so the user gets a live recompute. defp load_cached_path_for_redirect(rover_path_id) do with {:ok, uuid} <- Ecto.UUID.cast(rover_path_id), %RoverPath{} = path <- RoverPath |> Repo.get(uuid) |> Repo.preload([:rover_location, :station, :mission]) do {:ok, %{ "source" => Maidenhead.from_latlon(path.rover_location.lat, path.rover_location.lon, 10), "destination" => path.station.callsign || path.station.grid || "#{path.station.lat},#{path.station.lon}", "band" => Integer.to_string(path.band_mhz || path.mission.band_mhz), "src_height_ft" => Float.to_string((path.mission.rover_height_ft || 8.0) * 1.0), "dst_height_ft" => Float.to_string((path.mission.station_height_ft || 30.0) * 1.0) }} else _ -> {:error, :not_found} end end defp decode_term(term) when is_binary(term) do with {:ok, binary} <- Base.decode64(term), decoded when is_map(decoded) <- safe_binary_to_term(binary) do {:ok, decoded} else _ -> {:error, :stale_term} end rescue _ -> {:error, :stale_term} end defp safe_binary_to_term(binary) do :erlang.binary_to_term(binary, [:safe]) rescue _ -> nil end defp params_from_cached(_path, result) do sp = result.station_params || %{} %{ "source" => label_for(result.source), "destination" => label_for(result.destination), "band" => Integer.to_string(result.band_mhz), "src_height_ft" => Float.to_string((Map.get(sp, :src_height_ft) || 30.0) * 1.0), "dst_height_ft" => Float.to_string((Map.get(sp, :dst_height_ft) || 30.0) * 1.0), "tx_power_dbm" => Float.to_string((Map.get(sp, :tx_power_dbm) || 20.0) * 1.0), "src_gain_dbi" => Float.to_string((Map.get(sp, :src_gain_dbi) || 30.0) * 1.0), "dst_gain_dbi" => Float.to_string((Map.get(sp, :dst_gain_dbi) || 30.0) * 1.0) } end defp label_for(%{label: label}) when is_binary(label), do: label defp label_for(_), do: "" 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) } {:noreply, start_compute_async( socket, p["source"], p["destination"], parse_int(p["band"], 10_000), station_params )} else {:noreply, socket} end end # Kicks off the path compute in a Task so the LV can keep rendering # progress messages while it runs. The Task captures the LV pid and # sends `{:compute_progress, step, total, label}` messages from # `PathCompute`'s `:on_progress` callback as each stage starts; # `handle_async(:compute_path, ...)` picks up the final result. defp start_compute_async(socket, source, dest, band_mhz, station_params) do lv = self() on_progress = fn step, total, label -> send(lv, {:compute_progress, step, total, label}) end socket |> assign( computing: true, error: nil, result: nil, compute_step: 0, compute_total: PathCompute.total_stages(), compute_label: "Starting…" ) |> start_async(:compute_path, fn -> PathCompute.compute(source, dest, band_mhz, station_params, on_progress: on_progress) 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 = socket |> assign( 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"] ) |> start_compute_async( params["source"], params["destination"], parse_int(params["band"], 10_000), 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_progress, step, total, label}, socket) do # Drop stale progress messages from any task we no longer care about. if socket.assigns.computing do {:noreply, assign(socket, compute_step: step, compute_total: total, compute_label: label)} else {:noreply, socket} 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 @impl true def handle_async(:compute_path, {:ok, {:ok, result}}, socket) do {:noreply, assign(socket, result: result, computing: false, compute_label: nil, compute_step: 0, error: nil )} end def handle_async(:compute_path, {:ok, {:error, reason}}, socket) do {:noreply, assign(socket, error: reason, computing: false, compute_label: nil, compute_step: 0 )} end def handle_async(:compute_path, {:exit, reason}, socket) do Logger.error("PathLive compute task crashed: #{inspect(reason)}") {:noreply, assign(socket, error: "Internal error computing path. Please try again.", computing: false, compute_label: nil, compute_step: 0 )} end # ── 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_small(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 4) defp format_small(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 @computing and @compute_total > 0 do %> <% end %>
<%= 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_small( (@result.loss_budget.o2 + @result.loss_budget.h2o) / max(@result.dist_km, 0.001) )} dB/km
H₂O: {format_small(@result.band_config.h2o_coeff)} dB/km/g · O₂: {format_small(@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 %> <%!-- 48-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