prop/lib/microwaveprop/space_weather.ex
Graham McIntire 923c8a468d
SpaceWeather: SWPC JSON ingestion (Kp, F10.7, GOES X-ray)
Adds the second ionospheric data source layer. Polls NOAA SWPC's free
public JSON services every 5 minutes for three products:

* Planetary K-index (1-min cadence) → geomagnetic_observations
* 10.7 cm solar flux (hourly) → solar_flux_observations
* GOES X-ray flux (1-min, 0.1-0.8nm long-wave band only) →
  solar_xray_observations

All three products feed HF / Es scoring inputs that the propagation
engine will start using in a follow-up commit:

* Kp drives HF absorption and Es damping (memory notes Kp is inversely
  correlated with tropo/Es stability).
* F10.7 is the SSN proxy for ITU-R P.533 HF MUF prediction.
* GOES X-ray long-wave flux is the short-wave fade (SWF) / D-layer
  absorption proxy — a C-class flare starts attenuating HF within
  seconds of the X-ray peak.

New context `Microwaveprop.SpaceWeather` exposes `upsert_*/1` and
`latest_*/0` per product; `SpaceWeatherFetchWorker` pulls all three
independently so one endpoint's outage doesn't block the others.
Fixtures captured from live SWPC endpoints on 2026-04-15 for the
parser tests.

Not yet wired into scoring — that's a separate commit.
2026-04-15 14:55:24 -05:00

78 lines
2.3 KiB
Elixir

defmodule Microwaveprop.SpaceWeather do
@moduledoc """
Context for NOAA SWPC space-weather observations: planetary K-index,
10.7 cm solar flux, and GOES X-ray flux. Used by the propagation
scorer for HF MUF prediction (F10.7 → SSN proxy), geomagnetic storm
effects (Kp), and short-wave fade detection (GOES X-ray flares).
"""
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.SpaceWeather.GeomagneticObservation
alias Microwaveprop.SpaceWeather.SolarFluxObservation
alias Microwaveprop.SpaceWeather.SolarXrayObservation
# ---------- Kp ----------
@spec upsert_kp([map()]) :: {:ok, non_neg_integer()}
def upsert_kp([]), do: {:ok, 0}
def upsert_kp(rows) when is_list(rows) do
stamp_upsert(GeomagneticObservation, rows, conflict_target: :valid_time)
end
@spec latest_kp() :: GeomagneticObservation.t() | nil
def latest_kp do
Repo.one(from o in GeomagneticObservation, order_by: [desc: o.valid_time], limit: 1)
end
# ---------- F10.7 ----------
@spec upsert_solar_flux([map()]) :: {:ok, non_neg_integer()}
def upsert_solar_flux([]), do: {:ok, 0}
def upsert_solar_flux(rows) when is_list(rows) do
stamp_upsert(SolarFluxObservation, rows, conflict_target: :valid_time)
end
@spec latest_f107() :: SolarFluxObservation.t() | nil
def latest_f107 do
Repo.one(from o in SolarFluxObservation, order_by: [desc: o.valid_time], limit: 1)
end
# ---------- GOES X-ray ----------
@spec upsert_xray([map()]) :: {:ok, non_neg_integer()}
def upsert_xray([]), do: {:ok, 0}
def upsert_xray(rows) when is_list(rows) do
stamp_upsert(SolarXrayObservation, rows, conflict_target: [:valid_time, :energy_band])
end
@spec latest_xray() :: SolarXrayObservation.t() | nil
def latest_xray do
Repo.one(from o in SolarXrayObservation, order_by: [desc: o.valid_time], limit: 1)
end
# ---------- Shared ----------
defp stamp_upsert(schema, rows, opts) do
now = DateTime.truncate(DateTime.utc_now(), :second)
stamped =
Enum.map(rows, fn row ->
row
|> Map.put(:inserted_at, now)
|> Map.put(:updated_at, now)
end)
{count, _} =
Repo.insert_all(schema, stamped,
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: Keyword.fetch!(opts, :conflict_target)
)
{:ok, count}
end
end