defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do @moduledoc false use Oban.Pro.Worker, queue: :enqueue, max_attempts: 3 alias Microwaveprop.Propagation.Grid alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrPointEnqueuer alias Microwaveprop.Weather.NarrClient alias Microwaveprop.Weather.NexradClient alias Microwaveprop.Workers.IemreFetchWorker alias Microwaveprop.Workers.MechanismClassifyWorker alias Microwaveprop.Workers.NarrFetchWorker alias Microwaveprop.Workers.RadarFrameWorker alias Microwaveprop.Workers.TerrainProfileWorker alias Microwaveprop.Workers.WeatherFetchWorker require Logger @asos_radius_km 150 @sounding_radius_km 300 # Oban jobs have ~149 params each; PG limit is 65535 params per query @insert_batch_size 400 @doc """ Enqueue all enrichment jobs (weather, HRRR, terrain, IEMRE) for a single contact. Called directly from submission flow — no Oban indirection. Optionally pass a list of types to limit which enrichments run. """ @spec enqueue_for_contact(Contact.t(), [atom()]) :: :ok def enqueue_for_contact(contact, types \\ [:hrrr, :weather, :terrain, :iemre, :radar, :mechanism]) do contact = Radio.ensure_positions!(contact) # BackfillEnqueueWorker selects any contact with at least one # *_status in [:pending, :queued, :failed] and calls this with all # types. Filter out types that are already :complete for this # contact so mark_status!/3 doesn't demote them back to :queued on # every cron tick — that caused tens of thousands of # mechanism/terrain rows to ping-pong between :complete and :queued. types = Enum.reject(types, &already_complete?(contact, &1)) jobs_by_type = build_jobs_by_type(contact, types) bulk_jobs = jobs_by_type[:weather] ++ jobs_by_type[:terrain] ++ jobs_by_type[:iemre] ++ jobs_by_type[:radar] ++ jobs_by_type[:mechanism] if bulk_jobs != [] do insert_all_chunked(bulk_jobs) end # HRRR enrichment runs via the Rust hrrr-point-worker via the # hrrr_fetch_tasks table (Phase 3 Stream C). Grouping by valid_time # and UPSERT-union means a backfill tick that adds a new contact in # an already-scheduled hour collapses into the existing row instead # of creating a duplicate fetch. _ = if :hrrr in types do {:ok, _} = HrrrPointEnqueuer.enqueue_for_contacts([contact]) end # NARR jobs must go through Oban.insert/1 so the worker's unique # constraint is honored; Oban OSS insert_all does not check uniqueness. insert_unique(jobs_by_type[:narr]) _ = mark_enrichment_statuses(contact, types, jobs_by_type) :ok end defp build_jobs_by_type(contact, types) do %{ weather: if(:weather in types, do: build_weather_jobs([contact]), else: []), # hrrr jobs are routed to hrrr_fetch_tasks (not Oban) but the # status-marking path still wants to know whether any HRRR work # was created for this contact, so we mirror the list-shape. hrrr: if(:hrrr in types, do: hrrr_placeholder_jobs([contact]), else: []), terrain: if(:terrain in types, do: build_terrain_jobs([contact]), else: []), iemre: if(:iemre in types, do: build_iemre_jobs([contact]), else: []), narr: if(:narr in types, do: build_narr_jobs([contact]), else: []), radar: if(:radar in types, do: build_radar_jobs([contact]), else: []), mechanism: if(:mechanism in types, do: build_mechanism_jobs([contact]), else: []) } end # Placeholder for the HRRR job-list signal. Returns a truthy singleton # if the contact has at least one HRRR-eligible path point so # mark_hrrr_status!/3 flips hrrr_queued on the contact row. Actual # scheduling happens via HrrrPointEnqueuer.enqueue_for_contacts/1 # above. Returns [] for pre-coverage contacts (NARR's territory) so # the status stays :unavailable, and [] for contacts whose HRRR # profiles are already fully present in the DB so mark_hrrr_status!/3 # flips them directly to :complete — otherwise backfill re-flags the # contact as :queued every cron cycle forever (HrrrPointEnqueuer # sees 0 missing points, inserts nothing, but the status stays # :queued and the contact is picked up again next tick). defp hrrr_placeholder_jobs(contacts) do Enum.flat_map(contacts, fn contact -> cond do is_nil(contact.pos1) -> [] not Grid.contains?(contact.pos1) -> [] NarrClient.in_coverage?(contact.qso_timestamp) -> [] Weather.hrrr_data_fully_present?(contact) -> [] true -> [contact.id] end end) end defp mark_enrichment_statuses(contact, types, jobs_by_type) do ids = [contact.id] _ = if contact.pos1 do mark_pos1_statuses(contact, ids, types, jobs_by_type) end if contact.pos1 && contact.pos2 do mark_path_statuses(contact, ids, types, jobs_by_type) else # Path enrichments (terrain/radar/mechanism) need both endpoints. # Marking :unavailable stops BackfillEnqueueWorker from re-scanning # these contacts every cron cycle. Radio.reset_enrichment_statuses/2 # will flip them back to :pending if pos2 is added later. mark_missing_path_statuses(ids, types) end end defp mark_missing_path_statuses(ids, types) do _ = if :terrain in types, do: Radio.set_enrichment_status!(ids, :terrain_status, :unavailable) _ = if :radar in types, do: Radio.set_enrichment_status!(ids, :radar_status, :unavailable) if :mechanism in types, do: Radio.set_enrichment_status!(ids, :mechanism_status, :unavailable) end defp mark_pos1_statuses(contact, ids, types, jobs_by_type) do _ = if :weather in types, do: mark_status!(ids, :weather_status, jobs_by_type[:weather]) _ = if :hrrr in types, do: mark_hrrr_status!(contact, ids, jobs_by_type[:hrrr]) if :iemre in types, do: mark_status!(ids, :iemre_status, jobs_by_type[:iemre]) end defp mark_path_statuses(contact, ids, types, jobs_by_type) do _ = if :terrain in types, do: mark_status!(ids, :terrain_status, jobs_by_type[:terrain]) _ = if :radar in types, do: mark_radar_status!(contact, ids, jobs_by_type[:radar]) if :mechanism in types, do: mark_status!(ids, :mechanism_status, jobs_by_type[:mechanism]) end # IEM n0q composite reflectivity archive only has meaningful CONUS # coverage post-2014 (pre that, the archive is spotty mosaics). For # pre-2014 contacts we can't get radar, so pin to :unavailable rather # than leaving it :pending forever. defp mark_radar_status!(contact, ids, []) do if NarrClient.in_coverage?(contact.qso_timestamp) do Radio.set_enrichment_status!(ids, :radar_status, :unavailable) else Radio.set_enrichment_status!(ids, :radar_status, :queued) end end defp mark_radar_status!(_contact, ids, [_ | _]), do: Radio.set_enrichment_status!(ids, :radar_status, :queued) # Empty-job-list HRRR path has three terminal states: # # 1. pos1 outside the CONUS grid → :unavailable. hrrr_point_rs silently # returns zero profiles for OCONUS points and the backfill cron # would otherwise re-flag :queued every tick forever. # 2. pre-2014 contacts (NarrClient.in_coverage?) → :unavailable so the # :narr filter in BackfillEnqueueWorker picks them up. # 3. modern CONUS contact whose profiles already exist → :complete. defp mark_hrrr_status!(contact, ids, []) do cond do not Grid.contains?(contact.pos1) -> Radio.set_enrichment_status!(ids, :hrrr_status, :unavailable) NarrClient.in_coverage?(contact.qso_timestamp) -> Radio.set_enrichment_status!(ids, :hrrr_status, :unavailable) true -> Radio.set_enrichment_status!(ids, :hrrr_status, :complete) end end defp mark_hrrr_status!(_contact, ids, [_ | _]), do: Radio.set_enrichment_status!(ids, :hrrr_status, :queued) @impl Oban.Pro.Worker def process(%Oban.Job{}) do enqueue_weather_jobs() enqueue_hrrr_jobs() enqueue_terrain_jobs() enqueue_iemre_jobs() # After enqueuing, sweep any :queued contacts whose data has # already landed (from a prior run's ingestion) but whose # _status was never flipped. Without these, contacts stayed # visible to the backfill scan forever. Enum.each( [ {"weather", &Weather.reconcile_weather_statuses/0}, {"iemre", &Weather.reconcile_iemre_statuses/0}, {"hrrr", &Weather.reconcile_hrrr_statuses/0} ], fn {label, fun} -> case fun.() do {:ok, n} when n > 0 -> Logger.info("ContactWeatherEnqueueWorker: reconciled #{n} #{label}_status → :complete") _ -> :ok end end ) :ok end defp enqueue_weather_jobs do case Radio.unprocessed_contacts() do [] -> :ok contacts -> _ = Radio.backfill_distances(contacts) jobs = build_weather_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end ids = Enum.map(contacts, & &1.id) _ = Radio.mark_weather_queued!(ids) enqueue_weather_jobs() end end defp enqueue_hrrr_jobs do case Radio.unprocessed_hrrr_contacts() do [] -> :ok contacts -> # Rust Stream C path: route into hrrr_fetch_tasks instead of # Oban. The enqueuer groups by valid_time and UPSERTs so a # backfill cron sweep that rediscovers the same contacts # simply refreshes the existing hourly row. {:ok, _} = HrrrPointEnqueuer.enqueue_for_contacts(contacts) ids = Enum.map(contacts, & &1.id) _ = Radio.mark_hrrr_queued!(ids) enqueue_hrrr_jobs() end end defp enqueue_terrain_jobs do case Radio.unprocessed_terrain_contacts() do [] -> :ok contacts -> jobs = build_terrain_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end ids = Enum.map(contacts, & &1.id) _ = Radio.mark_terrain_queued!(ids) enqueue_terrain_jobs() end end @spec build_terrain_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_terrain_jobs(contacts) do contacts |> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end) |> Enum.map(fn contact -> TerrainProfileWorker.new(%{"contact_id" => contact.id}) end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end @spec build_radar_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_radar_jobs(contacts) do # Group contacts by their 5-min NEXRAD frame so the worker can # fetch+decode the ~5 MB PNG once per frame and iterate every # contact in that frame against the in-memory pixel buffer. # Historical backlog averages ~10 contacts per distinct frame, # collapsing the per-job cost by roughly that factor. contacts |> Enum.filter(fn contact -> contact.pos1 && contact.pos2 && not NarrClient.in_coverage?(contact.qso_timestamp) end) |> Enum.group_by(fn contact -> contact.qso_timestamp |> DateTime.from_naive!("Etc/UTC") |> NexradClient.round_to_5min() end) |> Enum.map(fn {frame_ts, batch} -> RadarFrameWorker.new(%{ "frame_ts" => DateTime.to_iso8601(frame_ts), "contact_ids" => Enum.map(batch, & &1.id) }) end) end @spec build_mechanism_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_mechanism_jobs(contacts) do contacts |> Enum.filter(fn contact -> contact.pos1 && contact.pos2 end) |> Enum.map(fn contact -> MechanismClassifyWorker.new(%{"contact_id" => contact.id}) end) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end @spec build_weather_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_weather_jobs(contacts) do contacts |> Enum.flat_map(&jobs_for_contact/1) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end @spec build_iemre_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_iemre_jobs(contacts) do contacts |> Enum.flat_map(&iemre_job_for_contact/1) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end @spec build_narr_jobs([Contact.t()]) :: [Ecto.Changeset.t()] def build_narr_jobs(contacts) do contacts |> Enum.flat_map(&narr_jobs_for_contact/1) |> Enum.uniq_by(fn changeset -> changeset.changes.args end) end defp narr_jobs_for_contact(%{pos1: nil}), do: [] defp narr_jobs_for_contact(contact) do # NARR analyses are 3-hourly (00/03/06/09/12/15/18/21 UTC). NarrFetchWorker # rejects anything else, so snap here in the caller. valid_time = NarrClient.snap_to_analysis_hour(contact.qso_timestamp) # NCEI's NARR archive covers 1979-01-01 → 2014-10-02. Post-coverage contacts # (hrrr_status = :unavailable but qso_timestamp >= 2014-10-02) are HRRR's # responsibility — NARR would just 404. if NarrClient.in_coverage?(valid_time) do narr_jobs_for_path(contact, valid_time) else [] end end defp narr_jobs_for_path(contact, valid_time) do contact |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> rlat = Float.round(lat * 4) / 4 rlon = Float.round(lon * 4) / 4 if Weather.find_nearest_narr(rlat, rlon, valid_time) do [] else [ NarrFetchWorker.new(%{ "lat" => rlat, "lon" => rlon, "valid_time" => DateTime.to_iso8601(valid_time) }) ] end end) end defp jobs_for_contact(contact) do contact |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> build_asos_jobs(lat, lon, contact.qso_timestamp) ++ build_raob_jobs(lat, lon, contact.qso_timestamp) end) end defp enqueue_iemre_jobs do case Radio.unprocessed_iemre_contacts() do [] -> :ok contacts -> jobs = build_iemre_jobs(contacts) if jobs != [] do insert_all_chunked(jobs) end ids = Enum.map(contacts, & &1.id) _ = Radio.mark_iemre_queued!(ids) enqueue_iemre_jobs() end end defp build_asos_jobs(lat, lon, timestamp) do start_dt = DateTime.add(timestamp, -2 * 3600, :second) end_dt = DateTime.add(timestamp, 2 * 3600, :second) stations = Weather.nearby_stations(lat, lon, "asos", @asos_radius_km) # A ±2h window around the QSO can straddle UTC midnight, so enumerate # every date the window touches. Each (station, date) becomes one # asos_day job — Oban's unique-args dedup collapses cross-QSO # repeats (contact A's KDFW/2021-08-21 and contact B's KDFW/2021-08-21 # share args → one job). A full-day fetch returns ~24 hourly rows, # amortizing the IemRateLimiter gap over 24× the previous payload. dates = window_dates(start_dt, end_dt) pairs = for station <- stations, date <- dates do {station, date} end covered = pairs |> Enum.map(fn {s, d} -> {s.id, d} end) |> Weather.station_day_pairs_covered() pairs |> Enum.reject(fn {s, d} -> MapSet.member?(covered, {s.id, d}) end) |> Enum.map(fn {station, date} -> WeatherFetchWorker.new(%{ "fetch_type" => "asos_day", "station_id" => station.id, "station_code" => station.station_code, "date" => Date.to_iso8601(date) }) end) end defp window_dates(start_dt, end_dt) do start_date = DateTime.to_date(start_dt) end_date = DateTime.to_date(end_dt) if Date.compare(start_date, end_date) == :eq do [start_date] else [start_date, end_date] end end defp iemre_job_for_contact(%{pos1: nil}), do: [] defp iemre_job_for_contact(contact) do date = DateTime.to_date(contact.qso_timestamp) contact |> Radio.contact_path_points() |> Enum.flat_map(fn {lat, lon} -> {rlat, rlon} = Weather.round_to_iemre_grid(lat, lon) if Weather.has_iemre_observation?(rlat, rlon, date) do [] else [ IemreFetchWorker.new(%{ "lat" => rlat, "lon" => rlon, "date" => Date.to_iso8601(date) }) ] end end) end defp build_raob_jobs(lat, lon, timestamp) do build_raob_jobs(lat, lon, timestamp, @sounding_radius_km, nil) end defp build_raob_jobs(lat, lon, timestamp, radius_km, contact_id) do sounding_times = Weather.sounding_times_around(timestamp) stations = Weather.nearby_stations(lat, lon, "sounding", radius_km) station_ids = Enum.map(stations, & &1.id) covered = Weather.station_ids_with_soundings(station_ids, sounding_times) for station <- stations, sounding_time <- sounding_times, not MapSet.member?(covered, {station.id, sounding_time}) do args = Map.merge( %{ "fetch_type" => "raob", "station_id" => station.id, "station_code" => station.station_code, "sounding_time" => DateTime.to_iso8601(sounding_time) }, if(contact_id, do: %{"contact_id" => contact_id}, else: %{}) ) WeatherFetchWorker.new(args) end end @doc """ Enqueue RAOB fetch jobs for the given contact at a progressively wider radius until the radius yields at least one sounding station. Passes the contact id through so `WeatherFetchWorker` can PubSub the contact detail page when each sounding lands. Returns `{:ok, %{radius_km, jobs_enqueued}}`, or `{:error, :no_stations}` when even 1000 km finds nothing. """ @spec enqueue_raob_fetch_with_widening(Contact.t()) :: {:ok, %{radius_km: pos_integer(), jobs_enqueued: non_neg_integer()}} | {:error, :no_stations} | {:error, :no_coords} def enqueue_raob_fetch_with_widening(contact) do case Radio.contact_path_points(contact) do [] -> {:error, :no_coords} [{lat, lon} | _] -> do_widening_raob(lat, lon, contact) end end defp do_widening_raob(lat, lon, contact) do Enum.reduce_while([150, 300, 600, 1000], {:error, :no_stations}, fn radius, _acc -> stations = Weather.nearby_stations(lat, lon, "sounding", radius) if stations == [] do {:cont, {:error, :no_stations}} else {:halt, enqueue_raob_at_radius(lat, lon, contact, radius)} end end) end defp enqueue_raob_at_radius(lat, lon, contact, radius) do jobs = build_raob_jobs(lat, lon, contact.qso_timestamp, radius, contact.id) if jobs != [], do: insert_all_chunked(jobs) {:ok, %{radius_km: radius, jobs_enqueued: length(jobs)}} end defp insert_all_chunked(jobs) do jobs |> Enum.chunk_every(@insert_batch_size) |> Enum.each(&Oban.insert_all/1) end defp insert_unique(jobs) do Enum.each(jobs, &Oban.insert/1) end defp mark_status!(ids, field, [_ | _]), do: Radio.set_enrichment_status!(ids, field, :queued) defp mark_status!(ids, field, []), do: Radio.set_enrichment_status!(ids, field, :complete) # :narr is a virtual type synthesized from hrrr_status = :unavailable; # it has no column of its own, so there's nothing to "already be # complete." defp already_complete?(_contact, :narr), do: false defp already_complete?(contact, type) do Map.get(contact, String.to_existing_atom("#{type}_status")) == :complete end end