defmodule MicrowavepropWeb.PathLive do @moduledoc false use MicrowavepropWeb, :live_view alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.TerrainAnalysis alias Microwaveprop.Weather @band_options [ {"10 GHz", "10000"}, {"24 GHz", "24000"}, {"47 GHz", "47000"}, {"68 GHz", "68000"}, {"76 GHz", "75000"}, {"122 GHz", "122000"}, {"134 GHz", "134000"}, {"241 GHz", "241000"} ] @impl true def mount(_params, _session, socket) do {:ok, assign(socket, page_title: "Path Calculator", band_options: @band_options, source: "", destination: "", band: "10000", result: nil, error: nil, computing: false )} end @impl true def handle_event("calculate", %{"source" => source, "destination" => dest, "band" => band}, socket) do socket = assign(socket, source: source, destination: dest, band: band, error: nil, computing: true, result: nil) send(self(), {:compute_path, source, dest, String.to_integer(band)}) {:noreply, socket} end @impl true def handle_info({:compute_path, source, dest, band_mhz}, socket) do case compute_path(source, dest, band_mhz) do {:ok, result} -> {:noreply, assign(socket, result: result, computing: false)} {:error, reason} -> {:noreply, assign(socket, error: reason, computing: false)} end end defp compute_path(source, dest, band_mhz) 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 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) %{profile: profile, analysis: analysis} {:error, _} -> nil end # HRRR profiles along path now = DateTime.utc_now() midlat = (src.lat + dst.lat) / 2 midlon = (src.lon + dst.lon) / 2 hrrr_profiles = [{src.lat, src.lon}, {midlat, midlon}, {dst.lat, dst.lon}] |> Enum.map(fn {lat, lon} -> Weather.find_nearest_hrrr(lat, lon, now) end) |> Enum.reject(&is_nil/1) # Build conditions and score {conditions, scoring} = build_scoring(hrrr_profiles, src, now, band_config) # Loss budget loss_budget = compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) {:ok, %{ source: src, destination: dst, 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, hrrr_count: length(hrrr_profiles) }} end end defp resolve_location(input) do input = String.trim(input) cond do input == "" -> {:error, "Location is required"} 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 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 build_scoring([], _src, _now, _band_config), do: {nil, nil} defp build_scoring(profiles, src, now, band_config) 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, 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)) } 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 terrain_result.analysis.diffraction_db 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 # ── Geo helpers ── defp haversine_km(lat1, lon1, lat2, lon2) do r = 6371.0 dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) a = :math.sin(dlat / 2) ** 2 + :math.cos(deg_to_rad(lat1)) * :math.cos(deg_to_rad(lat2)) * :math.sin(dlon / 2) ** 2 2 * r * :math.asin(:math.sqrt(a)) end defp bearing_deg(lat1, lon1, lat2, lon2) do lat1r = deg_to_rad(lat1) lat2r = deg_to_rad(lat2) dlonr = deg_to_rad(lon2 - lon1) y = :math.sin(dlonr) * :math.cos(lat2r) x = :math.cos(lat1r) * :math.sin(lat2r) - :math.sin(lat1r) * :math.cos(lat2r) * :math.cos(dlonr) b = :math.atan2(y, x) * 180 / :math.pi() Float.round(:math.fmod(b + 360, 360), 1) end defp deg_to_rad(d), do: d * :math.pi() / 180 # ── 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: "#00ffa3" defp tier_color(score) when score >= 65, do: "#7dffd4" defp tier_color(score) when score >= 50, do: "#ffe566" defp tier_color(score) when score >= 33, do: "#ff9044" defp tier_color(_), do: "#ff4f4f" 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) # ── 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 %> <%!-- Summary cards --%>
<%!-- Link Summary --%>
Link Summary
Distance {format_number(@result.dist_km)} km
Bearing {format_number(@result.bearing)}°
Band {@result.band_config.label}
<%= if @result.terrain do %>
Terrain {@result.terrain.analysis.verdict}
<% end %>
HRRR Profiles {@result.hrrr_count} / 3
<%!-- Loss Budget --%>
Loss Budget
Free Space {@result.loss_budget.fspl} dB
O₂ Absorption {@result.loss_budget.o2} dB
H₂O Absorption {@result.loss_budget.h2o} dB
<%= if @result.loss_budget.rain > 0 do %>
Rain {@result.loss_budget.rain} dB
<% end %> <%= if @result.loss_budget.diffraction > 0 do %>
Diffraction {@result.loss_budget.diffraction} dB
<% end %>
Total Path Loss {@result.loss_budget.total} dB
<%!-- Propagation Score --%>
Propagation Score
<%= if @result.scoring do %>
{@result.scoring.score} / 100
{tier_label(@result.scoring.score)}
<% else %>
No HRRR data available for current conditions
<% end %>
<%!-- Factor breakdown --%> <%= if @result.scoring do %>
Propagation Factors (current conditions)
<.factor_table scoring={@result.scoring} />
<% end %> <%!-- Atmospheric conditions --%> <%= if @result.conditions do %>
Path Atmospheric Conditions
Temperature
{format_number(@result.conditions.temp_c)}°C
Dewpoint
{format_number(@result.conditions.dewpoint_c)}°C
T-Td Depression
{format_number(@result.conditions.temp_c - @result.conditions.dewpoint_c)}°C
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
Refractivity Gradient
{format_number(@result.conditions.min_refractivity_gradient || 0)} N/km
Abs Humidity
{format_number(@result.conditions.abs_humidity)} g/m³
<% end %> """ end defp factor_table(assigns) do weights = BandConfig.weights() rows = assigns.scoring.factors |> Enum.map(fn {key, score} -> weight = Map.get(weights, key, 0) %{ name: factor_name(key), score: score, weight: round(weight * 100), contribution: Float.round(score * weight, 1) } end) |> Enum.sort_by(& &1.contribution, :desc) assigns = assign(assigns, :rows, rows) ~H""" <%= for row <- @rows do %> <% end %>
Factor Score Weight Contribution
{row.name} {row.score} {row.weight}% {row.contribution}
""" end 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 points = Enum.map(result.terrain.analysis.points, fn p -> %{ dist_km: p.dist_km, elev_m: p.elev, beam_m: p[:beam], fresnel_top: if(p[:beam] && p[:r1], do: p.beam + p.r1), fresnel_bottom: if(p[:beam] && p[:r1], do: p.beam - p.r1) } end) %{ points: points, freq_mhz: result.band_mhz, dist_km: result.dist_km, verdict: result.terrain.analysis.verdict, ducts: [] } end end