+ <.link navigate="/path" class="btn btn-xs btn-ghost justify-start">
+ Path Calculator
+
<.link navigate="/weather" class="btn btn-xs btn-ghost justify-start">
Weather Map
diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex
new file mode 100644
index 00000000..4fcf2934
--- /dev/null
+++ b/lib/microwaveprop_web/live/path_live.ex
@@ -0,0 +1,589 @@
+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"""
+
+
+
+ | Factor |
+ Score |
+ Weight |
+ Contribution |
+
+
+
+ <%= for row <- @rows do %>
+
+ | {row.name} |
+ {row.score} |
+ {row.weight}% |
+ {row.contribution} |
+
+ <% end %>
+
+
+ """
+ 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
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index a78ded2f..f35a6c02 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -36,6 +36,7 @@ defmodule MicrowavepropWeb.Router do
live "/contacts", ContactLive.Index
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
+ live "/path", PathLive
live "/algo", AlgoLive
live "/backfill", BackfillLive