prop/lib/microwaveprop/weather/hrrr.ex
Graham McIntire 581955bd69
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
fix: prevent contacts_dedup_idx collisions in parallel async tests
Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.

Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.

Includes automated DateTime.add → DateTime.shift migration from
mix format.
2026-08-04 17:05:16 -05:00

306 lines
9.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.Weather.Hrrr do
@moduledoc false
import Ecto.Query
alias Ecto.UUID
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.Narr
alias Microwaveprop.Weather.NarrProfile
alias Microwaveprop.Weather.ProfileLookup
alias Microwaveprop.Weather.SoundingParams
# ── Delegates to ProfileLookup ──
defdelegate find_nearest_hrrr(lat, lon, timestamp), to: ProfileLookup
defdelegate hrrr_profiles_for_path(contact), to: ProfileLookup
defdelegate hrrr_profiles_for_contacts(contacts), to: ProfileLookup
defdelegate find_nearest_native_profile(lat, lon, timestamp), to: ProfileLookup
defdelegate best_profile_for_contact(contact), to: ProfileLookup
defdelegate hrrr_for_contact(contact), to: ProfileLookup
defdelegate hrrr_data_fully_present?(contact), to: ProfileLookup
defdelegate has_hrrr_profile?(lat, lon, valid_time), to: ProfileLookup
defdelegate hrrr_points_present_batch(points), to: ProfileLookup
defdelegate round_to_hrrr_grid(lat, lon), to: ProfileLookup
defdelegate purge_grid_point_profiles(), to: ProfileLookup
# ── HRRR profile persistence ──
@spec upsert_hrrr_profile(map()) :: {:ok, HrrrProfile.t()} | {:error, Ecto.Changeset.t()}
def upsert_hrrr_profile(attrs) do
%HrrrProfile{}
|> HrrrProfile.changeset(attrs)
|> Repo.insert(
on_conflict: :nothing,
conflict_target: [:lat, :lon, :valid_time]
)
end
@spec upsert_hrrr_profiles_batch([map()], keyword()) :: {non_neg_integer(), nil}
def upsert_hrrr_profiles_batch(profiles, _opts \\ []) do
Microwaveprop.Instrument.span(
[:db, :upsert_hrrr_profiles],
%{count: length(profiles)},
fn -> do_upsert_hrrr_profiles_batch(profiles) end
)
end
defp do_upsert_hrrr_profiles_batch(profiles) do
now = DateTime.truncate(DateTime.utc_now(), :second)
profiles
|> Enum.chunk_every(500)
|> Enum.reduce({0, nil}, fn chunk, {total_count, _} ->
entries =
Enum.map(chunk, fn attrs ->
is_gp =
rem(round(attrs.lat * 1000), 125) == 0 and
rem(round(attrs.lon * 1000), 125) == 0
Map.merge(attrs, %{
id: UUID.generate(),
is_grid_point: is_gp,
inserted_at: now,
updated_at: now
})
end)
{count, rows} =
Repo.insert_all(HrrrProfile, entries,
on_conflict: :nothing,
conflict_target: [:lat, :lon, :valid_time]
)
{total_count + count, rows}
end)
end
# ── Native duct info ──
@doc """
Returns just `hrrr_native_profiles.best_duct_band_ghz` near the
given cell. Convenience wrapper around `nearest_native_duct_info/3`
for callers that don't need Richardson.
"""
@spec nearest_native_duct_ghz(float(), float(), DateTime.t()) :: float() | nil
def nearest_native_duct_ghz(lat, lon, %DateTime{} = timestamp) do
case nearest_native_duct_info(lat, lon, timestamp) do
{:ok, %{best_duct_band_ghz: ghz}} -> ghz
_ -> nil
end
end
@doc """
Returns `{:ok, %{best_duct_band_ghz: ghz, bulk_richardson: r}}` for
the nearest `hrrr_native_profiles` cell to (`lat`, `lon`) at
`timestamp` within ±0.07° / ±1h, or `{:error, :not_found}`.
Richardson gates the 1.15× refractivity boost in `Scorer` — a duct
band reading under turbulent conditions (high Richardson) is likely
to be mixed out before it supports the target path. The pair is
cheap to fetch together, so callers that score a cell prefer this
over the bare `nearest_native_duct_ghz/3`.
"""
@spec nearest_native_duct_info(float(), float(), DateTime.t()) ::
{:ok, %{best_duct_band_ghz: float() | nil, bulk_richardson: float() | nil}}
| {:error, :not_found}
def nearest_native_duct_info(lat, lon, %DateTime{} = timestamp) do
dlat = 0.07
dlon = 0.07
time_start = DateTime.shift(timestamp, hour: -1)
time_end = DateTime.shift(timestamp, hour: 1)
from(h in HrrrNativeProfile,
where:
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:
fragment(
"ABS(? - ?) + ABS(? - ?) + ABS(EXTRACT(EPOCH FROM ? - ?))",
h.lat,
^lat,
h.lon,
^lon,
h.valid_time,
^timestamp
),
limit: 1,
select: %{best_duct_band_ghz: h.best_duct_band_ghz, bulk_richardson: h.bulk_richardson}
)
|> Repo.one()
|> case do
nil -> {:error, :not_found}
row -> {:ok, row}
end
end
# ── Reconciliation ──
@doc """
Reconcile `contacts.hrrr_status` for every `:queued` contact:
* flips to `:complete` when every path point has an hrrr_profiles
row at the rounded HRRR hour;
* flips to `:unavailable` when any path point falls outside the
HRRR CONUS grid (pos2 or the great-circle midpoint can drift
into Canada, the mid-Atlantic, or the Pacific even when pos1 is
stateside — the Rust hrrr-point-worker returns no profile for
those points and the contact would otherwise sit `:queued`
forever).
Returns `{:ok, n}` where `n` is the total number of contacts advanced
(completed + oconus).
"""
@spec reconcile_hrrr_statuses() :: {:ok, non_neg_integer()}
def reconcile_hrrr_statuses do
queued =
Contact
|> where([c], c.hrrr_status == :queued and not is_nil(c.pos1))
|> Repo.all()
{oconus, rest} = Enum.split_with(queued, &contact_has_oconus_path_point?/1)
to_complete = Enum.filter(rest, &hrrr_data_fully_present?/1)
_ =
if oconus != [] do
ids = Enum.map(oconus, & &1.id)
Radio.set_enrichment_status!(ids, :hrrr_status, :unavailable)
end
_ =
if to_complete != [] do
ids = Enum.map(to_complete, & &1.id)
Radio.set_enrichment_status!(ids, :hrrr_status, :complete)
end
{:ok, length(oconus) + length(to_complete)}
end
defp contact_has_oconus_path_point?(contact) do
contact
|> Radio.contact_path_points()
|> Enum.any?(fn {lat, lon} ->
not Grid.contains?(%{"lat" => lat, "lon" => lon})
end)
end
# ── Backfill scalars ──
@doc """
Derive `surface_refractivity` / `min_refractivity_gradient` /
`ducting_detected` for every `hrrr_profiles` row where those columns
are NULL but the pressure-level `profile` JSONB is populated.
Required one-off after the Rust hrrr_points worker shipped without
deriving these scalars — existing rows have the raw levels but the
DB scalars are NULL. Idempotent; rows with scalars already set are
skipped by the WHERE clause.
Batches by `limit` rows per pass to keep transactions short on the
Turing Pi 2 Postgres node. Returns the total number of rows advanced.
"""
@spec backfill_hrrr_scalars(keyword()) :: non_neg_integer()
def backfill_hrrr_scalars(opts \\ []) do
batch = Keyword.get(opts, :batch_size, 500)
max_batches = Keyword.get(opts, :max_batches, 1_000)
Enum.reduce_while(1..max_batches, 0, fn _, total ->
case backfill_hrrr_batch(batch) do
0 -> {:halt, total}
n -> {:cont, total + n}
end
end)
end
defp backfill_hrrr_batch(limit) do
rows =
HrrrProfile
|> where([h], is_nil(h.surface_refractivity))
|> limit(^limit)
|> select([h], %{id: h.id, profile: h.profile})
|> Repo.all()
updates =
Enum.flat_map(rows, fn %{id: id, profile: profile} ->
case SoundingParams.derive(profile || []) do
%{} = derived ->
[
%{
id: id,
surface_refractivity: Map.get(derived, :surface_refractivity),
min_refractivity_gradient: Map.get(derived, :min_refractivity_gradient),
ducting_detected: Map.get(derived, :ducting_detected, false),
duct_characteristics: Map.get(derived, :duct_characteristics)
}
]
_ ->
[]
end
end)
now = DateTime.truncate(DateTime.utc_now(), :second)
_ =
if updates != [] do
{values_sql, params} =
updates
|> Enum.with_index()
|> Enum.reduce({"", []}, &build_values_row(&1, &2, now))
Repo.query!(
"UPDATE hrrr_profiles AS h " <>
"SET surface_refractivity = v.surface_refractivity, " <>
"min_refractivity_gradient = v.min_refractivity_gradient, " <>
"ducting_detected = v.ducting_detected, " <>
"duct_characteristics = v.duct_characteristics, " <>
"updated_at = v.updated_at " <>
"FROM (VALUES #{values_sql}) " <>
"AS v(id, surface_refractivity, min_refractivity_gradient, " <>
"ducting_detected, duct_characteristics, updated_at) " <>
"WHERE h.id = v.id",
params
)
end
length(updates)
end
defp build_values_row({u, _idx}, {sql, params}, now) do
base = Enum.count(params)
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
sep = if sql == "", do: "", else: ", "
params =
params ++
[
UUID.dump!(u.id),
u.surface_refractivity,
u.min_refractivity_gradient,
u.ducting_detected,
u.duct_characteristics || [],
now
]
{sql <> sep <> frag, params}
end
# ── Path profiles ──
@doc "Find all atmospheric profiles along a contact's path, from any source."
@spec profiles_along_path(map()) :: [HrrrProfile.t() | NarrProfile.t()]
def profiles_along_path(contact) do
hrrr_path = hrrr_profiles_for_path(contact)
if hrrr_path == [] do
Narr.narr_profiles_for_path(contact)
else
hrrr_path
end
end
end