prop/lib/microwaveprop/ionosphere.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

119 lines
3.7 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
@observation_update_fields ~w(confidence_score fo_f2_mhz fo_e_mhz fo_es_mhz hm_f2_km mufd_mhz updated_at)a
@doc """
Upsert a list of parsed `GiroClient` observations for a station.
Conflicts on `(station_code, valid_time)` replace the scaled
characteristic columns plus `updated_at`; `id`, `inserted_at`,
`station_code`, and `valid_time` are preserved. 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, @observation_update_fields},
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