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
|
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 · 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 :forecast, :list, required: true
|
||||||
attr :band_config, :map, required: true
|
attr :band_config, :map, required: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue