prop/lib/microwaveprop/space_weather.ex
Graham McIntire 084e6b6224
refactor(db): use explicit on_conflict set clauses over :replace_all_except
Convert every context/worker upsert that used the catch-all
`:replace_all_except` form to the explicit `{:replace, [:col1, ...]}`
form. Reviewers can now see exactly which columns an upsert overwrites,
and adding a new column to a schema no longer silently opts it into the
update path.

Behaviour is preserved bit-for-bit: each new explicit list contains
every column the old `:replace_all_except` would have overwritten.

Touched:
- Microwaveprop.SpaceWeather (Kp / F10.7 / X-ray shared helper)
- Microwaveprop.Ionosphere (observation upsert)
- NexradWorker, HrrrNativeGridWorker (insert_all grids)
- CommonVolumeRadarWorker, RadarFrameWorker (per-contact stats)

Also pins the conflict behaviour for the ContactCommonVolumeRadar
upsert path in RadarFrameWorkerTest so a future column addition that
isn't reflected in the explicit list fails loudly.
2026-04-21 14:22:15 -05:00

93 lines
2.7 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 ----------
@kp_update_fields ~w(kp_index estimated_kp updated_at)a
@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,
update_fields: @kp_update_fields
)
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 ----------
@solar_flux_update_fields ~w(frequency_mhz flux_sfu reporting_schedule ninety_day_mean updated_at)a
@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,
update_fields: @solar_flux_update_fields
)
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 ----------
@xray_update_fields ~w(satellite flux_wm2 electron_contaminated updated_at)a
@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],
update_fields: @xray_update_fields
)
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, Keyword.fetch!(opts, :update_fields)},
conflict_target: Keyword.fetch!(opts, :conflict_target)
)
{:ok, count}
end
end