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:
parent
33524ece3d
commit
83ea123e22
2 changed files with 163 additions and 1 deletions
|
|
@ -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"""
|
||||
<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 · 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 (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 %>
|
||||
</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 :band_config, :map, required: true
|
||||
|
||||
|
|
|
|||
|
|
@ -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:.*?<span[^>]*>\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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue