defmodule Microwaveprop.Workers.NarrFetchWorker do @moduledoc """ Fetches one NCEP NARR profile from NCEI via `NarrClient.fetch_profile_at/2` and inserts it into the `era5_profiles` table. Replaces `Era5FetchWorker` for pre-2014 contacts. Unlike the ERA5 router, NARR fetches are per-point (one analysis hour, one location), so this worker does the fetch + insert directly — no downstream batch worker, no routing. Jobs are unique on `{lat, lon, valid_time}` so duplicate enrichment requests collapse. `valid_time` MUST already be snapped to a NARR 3-hourly analysis slot (00/03/06/09/12/15/18/21 UTC on the hour) by the caller — the worker raises `ArgumentError` otherwise via `NarrClient.url_for/1`. See `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full architecture. The `era5_profiles` table is reused 1:1 (rename is a follow-up). """ use Oban.Worker, queue: :narr, max_attempts: 3, unique: [ period: :infinity, states: [:available, :scheduled, :executing, :retryable] ] import Ecto.Query alias Microwaveprop.Repo alias Microwaveprop.Weather.Era5Profile alias Microwaveprop.Weather.NarrClient require Logger @impl Oban.Worker def perform(%Oban.Job{args: %{"lat" => lat, "lon" => lon, "valid_time" => valid_time_str}}) do {:ok, valid_time, _} = DateTime.from_iso8601(valid_time_str) rlat = Float.round(lat * 4) / 4 rlon = Float.round(lon * 4) / 4 if has_era5_profile?(rlat, rlon, valid_time) do :ok else fetch_and_insert(rlat, rlon, valid_time) end end defp fetch_and_insert(rlat, rlon, valid_time) do case NarrClient.fetch_profile_at(valid_time, {rlat, rlon}) do {:ok, attrs} -> insert_profile(attrs, rlat, rlon, valid_time) :ok {:error, reason} = error -> Logger.warning( "NARR: fetch_profile_at failed for #{rlat},#{rlon} @ #{DateTime.to_iso8601(valid_time)}: #{inspect(reason)}" ) error end end defp insert_profile(attrs, rlat, rlon, valid_time) do now = DateTime.truncate(DateTime.utc_now(), :second) row = attrs |> Map.put(:lat, rlat) |> Map.put(:lon, rlon) |> Map.put(:valid_time, valid_time) |> Map.put(:inserted_at, now) |> Map.put(:updated_at, now) Repo.insert_all(Era5Profile, [row], on_conflict: :nothing, conflict_target: [:lat, :lon, :valid_time] ) end defp has_era5_profile?(lat, lon, valid_time) do # NARR is exact 3-hourly, so we tighten the time window to ±15 minutes # compared to Era5FetchWorker's ±30-minute check. Lat/lon tolerance # matches the 0.25° dedup granularity. dlat = 0.13 dlon = 0.13 time_start = DateTime.add(valid_time, -900, :second) time_end = DateTime.add(valid_time, 900, :second) Era5Profile |> 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 ) |> Repo.exists?() end end