prop/lib/microwaveprop/weather/narr_client.ex
Graham McIntire b7e19e18cc
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.
2026-04-15 18:54:02 -05:00

69 lines
2.6 KiB
Elixir

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