prop/lib/microwaveprop/weather/profile_lookup.ex
Graham McInitre b3ad3bdbe6 fix: sweep bugs across Python MQTT listener, Elixir PSKR/propagation, and Rust
Python pskr_mqtt_listen.py:
- Guard against malformed CONNACK/SUBACK/PUBLISH packets
- Fix _s() truthiness: is not None instead of if v (zero SNR was lost)
- EINTR-safe select loop, OOB data handling, VBInt overflow check
- Proper socket cleanup with try/finally + DISCONNECT on shutdown

Elixir:
- Log dropped spot errors in aggregator (was silently discarded)
- Wire retain_scores_window into NotifyListener chain completion
- Recurse sweep_tmp_dir into band/weather_scalars subdirectories
- Rescue recalibrator.run/0 to write failed status row on crash
- Handle nil in fmt_snr/fmt_callsigns (no more nil dB crashes)
- Replace Stream.run with Enum.reduce logging exits in poll_worker
- Handle File.stat race in ms_footprints prune, grid_center nil log
- Fix unused variables, stale comments, missing get_path!/1

Rust:
- Graceful JoinError handling in fetcher/hrdps_fetcher (no more panics)
- round_to_5min returns Option (leap-second safe)
- Acquire/Release ordering on shutdown flags (was Relaxed)
- Parameterized NOTIFY in db.rs, defensive scalar sweep, NaN guard
- OsString::push for tmp naming, clippy fixes
2026-07-20 19:48:45 -05:00

423 lines
13 KiB
Elixir

