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.
This commit is contained in:
parent
1faeb60e3c
commit
b7e19e18cc
2 changed files with 145 additions and 0 deletions
69
lib/microwaveprop/weather/narr_client.ex
Normal file
69
lib/microwaveprop/weather/narr_client.ex
Normal file
|
|
@ -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
|
||||
76
test/microwaveprop/weather/narr_client_test.exs
Normal file
76
test/microwaveprop/weather/narr_client_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue