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 "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