defmodule Microwaveprop.Weather.ProfileLookup do
@moduledoc """
Profile-lookup functions extracted from `Microwaveprop.Weather` to reduce the
size of the main module. Each function is accessed via `defdelegate` on
`Microwaveprop.Weather`, so all existing callers continue to work unchanged.
"""
import Ecto.Query
alias Microwaveprop.Radio
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.NarrProfile
require Logger
@spec has_hrrr_profile?(float(), float(), DateTime.t()) :: boolean()
def has_hrrr_profile?(lat, lon, valid_time) do
dlat = 0.07
dlon = 0.07
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time == ^valid_time
)
|> Repo.exists?()
end
@doc """
Batch version of `has_hrrr_profile?/3`. Accepts a list of
`{{lat, lon}, valid_time}` tuples and returns a `MapSet` of the
tuples that have at least one matching HRRR profile. A single query
covers the union bounding box of all points — dramatically cheaper
than N individual `EXISTS` queries when checking hundreds of contacts.
"""
@spec hrrr_points_present_batch([{{float(), float()}, DateTime.t()}]) :: MapSet.t()
def hrrr_points_present_batch([]), do: MapSet.new()
def hrrr_points_present_batch(points) do
d = 0.07
{lat_lons, times} = Enum.unzip(points)
{lats, lons} = Enum.unzip(lat_lons)
lat_min = Enum.min(lats) - d
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = Enum.min_by(times, &DateTime.to_unix/1)
time_max = Enum.max_by(times, &DateTime.to_unix/1)
profiles =
Repo.all(
from(h in HrrrProfile,
where:
h.lat >= ^lat_min and h.lat <= ^lat_max and
h.lon >= ^lon_min and h.lon <= ^lon_max and
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
select: {h.lat, h.lon, h.valid_time}
)
)
points
|> Enum.filter(fn {{lat, lon}, valid_time} ->
Enum.any?(profiles, fn {pl, pn, pvt} ->
abs(pl - lat) <= d and abs(pn - lon) <= d and pvt == valid_time
end)
end)
|> MapSet.new()
end
@doc """
Returns `true` when every path point for `contact` (pos1 → midpoint → pos2,
or just pos1 for one-endpoint contacts) already has an HRRR profile at the
nearest HRRR hour. Lets the enrichment enqueuer skip :queued → :queued
churn when the backing data is already present.
Returns `false` if `pos1` or `qso_timestamp` is nil — a contact with no
position or no timestamp can't be looked up.
"""
@spec hrrr_data_fully_present?(term()) :: boolean()
def hrrr_data_fully_present?(%{pos1: nil}), do: false
def hrrr_data_fully_present?(%{qso_timestamp: nil}), do: false
def hrrr_data_fully_present?(contact) do
rounded = HrrrClient.nearest_hrrr_hour(contact.qso_timestamp)
case Radio.contact_path_points(contact) do
[] ->
false
points ->
Enum.all?(points, fn {lat, lon} ->
{rlat, rlon} = round_to_hrrr_grid(lat, lon)
has_hrrr_profile?(rlat, rlon, rounded)
end)
end
end
@spec hrrr_for_contact(map()) :: HrrrProfile.t() | nil
def hrrr_for_contact(%{pos1: nil}), do: nil
def hrrr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_hrrr(lat, lon, contact.qso_timestamp)
end
end
@doc """
Nearest sounding to (`lat`, `lon`) within `radius_km` km and a ±3-hour
window around `timestamp`. Joins `weather_stations` to `soundings` so
the caller gets the raw sounding row (station_id set, derived duct
fields populated) back.
Returns `{:ok, sounding}` or `{:error, :not_found}`. Use this in the
path calculator to surface the nearest RAOB's `ducting_detected` flag
as an independent check on HRRR's pressure-level duct signal, which
under-reads thin surface ducts.
"""
@spec find_nearest_hrrr(float(), float(), DateTime.t()) :: HrrrProfile.t() | nil
def find_nearest_hrrr(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrProfile
|> where(
[h],
h.lat >= ^(lat - dlat) and h.lat <= ^(lat + dlat) and
h.lon >= ^(lon - dlon) and h.lon <= ^(lon + dlon) and
h.valid_time >= ^time_start and h.valid_time <= ^time_end
)
|> order_by([h],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@spec hrrr_profiles_for_path(map()) :: [HrrrProfile.t()]
def hrrr_profiles_for_path(%{pos1: nil}), do: []
def hrrr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_hrrr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@doc """
Batch version of `hrrr_profiles_for_path/1`. Queries all HRRR profiles
for a list of contacts in a single DB round-trip, then maps results
back per-contact. Returns a list of `{contact, [profile]}` tuples.
"""
@spec hrrr_profiles_for_contacts([map()]) :: [{map(), [HrrrProfile.t()]}]
def hrrr_profiles_for_contacts(contacts) do
for_result =
for c <- contacts, c.pos1 != nil, {lat, lon} <- Radio.contact_path_points(c) do
{{lat, lon, c.qso_timestamp}, c}
end
points =
Enum.uniq_by(for_result, fn {key, _} -> key end)
{keys, contact_map} =
Enum.reduce(points, {[], %{}}, fn {{_lat, _lon, _ts} = key, c}, {ks, cm} ->
{[key | ks], Map.update(cm, c, [key], &[key | &1])}
end)
index = find_nearest_hrrrs_batch(keys)
Enum.map(contacts, fn c ->
point_keys = Map.get(contact_map, c, [])
profiles = point_keys |> Enum.map(&Map.get(index, &1)) |> Enum.reject(&is_nil/1)
{c, profiles}
end)
end
# Batch nearest-HRRR lookup: query all profiles in the union bounding
# box once, then match each point to its nearest profile in Elixir.
@spec find_nearest_hrrrs_batch([{float(), float(), DateTime.t()}]) :: %{
{float(), float(), DateTime.t()} => HrrrProfile.t()
}
defp find_nearest_hrrrs_batch([]), do: %{}
defp find_nearest_hrrrs_batch(points) do
d = 0.07
margin_s = 3600
{lats, lons, times} = Enum.unzip(points)
lat_min = Enum.min(lats) - d
lat_max = Enum.max(lats) + d
lon_min = Enum.min(lons) - d
lon_max = Enum.max(lons) + d
time_min = times |> Enum.min_by(&DateTime.to_unix/1) |> DateTime.add(-margin_s, :second)
time_max = times |> Enum.max_by(&DateTime.to_unix/1) |> DateTime.add(margin_s, :second)
profiles =
Repo.all(
from(h in HrrrProfile,
where:
h.lat >= ^lat_min and h.lat <= ^lat_max and
h.lon >= ^lon_min and h.lon <= ^lon_max and
h.valid_time >= ^time_min and h.valid_time <= ^time_max,
select: %{lat: h.lat, lon: h.lon, valid_time: h.valid_time, profile: h}
)
)
Map.new(points, fn {lat, lon, ts} ->
nearest =
Enum.min_by(
profiles,
fn p ->
abs(p.lat - lat) + abs(p.lon - lon) + abs(DateTime.diff(p.valid_time, ts))
end,
fn -> nil end
)
{{lat, lon, ts}, nearest && nearest.profile}
end)
end
@spec find_nearest_native_profile(float(), float(), DateTime.t()) ::
HrrrNativeProfile.t() | nil
def find_nearest_native_profile(lat, lon, timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.add(timestamp, -3600, :second)
time_end = DateTime.add(timestamp, 3600, :second)
HrrrNativeProfile
|> where(
[n],
n.lat >= ^(lat - dlat) and n.lat <= ^(lat + dlat) and
n.lon >= ^(lon - dlon) and n.lon <= ^(lon + dlon) and
n.valid_time >= ^time_start and n.valid_time <= ^time_end
)
|> order_by([n],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
n.lat,
^lat,
n.lon,
^lon,
n.valid_time,
^timestamp
)
)
|> limit(1)
|> Repo.one()
end
@doc """
Find the best available atmospheric profile for a contact.
Tries HRRR first (3 km, hourly), falls back to NARR (32 km, 3-hourly).
Returns the profile struct or nil.
"""
@spec best_profile_for_contact(map()) :: HrrrProfile.t() | NarrProfile.t() | nil
def best_profile_for_contact(contact) do
hrrr_for_contact(contact) || narr_for_contact(contact)
end
@spec narr_for_contact(map()) :: NarrProfile.t() | nil
def narr_for_contact(%{pos1: nil}), do: nil
def narr_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_narr(lat, lon, contact.qso_timestamp)
end
end
@spec narr_profiles_for_path(map()) :: [NarrProfile.t()]
def narr_profiles_for_path(%{pos1: nil}), do: []
def narr_profiles_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_narr(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
@spec find_nearest_narr(float(), float(), DateTime.t()) :: NarrProfile.t() | nil
def find_nearest_narr(lat, lon, timestamp) do
dlat = 0.15
dlon = 0.15
time_start = DateTime.add(timestamp, -1800, :second)
time_end = DateTime.add(timestamp, 1800, :second)
NarrProfile
|> where(
[p],
p.lat >= ^(lat - dlat) and p.lat <= ^(lat + dlat) and
p.lon >= ^(lon - dlon) and p.lon <= ^(lon + dlon) and
p.valid_time >= ^time_start and p.valid_time <= ^time_end
)
|> order_by([p],
asc:
fragment(
"ABS(? - ?) + ABS(? - ?)",
p.lat,
^lat,
p.lon,
^lon
)
)
|> limit(1)
|> Repo.one()
end
@spec round_to_hrrr_grid(float(), float()) :: {float(), float()}
def round_to_hrrr_grid(lat, lon) do
{Float.round(lat / 1.0, 2), Float.round(lon / 1.0, 2)}
end
@doc """
Delete every `is_grid_point = true` row from `hrrr_profiles`, regardless of
age. Grid-point profiles are historical — the propagation grid now lives in
`/data/scores` binary files, nothing reads `is_grid_point = true` rows
anymore, and forecast runs no longer write them. This purge walks each
partition directly so a single DELETE can't scan the whole parent table.
Preserves QSO-linked rows (`is_grid_point = false`), which remain the data
path for contact enrichment and the `/path` calculator.
"""
@spec purge_grid_point_profiles() :: non_neg_integer()
def purge_grid_point_profiles do
deleted =
Enum.reduce(hrrr_profile_partitions(), 0, fn partition, acc ->
%{num_rows: n} =
Repo.query!(
~s(DELETE FROM "#{partition}" WHERE is_grid_point = true),
[],
timeout: 600_000
)
if n > 0 do
Logger.info("Purged #{n} grid-point rows from #{partition}")
end
acc + n
end)
deleted
end
@spec hrrr_profile_partitions() :: [String.t()]
defp hrrr_profile_partitions do
{:ok, %{rows: rows}} =
Repo.query(
"""
SELECT child.relname
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'hrrr_profiles'
ORDER BY child.relname
""",
[],
timeout: 60_000
)
Enum.map(rows, fn [name] -> name end)
end
@spec round_to_iemre_grid(float(), float()) :: {float(), float()}
def round_to_iemre_grid(lat, lon) do
{Float.round(lat * 8) / 8, Float.round(lon * 8) / 8}
end
@spec iemre_for_contact(map()) :: IemreObservation.t() | nil
def iemre_for_contact(%{pos1: nil}), do: nil
def iemre_for_contact(contact) do
lat = contact.pos1["lat"]
lon = contact.pos1["lon"]
if lat && lon do
find_nearest_iemre(lat, lon, contact.qso_timestamp)
end
end
@spec find_nearest_iemre(float(), float(), DateTime.t()) :: IemreObservation.t() | nil
def find_nearest_iemre(lat, lon, timestamp) do
{rlat, rlon} = round_to_iemre_grid(lat, lon)
date = DateTime.to_date(timestamp)
IemreObservation
|> where([i], i.lat == ^rlat and i.lon == ^rlon and i.date == ^date)
|> Repo.one()
end
@spec iemre_for_path(map()) :: [IemreObservation.t()]
def iemre_for_path(%{pos1: nil}), do: []
def iemre_for_path(contact) do
contact
|> Radio.contact_path_points()
|> Enum.map(fn {lat, lon} -> find_nearest_iemre(lat, lon, contact.qso_timestamp) end)
|> Enum.reject(&is_nil/1)
end
end