diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex
index c3df912b..2750b1c3 100644
--- a/lib/microwaveprop_web/live/path_live.ex
+++ b/lib/microwaveprop_web/live/path_live.ex
@@ -4,9 +4,11 @@ defmodule MicrowavepropWeb.PathLive do
import MicrowavepropWeb.Components.SkewTChart
+ alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
+ alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Terrain.ElevationClient
@@ -270,6 +272,10 @@ defmodule MicrowavepropWeb.PathLive do
# 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,
@@ -287,11 +293,39 @@ defmodule MicrowavepropWeb.PathLive do
power_budget: power_budget,
forecast: forecast,
hrrr_count: length(hrrr_profiles),
- hrrr_points: hrrr_points
+ hrrr_points: hrrr_points,
+ ionosphere: ionosphere
}}
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 [144, 440] 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)
@@ -883,6 +917,11 @@ defmodule MicrowavepropWeb.PathLive do
<.atmospheric_profile hrrr_points={@result.hrrr_points} />
<% 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} />
@@ -983,6 +1022,73 @@ defmodule MicrowavepropWeb.PathLive do
"""
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
diff --git a/test/microwaveprop_web/live/path_live_test.exs b/test/microwaveprop_web/live/path_live_test.exs
index db3d5b28..ee68853c 100644
--- a/test/microwaveprop_web/live/path_live_test.exs
+++ b/test/microwaveprop_web/live/path_live_test.exs
@@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.PathLiveTest do
import Phoenix.LiveViewTest
+ alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Terrain.ElevationClient
@@ -65,4 +66,59 @@ defmodule MicrowavepropWeb.PathLiveTest do
refute refreshed =~ ~r/Best:.*?]*>\s*30\s*<\/span>/s
end
end
+
+ describe "ionosphere panel" do
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ params = Plug.Conn.fetch_query_params(conn).query_params
+ lat_count = params["latitude"] |> String.split(",") |> length()
+ Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
+ end)
+
+ :ok
+ end
+
+ test "renders Es panel with live foEs and score when nearest station has fresh data", %{conn: conn} do
+ # Seed Millstone Hill with an extreme-Es observation (foEs = 18 MHz).
+ recent = DateTime.truncate(DateTime.utc_now(), :second)
+
+ {:ok, _} =
+ Ionosphere.upsert_observations("MHJ45", [
+ %{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
+ ])
+
+ # 52.6N, -71.5W to 32.6N, -71.5W → 2220 km pure N-S across
+ # Millstone Hill's latitude (midpoint 42.6N).
+ {:ok, lv, _} = live(conn, ~p"/path?source=52.6,-71.5&destination=32.6,-71.5&band=144")
+
+ html = render(lv)
+ assert html =~ "Ionosphere"
+ assert html =~ "MHJ45"
+ assert html =~ "foEs"
+ assert html =~ ~r/18\.\d/
+ end
+
+ test "renders 'tropo only' notice when path is outside the single-hop Es window", %{conn: conn} do
+ recent = DateTime.truncate(DateTime.utc_now(), :second)
+
+ {:ok, _} =
+ Ionosphere.upsert_observations("MHJ45", [
+ %{valid_time: recent, fo_es_mhz: 18.0, fo_f2_mhz: 9.0, mufd_mhz: 28.0}
+ ])
+
+ # ~100 km path — way under the 500 km Es minimum.
+ {:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=43.5,-71.5&band=144")
+
+ html = render(lv)
+ assert html =~ "Ionosphere"
+ assert html =~ ~r/out of range|not applicable|tropo only/i
+ end
+
+ test "omits the Es panel entirely when nearest ionosonde has no recent data", %{conn: conn} do
+ {:ok, lv, _} = live(conn, ~p"/path?source=42.6,-71.5&destination=32.6,-71.5&band=144")
+
+ html = render(lv)
+ refute html =~ "Ionosphere"
+ end
+ end
end