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