- Unify haversine_km / deg_to_rad: Geo is the canonical home (atan2
form for antipodal stability); Radio, common_volume, rain_scatter,
ionosphere, terrain/srtm, terrain/elevation_client now delegate.
- Drop safe_min/safe_max wrappers in favor of Enum.min/max defaults.
- Move parse_float / parse_int to MicrowavepropWeb.LiveHelpers; eme,
path, and rover LiveViews import from there.
- Extract MicrowavepropWeb.LocationResolver from the eme/path
resolve_source / resolve_location duplicate. Standardize the kind
key and "Invalid grid square" error message.
- Convert Scorer cond chains (humidity, td_depression, sky, wind,
rain, pwat, pressure) to multi-clause guarded defs, matching the
existing classify_time_period style.
- extract_profile_fields uses a named map accumulator instead of a
6-tuple; build_path_conditions guards on %{temps: []} / %{dewpoints: []}.
- Collapse scores_file clamp/3 to max |> min.
- humidity_effect_label lives in BandConfig; map_live and path_live
both use it.
103 lines
3.2 KiB
Elixir
103 lines
3.2 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
|
|
|
|
defp haversine_km(lat1, lon1, lat2, lon2), do: Microwaveprop.Geo.haversine_km(lat1, lon1, lat2, lon2)
|
|
end
|