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 defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2) end