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
423 lines
13 KiB
Elixir
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
|