prop/lib/microwaveprop/workers/era5_month_batch_worker.ex
Graham McIntire fc3eb58910
Refresh propagation algo against expanded prod corpus (2026-04-13)
Direct queries against prod (75M HRRR profiles, 16,864 soundings, 392k
surface obs, new hrrr_native_profiles table) drive these changes:

* Reinstate shallow-BL bonus as an HPBL multiplier on the refractivity
  score (1.10× <200m → 0.78× ≥2000m). The Apr 11 "shallow BL bonus
  removed" conclusion was an artifact of the prior matching strategy;
  with the cleaner contact↔HRRR join (n=680) the binned data goes 230 km
  avg at HPBL <200m vs 100 km at ≥2000m, monotonic across all bins.
* Add 6 missing bands (142, 145, 288, 322, 403, 411 GHz) so contacts in
  those bands stop being silently dropped. Coefficients extrapolate
  ITU-R P.676/P.838 trends from the existing 134/241 GHz entries.
* Bump ERA5 poll timeout 10 min → 1 hour and Era5MonthBatchWorker
  max_attempts 3 → 5 so CDS slowness stops discarding tiles. Also dump
  the full failure body when CDS omits the message field — the prior
  "ERA5 job failed: nil" was masking real error reasons in oban_jobs.
* Add scripts/recalibrate_algo.py so this analysis can be re-run any
  time new data lands without manual SQL. Reads PROP_PROD_DB_URL from
  .envrc, drops a Markdown report into docs/algo-reports/.
* Append a dated Part 2c to algo.md documenting the corpus expansion,
  the honest accounting of historical HRRR coverage (only ~1,020 of 58k
  contacts are precision-matchable), and the empty era5/rtma/climatology
  tables. Update the gaseous-absorption and rain-attenuation tables to
  include the new bands.

Test suite: 1,335 tests, 0 failures.
2026-04-13 10:59:01 -05:00

72 lines
2.4 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.Workers.Era5MonthBatchWorker do
@moduledoc """
Fetches one month of ERA5 data for a 2° × 2° tile in a single CDS request
and bulk-inserts all resulting hourly profiles into `era5_profiles`.
This exists because fetching ERA5 per point-hour is tragically slow: each
CDS request goes through a full submit → poll → assemble → download cycle
whose overhead dwarfs the data transfer. Grouping by month+tile turns
thousands of CDS jobs into a handful, and once a tile-month is cached in
the DB every downstream query for a point inside that window is a local
lookup.
Uniqueness on the full arg set means Oban collapses duplicate enqueues
for the same tile-month — multiple `Era5FetchWorker` requests for the
same region collapse into a single batch.
Runs on its own `:era5_batch` queue so its slow CDS poll cycles don't
starve the fast `Era5FetchWorker` router that also lives in the ERA5
namespace.
"""
# The :era5_batch queue is rate-limited to 10/hour so a single failed run
# eats a meaningful chunk of throughput. Retrying 5×, combined with the
# generous backoff below, gives CDS up to a day to come back without
# discarding the tile and silently dropping the historical contact.
use Oban.Worker,
queue: :era5_batch,
max_attempts: 5,
unique: [
period: :infinity,
states: [:available, :scheduled, :executing, :retryable],
keys: [:year, :month, :tile_lat, :tile_lon]
]
alias Microwaveprop.Weather.Era5BatchClient
require Logger
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
# A full month fetch can sit in the CDS queue for ages; back off generously.
min(600 * Integer.pow(2, attempt - 1), _one_day = 86_400)
end
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
params = %{
year: fetch_int!(args, "year"),
month: fetch_int!(args, "month"),
tile_lat: fetch_int!(args, "tile_lat"),
tile_lon: fetch_int!(args, "tile_lon")
}
case Era5BatchClient.fetch_month_into_db(params) do
{:ok, _count} ->
:ok
{:error, reason} ->
Logger.warning(
"ERA5Batch: #{params.year}-#{params.month} tile #{params.tile_lat},#{params.tile_lon} failed: #{inspect(reason)}"
)
{:error, reason}
end
end
defp fetch_int!(args, key) do
case Map.fetch!(args, key) do
n when is_integer(n) -> n
n when is_binary(n) -> String.to_integer(n)
end
end
end