prop/lib/microwaveprop/weather/narr_client.ex
Graham McIntire e5528d0135
NarrClient.extract_profile_from_file/3 + fetch_profile_at/2
extract_profile_from_file shells out to cdo:

    cdo -outputtab,name,lev,value -remapnn,lon=X_lat=Y <merged.grb>

parses the text output (varNNN ↔ NCEP GRIB1 param number lookup),
maps surface records to era5_profiles fields, builds the pressure
profile from each (TMP, HGT, SPFH) triple, and merges in the derived
fields from SoundingParams.derive/1. Returns the same attrs shape as
Era5BatchClient.build_profile_attrs/5 so the existing
bulk_insert_profiles path can ingest NARR rows unchanged.

fetch_profile_at picks records from a fetched inventory, byte-range
fetches each one into a per-record temp file, runs cdo -merge to build
a composite, calls extract_profile_from_file, and cleans up via
try/after.

Drive-by fix: ThetaE.dewpoint_from_spfh/2 returns Kelvin (its existing
test asserts that explicitly). Era5BatchClient was passing that
Kelvin value straight into "dwpc" (Celsius), which would then crash
SoundingParams.compute_refractivity_profile when it called Buck's
saturation-vapor-pressure equation with t=294 instead of t=21. The
bug never surfaced because era5_profiles is empty in production —
no ERA5 backfill has ever succeeded. Fixed in both era5_batch_client
and the new narr_client by subtracting 273.15 in the wrapper. Both
wrappers now have a comment pointing at the K→C conversion.

Test fixture test/fixtures/narr/narr_dfw_2010-06-15_12z.grb is the
spike-captured composite (1.1 MB — Lambert conformal projection
isn't sellonlatbox-subsettable so we can't shrink it further).
2026-04-15 18:54:02 -05:00

