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