defmodule Microwaveprop.Weather.HrdpsClientTest do use ExUnit.Case, async: true alias Microwaveprop.Weather.HrdpsClient describe "nearest_hrdps_cycle/1" do test "snaps to the start of the current 6-hour cycle" do assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) == ~U[2026-04-29 12:00:00Z] assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) == ~U[2026-04-29 18:00:00Z] assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) == ~U[2026-04-29 00:00:00Z] end test "rounds DOWN — never points at a future cycle that hasn't published" do # 11:59Z is firmly inside the 06Z cycle window (06-12Z); rounding up # to 12Z would race the publish latency. assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) == ~U[2026-04-29 06:00:00Z] end test "stays same when exactly on a cycle boundary" do assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) == ~U[2026-04-29 12:00:00Z] end end describe "grib_url/3" do test "builds surface 2m temperature URL with date-prefixed datamart path" do url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0) assert url == "https://dd.weather.gc.ca/20260429/WXO-DD/model_hrdps/continental/2.5km/12/000/" <> "20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2" end test "pads forecast hour to three digits" do url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24) assert String.contains?(url, "PT024H.grib2") end test "pads cycle hour to two digits" do url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 06:00:00Z], 0) assert String.contains?(url, "/06/000/") assert String.contains?(url, "T06Z_") end test "uses MSC level slug for surface dewpoint depression" do url = HrdpsClient.grib_url(:depr_2m, ~U[2026-04-29 12:00:00Z], 0) assert String.contains?(url, "MSC_HRDPS_DEPR_AGL-2m_") end test "uses ISBL_NNNN for pressure-level temperature" do url = HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0) assert String.contains?(url, "MSC_HRDPS_TMP_ISBL_0850_") end test "raises on unknown variable" do assert_raise ArgumentError, ~r/unknown HRDPS variable/, fn -> HrdpsClient.grib_url(:not_a_real_var, ~U[2026-04-29 12:00:00Z], 0) end end end describe "variables/0" do test "covers the surface set the propagation scorer reads" do vars = HrdpsClient.variables() surface = [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc] for v <- surface do assert v in vars, "missing surface variable #{inspect(v)}" end end test "covers the grid pressure-level set used for refractivity gradient" do vars = HrdpsClient.variables() pressure_levels = for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700], do: :"#{var_kind}_#{level}mb" for v <- pressure_levels do assert v in vars, "missing pressure-level variable #{inspect(v)}" end end end describe "concat_grib_binaries/1" do test "byte-concatenates GRIB2 records (wgrib2 reads multi-record files natively)" do a = <<"GRIB", 1::24, 2::8, 0::8*100>> b = <<"GRIB", 1::24, 2::8, 1::8*200>> result = HrdpsClient.concat_grib_binaries([a, b]) assert result == a <> b end test "returns empty binary for empty list" do assert HrdpsClient.concat_grib_binaries([]) == <<>> end end describe "build_profile_from_extracted/1" do test "translates the wgrib2 extract shape into the HRRR-style profile shape" do # Simulates the output of Wgrib2.extract_points_from_file on a # concatenated HRDPS multi-variable file. Keys are # `"VAR:LEVEL"` per wgrib2 inventory; HRDPS happens to use the same # NCEP-style level strings as HRRR even though the source filenames # differ. extracted = %{ "TMP:2 m above ground" => 285.15, "DPT:2 m above ground" => 280.15, "PRES:surface" => 100_000.0, "HPBL:surface" => 800.0, "UGRD:10 m above ground" => 3.0, "VGRD:10 m above ground" => 1.0, "TCDC:surface" => 50.0, "TMP:850 mb" => 275.0, "DPT:850 mb" => 270.0, "HGT:850 mb" => 1500.0 } profile = HrdpsClient.build_profile_from_extracted(extracted) assert_in_delta profile.surface_temp_c, 12.0, 0.01 assert_in_delta profile.surface_dewpoint_c, 7.0, 0.01 assert profile.surface_pressure_mb == 1000.0 assert profile.hpbl_m == 800.0 assert profile.cloud_cover_pct == 50.0 assert profile.wind_u == 3.0 assert profile.wind_v == 1.0 assert [%{"pres" => 850.0, "tmpc" => tmpc, "dwpc" => dwpc, "hght" => 1500.0}] = profile.profile assert_in_delta tmpc, 1.85, 0.01 assert_in_delta dwpc, -3.15, 0.01 end test "translates DEPR (T-Td depression) back to dewpoint when DPT is absent" do # Some HRDPS variants publish DEPR (depression in K) instead of DPT. # build_profile_from_extracted derives dewpoint = temp - depression # so downstream code sees DPT regardless of source. extracted = %{ "TMP:2 m above ground" => 290.15, "DEPR:2 m above ground" => 5.0 } profile = HrdpsClient.build_profile_from_extracted(extracted) assert_in_delta profile.surface_temp_c, 17.0, 0.01 assert_in_delta profile.surface_dewpoint_c, 12.0, 0.01 end end describe "cycle_available?/1 (stubbed probe)" do setup do original = Application.get_env(:microwaveprop, :hrdps_cycle_available_fn) on_exit(fn -> if original, do: Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, original), else: Application.delete_env(:microwaveprop, :hrdps_cycle_available_fn) end) end test "returns whatever the configured probe function returns" do Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> true end) assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> false end) refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z]) end test "passes the snapped run_time to the probe" do test_pid = self() Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn dt -> send(test_pid, {:probed, dt}) true end) HrdpsClient.cycle_available?(~U[2026-04-29 13:45:00Z]) assert_receive {:probed, ~U[2026-04-29 12:00:00Z]} end end end