parse_cdo_row/1 assumed cdo -outputtab,code,lev,value would always emit numeric codes in the first column, but Debian's cdo 2.5.1 (the version in the prod container) emits mixed output: - 'codeNN' with a literal 'code' prefix for most GRIB1 parameters - Short GRIB1 names for a few: 2tag2 (PRES sfc), saip (DPT 2m), tpag10 (HGT) Result: every row fell through to :error, yielding a 'no parseable values' error and 986 discarded NarrFetchWorker jobs. Fix: normalize the code token by stripping 'code' prefix when present and mapping known 2.5.1 short names. UGRD/VGRD intentionally omitted (no discard evidence yet) — comment documents the add-when-they-surface plan. Also exposed parse_cdo_outputtab/1 as @doc false for testability.
345 lines
13 KiB
Elixir
345 lines
13 KiB
Elixir
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
|
|
|
|
# NCEI's NARR archive stops at 2014-10-02. Post-cutoff fetches hit a 404.
|
|
# Callers filter on this helper to skip post-coverage dispatches.
|
|
describe "in_coverage?/1" do
|
|
test "true for dates inside NARR's 1979-01-01 → 2014-10-02 window" do
|
|
assert NarrClient.in_coverage?(~U[1979-01-01 00:00:00Z])
|
|
assert NarrClient.in_coverage?(~U[1995-07-04 12:00:00Z])
|
|
assert NarrClient.in_coverage?(~U[2014-10-01 21:00:00Z])
|
|
end
|
|
|
|
test "false on or after the 2014-10-02 cutoff" do
|
|
refute NarrClient.in_coverage?(~U[2014-10-02 00:00:00Z])
|
|
refute NarrClient.in_coverage?(~U[2020-09-20 21:00:00Z])
|
|
refute NarrClient.in_coverage?(~U[2026-04-16 12:00:00Z])
|
|
end
|
|
|
|
test "false before the 1979-01-01 start" do
|
|
refute NarrClient.in_coverage?(~U[1978-12-31 23:00:00Z])
|
|
refute NarrClient.in_coverage?(~U[1970-01-01 00:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "parse_inventory/2" do
|
|
@fixture_path "test/fixtures/narr/narr-a_221_20100615_1200_000.inv"
|
|
@fixture_file_size 56_221_430
|
|
|
|
test "parses the spike fixture inventory into an offset/length index" do
|
|
inv = File.read!(@fixture_path)
|
|
|
|
{:ok, index} = NarrClient.parse_inventory(inv, @fixture_file_size)
|
|
|
|
# Record 288: TMP @ 2 m above gnd, offset 37836396, next record at 37967364.
|
|
assert {37_836_396, 130_968} = index[{"TMP", "2 m above gnd"}]
|
|
|
|
# Record 15: HPBL at surface — length is positive and computed from next record.
|
|
assert {1_940_538, len_hpbl} = index[{"HPBL", "sfc"}]
|
|
assert len_hpbl > 0
|
|
|
|
# Record 202: TMP at 850 mb — pressure-level record present in the index.
|
|
assert {_off_850, _len_850} = index[{"TMP", "850 mb"}]
|
|
|
|
# Record 317: PWAT at atmos col.
|
|
assert {_off_pwat, _len_pwat} = index[{"PWAT", "atmos col"}]
|
|
|
|
# Last record in the file (423): length is computed against file_size.
|
|
assert {56_042_950, last_len} = index[{"WVVFLX", "atmos col"}]
|
|
assert last_len == @fixture_file_size - 56_042_950
|
|
assert last_len > 0
|
|
end
|
|
|
|
test "returns an empty index for empty input" do
|
|
assert {:ok, %{}} = NarrClient.parse_inventory("", 0)
|
|
end
|
|
|
|
test "ignores blank lines and trailing whitespace" do
|
|
inv = """
|
|
1:0:D=2010061512:MSLET:MSL:kpds=130,102,0:anl:winds in grid direction:"Mean sea level pressure [Pa]
|
|
|
|
2:166602:D=2010061512:PRMSL:MSL:kpds=2,102,0:anl:winds in grid direction:"Pressure reduced to MSL [Pa]
|
|
|
|
"""
|
|
|
|
{:ok, index} = NarrClient.parse_inventory(inv, 333_204)
|
|
|
|
assert {0, 166_602} = index[{"MSLET", "MSL"}]
|
|
assert {166_602, 166_602} = index[{"PRMSL", "MSL"}]
|
|
end
|
|
end
|
|
|
|
describe "fetch_inventory/1" do
|
|
@fixture_path "test/fixtures/narr/narr-a_221_20100615_1200_000.inv"
|
|
@fixture_file_size 56_221_430
|
|
|
|
test "fetches the .inv body and the .grb HEAD content-length, then parses" do
|
|
inv_body = File.read!(@fixture_path)
|
|
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
cond do
|
|
String.ends_with?(conn.request_path, ".inv") and conn.method == "GET" ->
|
|
Plug.Conn.send_resp(conn, 200, inv_body)
|
|
|
|
String.ends_with?(conn.request_path, ".grb") and conn.method == "HEAD" ->
|
|
conn
|
|
|> Plug.Conn.put_resp_header("content-length", Integer.to_string(@fixture_file_size))
|
|
|> Plug.Conn.send_resp(200, "")
|
|
|
|
true ->
|
|
Plug.Conn.send_resp(conn, 500, "unexpected #{conn.method} #{conn.request_path}")
|
|
end
|
|
end)
|
|
|
|
assert {:ok, index} = NarrClient.fetch_inventory(~U[2010-06-15 12:00:00Z])
|
|
|
|
# Matches the parse_inventory spot-check for the same fixture.
|
|
assert {37_836_396, 130_968} = index[{"TMP", "2 m above gnd"}]
|
|
assert {56_042_950, last_len} = index[{"WVVFLX", "atmos col"}]
|
|
assert last_len == @fixture_file_size - 56_042_950
|
|
end
|
|
|
|
test "returns {:error, _} when the inv fetch fails" do
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
if String.ends_with?(conn.request_path, ".inv") do
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
else
|
|
Plug.Conn.send_resp(conn, 200, "")
|
|
end
|
|
end)
|
|
|
|
assert {:error, _reason} = NarrClient.fetch_inventory(~U[2010-06-15 12:00:00Z])
|
|
end
|
|
|
|
test "returns {:error, _} when the grb HEAD fails" do
|
|
inv_body = File.read!(@fixture_path)
|
|
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
cond do
|
|
String.ends_with?(conn.request_path, ".inv") ->
|
|
Plug.Conn.send_resp(conn, 200, inv_body)
|
|
|
|
String.ends_with?(conn.request_path, ".grb") ->
|
|
Plug.Conn.send_resp(conn, 500, "server error")
|
|
|
|
true ->
|
|
Plug.Conn.send_resp(conn, 500, "unexpected")
|
|
end
|
|
end)
|
|
|
|
assert {:error, _reason} = NarrClient.fetch_inventory(~U[2010-06-15 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "extract_profile_from_file/3" do
|
|
@grb_fixture "test/fixtures/narr/narr_dfw_2010-06-15_12z.grb"
|
|
|
|
test "extracts a profile_attrs map from the spike-captured composite GRIB1 fixture" do
|
|
assert {:ok, attrs} =
|
|
NarrClient.extract_profile_from_file(@grb_fixture, 32.9, -97.0)
|
|
|
|
# Surface vars (verified in the spike against real DFW 2010-06-15 12Z analysis)
|
|
# 296.94 K
|
|
assert_in_delta attrs.surface_temp_c, 23.79, 0.5
|
|
# 294.49 K
|
|
assert_in_delta attrs.surface_dewpoint_c, 21.34, 0.5
|
|
# 99383.6 Pa
|
|
assert_in_delta attrs.surface_pressure_mb, 993.84, 1.0
|
|
assert_in_delta attrs.hpbl_m, 703.5, 5.0
|
|
assert_in_delta attrs.pwat_mm, 45.1, 1.0
|
|
|
|
# Profile array — fixture only contains 850 mb, so length is 1
|
|
assert length(attrs.profile) == 1
|
|
[level_850] = attrs.profile
|
|
assert_in_delta level_850["pres"], 850.0, 0.1
|
|
# 291.52 K
|
|
assert_in_delta level_850["tmpc"], 18.37, 0.5
|
|
assert_in_delta level_850["hght"], 1547.8, 5.0
|
|
# Derived from SPFH=0.01040 kg/kg at 850 mb via Magnus-Tetens.
|
|
# Hand-check: e ≈ q*P/(0.622+0.378*q) ≈ 1410 Pa = 14.10 hPa,
|
|
# ln(14.10/6.1078) ≈ 0.836, Td_C = 243.04 * 0.836 / (17.625 - 0.836) ≈ 12.1 °C.
|
|
# Stored as Celsius — Kelvin would be ~285 and would crash the
|
|
# downstream Buck equation in SoundingParams.
|
|
assert_in_delta level_850["dwpc"], 12.1, 1.0
|
|
|
|
# Derived fields — SoundingParams.derive returns these (may be nil if profile is too short)
|
|
assert Map.has_key?(attrs, :surface_refractivity)
|
|
assert Map.has_key?(attrs, :min_refractivity_gradient)
|
|
assert Map.has_key?(attrs, :ducting_detected)
|
|
assert Map.has_key?(attrs, :duct_characteristics)
|
|
end
|
|
|
|
test "returns {:error, _} when the file doesn't exist" do
|
|
assert {:error, _} =
|
|
NarrClient.extract_profile_from_file("/tmp/does_not_exist.grb", 32.9, -97.0)
|
|
end
|
|
end
|
|
|
|
describe "parse_cdo_outputtab/1" do
|
|
# cdo 2.5.1 on Debian (prod) emits mixed output despite `-outputtab,code`:
|
|
# rows whose first token is either `codeNN` (numeric code with "code" prefix)
|
|
# or a GRIB1 shortname like `2tag2` / `saip` / `tpag10`. cdo 2.6 on Mac
|
|
# (dev) emits bare integers. The parser must handle both.
|
|
test "parses a cdo 2.5.1 output fixture with codeNN + shortname tokens" do
|
|
# Trimmed multi-line fixture modeled on real prod discard output.
|
|
# Codes: 221=HPBL, 1=PRES (2tag2), 11=TMP, 17=DPT (saip), 54=PWAT,
|
|
# 7=HGT (tpag10), 51=SPFH.
|
|
stdout = """
|
|
# name lev value
|
|
cdo(1) remapnn: some warning line
|
|
code221 0 1387.958
|
|
2tag2 0 98509.35
|
|
code11 2 299.4731
|
|
saip 2 292.6315
|
|
code54 0 37.28385
|
|
tpag10 90000 983.7363
|
|
code11 10000 209.1171
|
|
tpag10 85000 1547.8
|
|
code11 85000 291.52
|
|
code51 85000 0.0104
|
|
"""
|
|
|
|
assert {:ok, attrs} = NarrClient.parse_cdo_outputtab(stdout)
|
|
|
|
# HPBL at sfc (code 221) = 1387.958 m
|
|
assert_in_delta attrs.hpbl_m, 1387.958, 0.01
|
|
# PRES at sfc (2tag2 → code 1) = 98509.35 Pa → 985.09 mb
|
|
assert_in_delta attrs.surface_pressure_mb, 985.09, 0.1
|
|
# TMP at 2m (code 11, lev 2) = 299.47 K → 26.32 C
|
|
assert_in_delta attrs.surface_temp_c, 26.32, 0.1
|
|
# DPT at 2m (saip → code 17, lev 2) = 292.63 K → 19.48 C
|
|
assert_in_delta attrs.surface_dewpoint_c, 19.48, 0.1
|
|
# PWAT (code 54) = 37.28 mm
|
|
assert_in_delta attrs.pwat_mm, 37.28, 0.01
|
|
|
|
# Profile — 900 hPa has HGT+TMP+SPFH? No, only TMP is there at 900. Only
|
|
# 850 hPa has all three (TMP, HGT, SPFH) → one profile entry.
|
|
assert length(attrs.profile) == 1
|
|
[level_850] = attrs.profile
|
|
assert_in_delta level_850["pres"], 850.0, 0.01
|
|
assert_in_delta level_850["tmpc"], 18.37, 0.5
|
|
assert_in_delta level_850["hght"], 1547.8, 0.5
|
|
end
|
|
end
|
|
|
|
describe "fetch_profile_at/2" do
|
|
@inv_fixture "test/fixtures/narr/narr-a_221_20100615_1200_000.inv"
|
|
@grb_fixture "test/fixtures/narr/narr_dfw_2010-06-15_12z.grb"
|
|
@file_size 56_221_430
|
|
|
|
test "byte-range fetches records, merges via cdo, and returns the profile_attrs" do
|
|
inv_body = File.read!(@inv_fixture)
|
|
grb_body = File.read!(@grb_fixture)
|
|
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
cond do
|
|
String.ends_with?(conn.request_path, ".inv") and conn.method == "GET" ->
|
|
Plug.Conn.send_resp(conn, 200, inv_body)
|
|
|
|
String.ends_with?(conn.request_path, ".grb") and conn.method == "HEAD" ->
|
|
conn
|
|
|> Plug.Conn.put_resp_header("content-length", Integer.to_string(@file_size))
|
|
|> Plug.Conn.send_resp(200, "")
|
|
|
|
String.ends_with?(conn.request_path, ".grb") and conn.method == "GET" ->
|
|
# Byte-range request — return the entire fixture body. The composite
|
|
# fixture happens to contain ALL the records the byte-range fetcher
|
|
# asks for (just at different offsets than the real prod file), so
|
|
# cdo -merge will produce the same composite either way for the
|
|
# purpose of this test. The test asserts the OUTPUT of extract,
|
|
# not the byte-range mechanics.
|
|
Plug.Conn.send_resp(conn, 200, grb_body)
|
|
|
|
true ->
|
|
Plug.Conn.send_resp(conn, 500, "unexpected #{conn.method} #{conn.request_path}")
|
|
end
|
|
end)
|
|
|
|
assert {:ok, attrs} =
|
|
NarrClient.fetch_profile_at(~U[2010-06-15 12:00:00Z], {32.9, -97.0})
|
|
|
|
assert_in_delta attrs.surface_temp_c, 23.79, 0.5
|
|
end
|
|
|
|
test "returns {:error, _} when fetch_inventory fails" do
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
assert {:error, _} = NarrClient.fetch_profile_at(~U[2010-06-15 12:00:00Z], {32.9, -97.0})
|
|
end
|
|
end
|
|
end
|