prop/lib/microwaveprop/workers/ionosphere_fetch_worker.ex
Graham McIntire 361b9dec5a
Ionosphere: GIRO ionosonde ingestion (foF2/foEs/hmF2/MUFD)
First step toward physics-based HF / sporadic-E scoring. Polls GIRO's
DIDBase tabular endpoint every 10 minutes for two US ionosondes
(Millstone Hill MHJ45, Alpena AL945), parses the plain-text response
into observation rows, and upserts into a new ionosonde_observations
table keyed on (station_code, valid_time).

This gets foEs (E-layer sporadic critical frequency) into the database
as a direct measurement — the key input for predicting 144 MHz Es
openings via ITU-R P.534-6. foF2 + MUFD are the F2-layer inputs for
HF MUF predictions.

Not yet wired into scoring. Boulder/Wallops/Austin/Idaho/Point Arguello
are in the GIRO catalog but were silent when probed — add them back
if/when they come online.

Next steps: SWPC JSON (Kp, F10.7, sunspot), GOES X-ray flux, D-RAP text,
and the P.534-6 Es scoring factor that uses foEs at midpoint for the
144/440 band configs.
2026-04-15 14:37:43 -05:00

62 lines
2 KiB
Elixir

defmodule Microwaveprop.Workers.IonosphereFetchWorker do
@moduledoc """
Polls GIRO DIDBase for real-time ionosonde measurements (foF2, foE,
foEs, hmF2, MUFD) from a handful of CONUS stations. Drives the 144
MHz sporadic-E and HF MUF scoring inputs.
Runs on a ~10-minute cron — GIRO publishes each station at 7.5-minute
cadence so a 10-minute poll guarantees we pick up every new sample
within one cycle without hammering the endpoint.
Pulls a 2-hour window each poll to catch any late-arriving samples
and to be resilient to the occasional missed run. Upserts are
idempotent on (station_code, valid_time).
"""
use Oban.Worker,
queue: :ionosphere,
max_attempts: 3,
unique: [period: 300, states: [:available, :scheduled, :executing, :retryable]]
alias Microwaveprop.Ionosphere
alias Microwaveprop.Ionosphere.GiroClient
require Logger
# CONUS stations that have been publishing reliably. Others (Boulder,
# Wallops, Austin, Idaho, Point Arguello) are in the GIRO catalog but
# intermittent — add them here once they come back online.
@stations [
%{code: "MHJ45", name: "Millstone Hill MA", lat: 42.6, lon: -71.5},
%{code: "AL945", name: "Alpena MI", lat: 45.1, lon: -83.6}
]
@lookback_seconds 2 * 3600
@impl Oban.Worker
def perform(%Oban.Job{}) do
now = DateTime.truncate(DateTime.utc_now(), :second)
from_dt = DateTime.add(now, -@lookback_seconds, :second)
Enum.each(@stations, fn station ->
fetch_and_upsert(station, from_dt, now)
end)
:ok
end
@doc false
@spec stations() :: [map()]
def stations, do: @stations
defp fetch_and_upsert(%{code: code, name: name}, from_dt, to_dt) do
case GiroClient.fetch(code, from_dt, to_dt) do
{:ok, observations} ->
{:ok, count} = Ionosphere.upsert_observations(code, observations)
Logger.info("IonosphereFetch: #{code} (#{name}) upserted #{count} observations")
{:error, reason} ->
Logger.warning("IonosphereFetch: #{code} (#{name}) failed: #{inspect(reason)}")
end
end
end