prop/lib/microwaveprop/ionosphere.ex
Graham McIntire f632ea83bd
Sporadic-E physics module + Ionosphere.nearest_foes
Adds the missing link between the GIRO ionosonde data we just started
ingesting and the VHF/UHF band scoring:

* `Microwaveprop.Propagation.SporadicE` — ITU-R P.534-6 / Davies 1990
  thin-layer Es MUF (sec(i) · foEs with h=110 km), plus an `es_score/3`
  that maps (foEs, target band, hop distance) into a 0-100 single-hop
  propagation likelihood. Calibrated against literature: 50 MHz Es at
  2000 km needs foEs ≳ 5.5 MHz (routine summer); 144 MHz needs
  foEs ≳ 15.8 MHz (rare Jun/Jul peaks only); 440 MHz Es does not occur
  at physical foEs values. Multi-hop Es and Es-scatter are separate
  factors and explicitly out of scope.

* `Ionosphere.nearest_foes/3` — given a lat/lon, returns the latest
  observation from the nearest polled GIRO station (Millstone Hill or
  Alpena for now), with a configurable staleness cutoff (default 2h).
  Returns `{:error, :stale}` or `{:error, :no_data}` so callers can
  choose whether to apply the Es factor at all.

Neither is wired into the grid scorer yet — that's a separate commit
so the integration can be reviewed on its own.
2026-04-15 14:43:48 -05:00

116 lines
3.5 KiB
Elixir

defmodule Microwaveprop.Ionosphere do
@moduledoc """
Context for ionospheric observations (GIRO ionosonde data). Used by
the propagation scorer for VHF sporadic-E and HF MUF predictions.
"""
import Ecto.Query
alias Microwaveprop.Ionosphere.Observation
alias Microwaveprop.Repo
@doc """
Upsert a list of parsed `GiroClient` observations for a station.
Conflicts on `(station_code, valid_time)` replace every column except
the primary key and inserted_at. Returns `{:ok, count}`.
"""
@spec upsert_observations(String.t(), [map()]) :: {:ok, non_neg_integer()}
def upsert_observations(_station_code, []), do: {:ok, 0}
def upsert_observations(station_code, observations) when is_list(observations) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(observations, fn obs ->
obs
|> Map.put(:station_code, station_code)
|> Map.put(:inserted_at, now)
|> Map.put(:updated_at, now)
end)
{count, _} =
Repo.insert_all(Observation, rows,
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:station_code, :valid_time]
)
{:ok, count}
end
@doc """
Returns the most recent `Observation` row for a station, or nil.
"""
@spec latest_observation(String.t()) :: Observation.t() | nil
def latest_observation(station_code) do
Repo.one(
from o in Observation,
where: o.station_code == ^station_code,
order_by: [desc: o.valid_time],
limit: 1
)
end
# Station coordinates for the nearest-lookup. Must stay in sync with
# `IonosphereFetchWorker.stations/0` — these are the stations we're
# actively polling. Kept here (rather than imported) to avoid a
# context → worker dependency.
@stations [
%{code: "MHJ45", lat: 42.6, lon: -71.5},
%{code: "AL945", lat: 45.1, lon: -83.6}
]
@default_max_age_seconds 2 * 3600
@doc """
Look up the nearest polled ionosonde station's latest observation for
the given lat/lon.
Options:
- `:max_age_seconds` — reject observations older than this (default 2h).
Returns `{:error, :stale}` if the nearest station has no fresh data.
Returns `{:ok, observation}`, `{:error, :stale}`, or `{:error, :no_data}`.
"""
@spec nearest_foes(number(), number(), keyword()) ::
{:ok, Observation.t()} | {:error, :stale | :no_data}
def nearest_foes(lat, lon, opts \\ []) do
max_age = Keyword.get(opts, :max_age_seconds, @default_max_age_seconds)
nearest_station = nearest_station_to(lat, lon)
case latest_observation(nearest_station.code) do
nil ->
{:error, :no_data}
%Observation{valid_time: vt} = obs ->
age = DateTime.diff(DateTime.utc_now(), vt, :second)
if age <= max_age do
{:ok, obs}
else
{:error, :stale}
end
end
end
defp nearest_station_to(lat, lon) do
Enum.min_by(@stations, fn s -> haversine_km(lat, lon, s.lat, s.lon) end)
end
# Standard haversine distance in km. Good enough for station-pick
# ranking — we're not computing hop geometry here.
defp haversine_km(lat1, lon1, lat2, lon2) do
r = 6371.0
dlat = :math.pi() * (lat2 - lat1) / 180
dlon = :math.pi() * (lon2 - lon1) / 180
lat1_rad = :math.pi() * lat1 / 180
lat2_rad = :math.pi() * lat2 / 180
a =
:math.sin(dlat / 2) * :math.sin(dlat / 2) +
:math.cos(lat1_rad) * :math.cos(lat2_rad) *
:math.sin(dlon / 2) * :math.sin(dlon / 2)
c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))
r * c
end
end