PathLive: ionosphere readout panel for VHF paths

Closes the GIRO → SporadicE → display loop. When a 144 or 440 MHz path
is calculated, PathLive looks up the nearest polled ionosonde's latest
foEs via Ionosphere.nearest_foes/3, runs SporadicE.es_score for the
actual (band, distance) pair, and renders a panel showing:

* Live foEs / foF2 / station-code / timestamp
* Computed single-hop Es MUF for this exact path length
* 0-100 Es score for the selected band
* Human-readable interpretation ("strong opening", "tropo only",
  "out of single-hop window", etc.)

Panel only appears for 144/440 (Es isn't relevant on microwave) and
only when the nearest station has data within the last 2 hours — if
ionosonde data is stale or missing, the panel is omitted entirely
rather than shown with misleading values.

Does not touch the grid scorer on /map — a grid cell has no intrinsic
path length, so Es scoring there is conceptually muddy. PathLive is
the right home for path-specific predictions.
This commit is contained in:
Graham McIntire 2026-04-15 14:48:46 -05:00
parent 33524ece3d
commit 83ea123e22
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 163 additions and 1 deletions

View file

@ -4,9 +4,11 @@ defmodule MicrowavepropWeb.PathLive do
import MicrowavepropWeb.Components.SkewTChart import MicrowavepropWeb.Components.SkewTChart
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Propagation.SporadicE
alias Microwaveprop.Radio.CallsignClient alias Microwaveprop.Radio.CallsignClient
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
@ -270,6 +272,10 @@ defmodule MicrowavepropWeb.PathLive do
# 18-hour forecast from propagation grid (midpoint of path) # 18-hour forecast from propagation grid (midpoint of path)
forecast = Propagation.point_forecast(band_mhz, midlat, midlon) 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, {:ok,
%{ %{
source: src, source: src,
@ -287,11 +293,39 @@ defmodule MicrowavepropWeb.PathLive do
power_budget: power_budget, power_budget: power_budget,
forecast: forecast, forecast: forecast,
hrrr_count: length(hrrr_profiles), hrrr_count: length(hrrr_profiles),
hrrr_points: hrrr_points hrrr_points: hrrr_points,
ionosphere: ionosphere
}} }}
end end
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 defp resolve_location(input) do
input = String.trim(input) input = String.trim(input)
@ -883,6 +917,11 @@ defmodule MicrowavepropWeb.PathLive do
<.atmospheric_profile hrrr_points={@result.hrrr_points} /> <.atmospheric_profile hrrr_points={@result.hrrr_points} />
<% end %> <% end %>
<%!-- Ionosphere (GIRO sporadic-E readout) --%>
<%= if @result.ionosphere do %>
<.ionosphere_readout readout={@result.ionosphere} band_mhz={@result.band_mhz} />
<% end %>
<%!-- 18-Hour Forecast --%> <%!-- 18-Hour Forecast --%>
<%= if @result.forecast != [] do %> <%= if @result.forecast != [] do %>
<.forecast_chart forecast={@result.forecast} band_config={@result.band_config} /> <.forecast_chart forecast={@result.forecast} band_config={@result.band_config} />
@ -983,6 +1022,73 @@ defmodule MicrowavepropWeb.PathLive do
""" """
end end
attr :readout, :map, required: true
attr :band_mhz, :integer, required: true
defp ionosphere_readout(assigns) do
~H"""
<div class="bg-base-200 rounded-box p-4 mb-6">
<div class="flex items-center justify-between mb-2">
<div class="text-xs opacity-60">Ionosphere &middot; Sporadic-E Potential</div>
<div class="text-[10px] opacity-50">
{@readout.station_code} · {Calendar.strftime(@readout.valid_time, "%Y-%m-%d %H:%M UTC")}
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">foEs</div>
<div class="font-bold text-lg">
{format_foes(@readout.fo_es_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">foF2</div>
<div class="font-bold text-lg">
{format_foes(@readout.fo_f2_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">Es MUF (path)</div>
<div class="font-bold text-lg">
{format_foes(@readout.es_muf_mhz)}
<span class="text-xs font-normal opacity-60">MHz</span>
</div>
</div>
<div>
<div class="text-[10px] uppercase tracking-wider opacity-60">
Es Score ({@band_mhz} MHz)
</div>
<div class="font-bold text-lg" style={"color: #{tier_color(@readout.es_score)}"}>
{@readout.es_score}
</div>
</div>
</div>
<div class="text-[11px] opacity-70 mt-3 leading-snug">
<%= cond do %>
<% not @readout.es_in_range? -> %>
Path length is outside the single-hop Es window (5002500 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 %>
</div>
</div>
"""
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 :forecast, :list, required: true
attr :band_config, :map, required: true attr :band_config, :map, required: true

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.PathLiveTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
alias Microwaveprop.Ionosphere
alias Microwaveprop.Propagation alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Terrain.ElevationClient
@ -65,4 +66,59 @@ defmodule MicrowavepropWeb.PathLiveTest do
refute refreshed =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s refute refreshed =~ ~r/Best:.*?<span[^>]*>\s*30\s*<\/span>/s
end end
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 end