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.add(timestamp, -3600, :second) time_end = DateTime.add(timestamp, 3600, :second) 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