457 lines
15 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.
Provides URL/time helpers, the inventory parser, the inventory HTTP
fetch, the cdo-based composite-file profile extractor, and the
end-to-end byte-range `fetch_profile_at/2` pipeline.
"""
alias Microwaveprop.Weather.SoundingParams
alias Microwaveprop.Weather.ThetaE
@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]
# Surface records we need from the inventory. Tuples of {var, level} keyed
# exactly as `parse_inventory/2` returns them. Pressure-level records are
# added dynamically below from whatever (TMP|HGT|SPFH):"NN mb" tuples the
# parsed inventory contains.
@surface_records [
{"HPBL", "sfc"},
{"PRES", "sfc"},
{"TMP", "2 m above gnd"},
{"DPT", "2 m above gnd"},
{"PWAT", "atmos col"}
]
# cdo emits NCEP GRIB1 parameters as `varNNN`. This table maps the cdo
# parameter token to the semantic NCEP short name, verified empirically
# in the spike (see plan doc cdo lookup table).
@cdo_var_to_name %{
"var1" => "PRES",
"var7" => "HGT",
"var11" => "TMP",
"var17" => "DPT",
"var33" => "UGRD",
"var34" => "VGRD",
"var51" => "SPFH",
"var54" => "PWAT",
"var221" => "HPBL"
}
@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
@doc """
Parses the raw text of a NARR `.inv` file into a map keyed by
`{var, level}` with values of `{byte_offset, length}`.
Each record's length is computed as `next_offset - this_offset`. The
final record's length uses `file_size` as its "next offset".
Blank lines and trailing whitespace are ignored. `var` and `level` are
returned as-is (no trimming) so levels like `"2 m above gnd"` round-trip
unchanged.
"""
@spec parse_inventory(String.t(), non_neg_integer()) ::
{:ok, %{{String.t(), String.t()} => {non_neg_integer(), non_neg_integer()}}}
def parse_inventory(inv_text, file_size) when is_binary(inv_text) and is_integer(file_size) do
records =
inv_text
|> String.split("\n", trim: true)
|> Enum.map(&parse_inventory_line/1)
|> Enum.reject(&is_nil/1)
index =
records
|> Enum.zip(Enum.drop(records, 1) ++ [{nil, file_size, nil, nil}])
|> Map.new(fn {{_num, offset, var, level}, {_next_num, next_offset, _next_var, _next_level}} ->
{{var, level}, {offset, next_offset - offset}}
end)
{:ok, index}
end
@doc """
Fetches the `.inv` text and the `.grb` `Content-Length` for `valid_time`,
then delegates to `parse_inventory/2`.
Does one `GET` against the inventory URL and one `HEAD` against the GRIB
URL. Returns `{:ok, index}` on success; `{:error, reason}` if either HTTP
call is non-200, transport-errored, or missing a `content-length` header.
"""
@spec fetch_inventory(DateTime.t()) ::
{:ok, %{{String.t(), String.t()} => {non_neg_integer(), non_neg_integer()}}}
| {:error, term()}
def fetch_inventory(%DateTime{} = valid_time) do
inv_url = url_for_inventory(valid_time)
grb_url = url_for(valid_time)
with {:ok, inv_body} <- do_get_inventory(inv_url),
{:ok, file_size} <- do_head_grib_size(grb_url) do
parse_inventory(inv_body, file_size)
end
end
@doc """
Extracts an `era5_profiles`-shaped attribute map from a composite NARR
GRIB1 file at the given lat/lon point.
Pure-ish: shells out to `cdo -outputtab,name,lev,value -remapnn,lon=…_lat=…`
on the local file. No network.
Returns `{:ok, attrs}` where `attrs` has the same shape
`Era5BatchClient.build_profile_attrs/5` produces, or `{:error, reason}`
on missing file, cdo failure, or empty parser output.
"""
@spec extract_profile_from_file(Path.t(), float(), float()) ::
{:ok, map()} | {:error, term()}
def extract_profile_from_file(grb_path, lat, lon) when is_binary(grb_path) and is_number(lat) and is_number(lon) do
if File.exists?(grb_path) do
run_cdo_outputtab(grb_path, lat, lon)
else
{:error, "NARR cdo extract: file not found: #{grb_path}"}
end
end
@doc """
Fetches all the NARR records we need for `valid_time` via byte-range
`Range:` GETs against the NCEI grib file, merges them into a composite
GRIB1 with `cdo -merge`, then extracts the profile at `{lat, lon}`
using `extract_profile_from_file/3`.
Returns `{:ok, profile_attrs}` shaped like `Era5BatchClient.build_profile_attrs/5`
or `{:error, reason}`. Temp files are always cleaned up via `try/after`.
"""
@spec fetch_profile_at(DateTime.t(), {float(), float()}) ::
{:ok, map()} | {:error, term()}
def fetch_profile_at(%DateTime{} = valid_time, {lat, lon}) when is_number(lat) and is_number(lon) do
grb_url = url_for(valid_time)
with {:ok, index} <- fetch_inventory(valid_time),
{:ok, record_keys} <- pick_records(index) do
do_fetch_profile(grb_url, index, record_keys, lat, lon)
end
end
defp do_fetch_profile(grb_url, index, record_keys, lat, lon) do
tag = "narr_#{System.unique_integer([:positive])}"
tmp_dir = System.tmp_dir!()
merged_path = Path.join(tmp_dir, "#{tag}_merged.grb")
record_paths =
Enum.with_index(record_keys, fn key, i ->
{key, Path.join(tmp_dir, "#{tag}_#{i}.grb")}
end)
try do
with :ok <- fetch_records(grb_url, index, record_paths),
:ok <- merge_records(record_paths, merged_path) do
extract_profile_from_file(merged_path, lat, lon)
end
after
Enum.each(record_paths, fn {_key, path} -> File.rm(path) end)
File.rm(merged_path)
end
end
# Builds the list of {var, level} keys we want to pull from the inventory:
# the fixed surface set plus every (TMP|HGT|SPFH) at "NN mb" present in
# the index. Returns `{:error, _}` if any of the required surface records
# are missing from the inventory.
defp pick_records(index) do
missing_surface = Enum.reject(@surface_records, &Map.has_key?(index, &1))
if missing_surface == [] do
pressure_keys =
index
|> Map.keys()
|> Enum.filter(fn
{var, level} when var in ["TMP", "HGT", "SPFH"] ->
String.ends_with?(level, " mb")
_ ->
false
end)
{:ok, @surface_records ++ pressure_keys}
else
{:error, "NARR inventory missing required surface records: #{inspect(missing_surface)}"}
end
end
defp fetch_records(grb_url, index, record_paths) do
Enum.reduce_while(record_paths, :ok, fn {key, path}, _acc ->
{offset, length} = Map.fetch!(index, key)
case fetch_byte_range(grb_url, offset, length, path) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp fetch_byte_range(url, offset, length, dest_path) do
range_header = "bytes=#{offset}-#{offset + length - 1}"
case Req.get(url, [headers: [{"range", range_header}], receive_timeout: 60_000] ++ req_options()) do
{:ok, %{status: status, body: body}} when status in [200, 206] and is_binary(body) ->
File.write(dest_path, body)
{:ok, %{status: status}} ->
{:error, "NARR byte-range HTTP #{status} for #{range_header}"}
{:error, reason} ->
{:error, "NARR byte-range request failed: #{inspect(reason)}"}
end
end
defp merge_records(record_paths, merged_path) do
inputs = Enum.map(record_paths, fn {_key, path} -> path end)
args = ["-merge"] ++ inputs ++ [merged_path]
case System.cmd("cdo", args, stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, code} -> {:error, "cdo -merge exited #{code}: #{output}"}
end
end
defp run_cdo_outputtab(grb_path, lat, lon) do
args = [
"-outputtab,name,lev,value",
"-remapnn,lon=#{lon}_lat=#{lat}",
grb_path
]
case System.cmd("cdo", args, stderr_to_stdout: false) do
{stdout, 0} ->
parse_cdo_outputtab(stdout)
{output, code} ->
{:error, "cdo extract exited #{code}: #{output}"}
end
end
defp parse_cdo_outputtab(stdout) do
raw =
stdout
|> String.split("\n", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.filter(&String.starts_with?(&1, "var"))
|> Enum.reduce(%{}, &accumulate_cdo_row/2)
if raw == %{} do
{:error, "NARR cdo extract: no parseable values in cdo output"}
else
{:ok, build_profile_attrs(raw)}
end
end
defp accumulate_cdo_row(line, acc) do
case parse_cdo_row(line) do
{key, value} -> Map.put(acc, key, value)
:error -> acc
end
end
defp parse_cdo_row(line) do
with [var_token, lev_str, value_str] <- String.split(line, ~r/\s+/, trim: true),
{:ok, name} <- Map.fetch(@cdo_var_to_name, var_token),
{lev_int, ""} <- Integer.parse(lev_str),
{value_float, ""} <- Float.parse(value_str) do
{{name, lev_int}, value_float}
else
_ -> :error
end
end
defp build_profile_attrs(raw) do
profile = build_pressure_profile(raw)
base = %{
profile: profile,
surface_temp_c: kelvin_to_celsius(raw[{"TMP", 2}]),
surface_dewpoint_c: kelvin_to_celsius(raw[{"DPT", 2}]),
surface_pressure_mb: pa_to_mb(raw[{"PRES", 0}]),
hpbl_m: raw[{"HPBL", 0}],
pwat_mm: raw[{"PWAT", 0}]
}
params = SoundingParams.derive(profile)
if params do
Map.merge(base, %{
surface_refractivity: params.surface_refractivity,
min_refractivity_gradient: params.min_refractivity_gradient,
ducting_detected: params.ducting_detected || false,
duct_characteristics: params.duct_characteristics
})
else
Map.merge(base, %{
surface_refractivity: nil,
min_refractivity_gradient: nil,
ducting_detected: false,
duct_characteristics: nil
})
end
end
defp build_pressure_profile(raw) do
raw
|> Map.keys()
|> Enum.filter(fn
{"TMP", lev} when lev > 100 -> true
_ -> false
end)
|> Enum.map(fn {"TMP", lev} -> lev end)
|> Enum.sort(:desc)
|> Enum.map(&pressure_level_entry(&1, raw))
|> Enum.reject(&is_nil/1)
end
defp pressure_level_entry(lev_pa, raw) do
with t_k when is_float(t_k) <- raw[{"TMP", lev_pa}],
h_m when is_float(h_m) <- raw[{"HGT", lev_pa}],
spfh when is_float(spfh) <- raw[{"SPFH", lev_pa}] do
%{
"pres" => lev_pa / 100.0,
"tmpc" => kelvin_to_celsius(t_k),
"hght" => h_m,
"dwpc" => dewpoint_from_spfh(spfh, lev_pa)
}
else
_ -> nil
end
end
# ThetaE.dewpoint_from_spfh/2 returns Kelvin (the function adds 273.15
# at the end of the Magnus-Tetens inversion). Profile entries store
# `dwpc` in *Celsius* — the `c` is load-bearing — and downstream
# SoundingParams.compute_refractivity_profile passes p["dwpc"] straight
# to the Buck saturation-vapor-pressure function which expects °C. So
# convert here.
defp dewpoint_from_spfh(spfh, _lev_pa) when not is_number(spfh) or spfh <= 0, do: nil
defp dewpoint_from_spfh(spfh, lev_pa), do: ThetaE.dewpoint_from_spfh(spfh, lev_pa * 1.0) - 273.15
defp kelvin_to_celsius(nil), do: nil
defp kelvin_to_celsius(k) when is_number(k), do: k - 273.15
defp pa_to_mb(nil), do: nil
defp pa_to_mb(pa) when is_number(pa), do: pa / 100.0
defp do_get_inventory(url) do
case Req.get(url, [receive_timeout: 30_000] ++ req_options()) do
{:ok, %{status: 200, body: body}} when is_binary(body) ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
{:error, "NARR inventory HTTP #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, "NARR inventory request failed: #{inspect(reason)}"}
end
end
defp do_head_grib_size(url) do
case Req.head(url, [receive_timeout: 30_000] ++ req_options()) do
{:ok, %{status: 200} = response} ->
case content_length(response) do
{:ok, size} -> {:ok, size}
:error -> {:error, "NARR grib HEAD missing content-length"}
end
{:ok, %{status: status}} ->
{:error, "NARR grib HEAD HTTP #{status}"}
{:error, reason} ->
{:error, "NARR grib HEAD failed: #{inspect(reason)}"}
end
end
defp content_length(%Req.Response{} = response) do
case response |> Req.Response.get_header("content-length") |> List.first() do
nil ->
:error
value when is_binary(value) ->
case Integer.parse(value) do
{size, ""} when size >= 0 -> {:ok, size}
_ -> :error
end
end
end
defp parse_inventory_line(line) do
case String.split(line, ":") do
[num, offset, _date, var, level | _rest] ->
with {num_int, ""} <- Integer.parse(num),
{offset_int, ""} <- Integer.parse(offset) do
{num_int, offset_int, var, level}
else
_ -> nil
end
_ ->
nil
end
end
defp req_options do
Application.get_env(:microwaveprop, :narr_req_options, [])
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