From b7e19e18ccd14654cfce8a98934e3368327f8bdc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 15 Apr 2026 17:16:18 -0500 Subject: [PATCH] Add Microwaveprop.Weather.NarrClient URL/time helpers First slice of the NARR backfill client: url_for/1, url_for_inventory/1, and snap_to_analysis_hour/1. Pure functions, no network. Validates that NARR analyses only exist at 3-hourly slots (00/03/06/09/12/15/18/21 UTC). The byte-range fetch and cdo-driven point extraction land in follow-up commits per docs/plans/2026-04-15-merra2-historical-backfill.md. --- lib/microwaveprop/weather/narr_client.ex | 69 +++++++++++++++++ .../weather/narr_client_test.exs | 76 +++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 lib/microwaveprop/weather/narr_client.ex create mode 100644 test/microwaveprop/weather/narr_client_test.exs diff --git a/lib/microwaveprop/weather/narr_client.ex b/lib/microwaveprop/weather/narr_client.ex new file mode 100644 index 00000000..7960dec4 --- /dev/null +++ b/lib/microwaveprop/weather/narr_client.ex @@ -0,0 +1,69 @@ +defmodule Microwaveprop.Weather.NarrClient do + @moduledoc """ + Client for NCEP North American Regional Reanalysis (NARR) data served + anonymously from NCEI at `https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/`. + + Replaces the broken ERA5/CDS historical backfill with anonymous NCEI + fetches for pre-HRRR (1979-01-01 → 2014-10-02) contact enrichment. See + `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full + architecture — the filename is a historical artifact from an earlier + MERRA-2 plan. + + This module currently provides only the pure URL/time helpers. The + byte-range `fetch_inventory/1` and `fetch_profile_at/2` pipeline lands + in follow-up tasks of the same plan. + """ + + @base_url "https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/access/3-hourly" + @valid_hours [0, 3, 6, 9, 12, 15, 18, 21] + + @doc """ + Builds the NCEI HTTPS URL for the NARR analysis GRIB1 file at `valid_time`. + + Raises `ArgumentError` if `valid_time` is not on a 3-hourly analysis + boundary — NARR analyses exist only at 00, 03, 06, 09, 12, 15, 18, and + 21 UTC. + """ + @spec url_for(DateTime.t()) :: String.t() + def url_for(valid_time) do + validate_analysis_time!(valid_time) + + yyyymm = Calendar.strftime(valid_time, "%Y%m") + yyyymmdd = Calendar.strftime(valid_time, "%Y%m%d") + hhmm = Calendar.strftime(valid_time, "%H%M") + + "#{@base_url}/#{yyyymm}/#{yyyymmdd}/narr-a_221_#{yyyymmdd}_#{hhmm}_000.grb" + end + + @doc """ + Builds the NCEI HTTPS URL for the NARR analysis inventory (`.inv`) file + at `valid_time`. Same validation rules as `url_for/1`. + """ + @spec url_for_inventory(DateTime.t()) :: String.t() + def url_for_inventory(valid_time) do + valid_time + |> url_for() + |> String.replace_suffix(".grb", ".inv") + end + + @doc """ + Rounds a `DateTime` *down* to the nearest 3-hourly NARR analysis slot. + + NARR analyses are produced at 00, 03, 06, 09, 12, 15, 18, and 21 UTC. + Minutes, seconds, and microseconds are zeroed out; the hour becomes + `(hour div 3) * 3`. + """ + @spec snap_to_analysis_hour(DateTime.t()) :: DateTime.t() + def snap_to_analysis_hour(%DateTime{} = datetime) do + snapped_hour = div(datetime.hour, 3) * 3 + %{datetime | hour: snapped_hour, minute: 0, second: 0, microsecond: {0, 0}} + end + + defp validate_analysis_time!(%DateTime{hour: hour, minute: 0, second: 0} = _vt) when hour in @valid_hours, do: :ok + + defp validate_analysis_time!(vt) do + raise ArgumentError, + "NARR analyses are only produced at 00/03/06/09/12/15/18/21 UTC on the hour, " <> + "got #{inspect(vt)}" + end +end diff --git a/test/microwaveprop/weather/narr_client_test.exs b/test/microwaveprop/weather/narr_client_test.exs new file mode 100644 index 00000000..7a09b775 --- /dev/null +++ b/test/microwaveprop/weather/narr_client_test.exs @@ -0,0 +1,76 @@ +defmodule Microwaveprop.Weather.NarrClientTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Weather.NarrClient + + describe "url_for/1" do + test "builds the NCEI NARR HTTPS URL for a 3-hourly analysis time" do + vt = ~U[2010-06-15 12:00:00Z] + + assert NarrClient.url_for(vt) == + "https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/access/3-hourly/201006/20100615/narr-a_221_20100615_1200_000.grb" + end + + test "uses zero-padded HHMM for off-hour analyses" do + vt = ~U[2010-06-15 03:00:00Z] + + assert NarrClient.url_for(vt) == + "https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/access/3-hourly/201006/20100615/narr-a_221_20100615_0300_000.grb" + end + + test "raises ArgumentError on non-3-hourly hours" do + assert_raise ArgumentError, fn -> + NarrClient.url_for(~U[2010-06-15 13:00:00Z]) + end + end + + test "raises ArgumentError when minutes are non-zero" do + assert_raise ArgumentError, fn -> + NarrClient.url_for(~U[2010-06-15 12:30:00Z]) + end + end + end + + describe "url_for_inventory/1" do + test "swaps .grb for .inv" do + vt = ~U[2010-06-15 12:00:00Z] + + assert NarrClient.url_for_inventory(vt) == + "https://www.ncei.noaa.gov/data/north-american-regional-reanalysis/access/3-hourly/201006/20100615/narr-a_221_20100615_1200_000.inv" + end + end + + describe "snap_to_analysis_hour/1" do + test "rounds down to the nearest 3-hourly NARR slot" do + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 13:42:00Z]) == + ~U[2010-06-15 12:00:00Z] + + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 14:30:00Z]) == + ~U[2010-06-15 12:00:00Z] + + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 15:00:00Z]) == + ~U[2010-06-15 15:00:00Z] + + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 23:59:59Z]) == + ~U[2010-06-15 21:00:00Z] + + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 00:00:00Z]) == + ~U[2010-06-15 00:00:00Z] + + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 02:00:00Z]) == + ~U[2010-06-15 00:00:00Z] + end + + test "preserves the date when snapping doesn't cross midnight" do + assert NarrClient.snap_to_analysis_hour(~U[2010-06-15 05:17:42Z]) == + ~U[2010-06-15 03:00:00Z] + end + + test "snaps minutes and seconds to zero" do + snapped = NarrClient.snap_to_analysis_hour(~U[2010-06-15 13:42:37Z]) + assert snapped.minute == 0 + assert snapped.second == 0 + assert snapped.microsecond == {0, 0} + end + end +end