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.
116 lines
3.5 KiB
Elixir
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
|