The grid-cached profile is trimmed to 13 levels (1000→700 mb) for memory reasons, so the skew-T plot showed nothing above 700 mb. HrrrClient.fetch_profile/4 now accepts a forecast_hour opt; SkewtLive issues a per-point fetch against the same cycle the cached row came from to get the full 1000→100 mb set, falling back to the cached truncated cell on any failure. Time selection no longer round-trips through the URL — `select_time` updates state in-process and triggers a focused :load_skewt_time async, so refreshing or sharing a /skewt?q=... link always lands on the most recent analysis hour.
769 lines
25 KiB
Elixir
769 lines
25 KiB
Elixir
defmodule Microwaveprop.Weather.HrrrClientTest do
|
||
use ExUnit.Case, async: true
|
||
|
||
alias Microwaveprop.Weather.HrrrClient
|
||
|
||
describe "cycle_available?/1 (stubbed probe)" do
|
||
setup do
|
||
original = Application.get_env(:microwaveprop, :hrrr_cycle_available_fn)
|
||
|
||
on_exit(fn ->
|
||
if original,
|
||
do: Application.put_env(:microwaveprop, :hrrr_cycle_available_fn, original),
|
||
else: Application.delete_env(:microwaveprop, :hrrr_cycle_available_fn)
|
||
end)
|
||
end
|
||
|
||
test "returns whatever the configured probe function returns" do
|
||
Application.put_env(:microwaveprop, :hrrr_cycle_available_fn, fn _dt -> true end)
|
||
assert HrrrClient.cycle_available?(~U[2026-04-25 00:00:00Z])
|
||
|
||
Application.put_env(:microwaveprop, :hrrr_cycle_available_fn, fn _dt -> false end)
|
||
refute HrrrClient.cycle_available?(~U[2026-04-25 00:00:00Z])
|
||
end
|
||
|
||
test "passes the rounded-down run_time to the probe" do
|
||
test_pid = self()
|
||
|
||
Application.put_env(:microwaveprop, :hrrr_cycle_available_fn, fn dt ->
|
||
send(test_pid, {:probed, dt})
|
||
true
|
||
end)
|
||
|
||
HrrrClient.cycle_available?(~U[2026-04-25 00:38:00Z])
|
||
assert_receive {:probed, ~U[2026-04-25 00:00:00Z]}
|
||
end
|
||
end
|
||
|
||
describe "nearest_hrrr_hour/1" do
|
||
test "rounds down when within first 30 minutes" do
|
||
assert HrrrClient.nearest_hrrr_hour(~U[2026-03-28 18:15:00Z]) ==
|
||
~U[2026-03-28 18:00:00Z]
|
||
end
|
||
|
||
test "rounds down when past 30 minutes (never points at a future cycle)" do
|
||
# Rounding up was the historical behaviour; in production it caused
|
||
# 404/403 errors when the next cycle had not yet been published. The
|
||
# caller can always reach a later valid_time via `forecast_hour`.
|
||
assert HrrrClient.nearest_hrrr_hour(~U[2026-03-28 18:45:00Z]) ==
|
||
~U[2026-03-28 18:00:00Z]
|
||
end
|
||
|
||
test "stays same when exactly on the hour" do
|
||
assert HrrrClient.nearest_hrrr_hour(~U[2026-03-28 18:00:00Z]) ==
|
||
~U[2026-03-28 18:00:00Z]
|
||
end
|
||
|
||
test "rounds down at exactly 30 minutes" do
|
||
assert HrrrClient.nearest_hrrr_hour(~U[2026-03-28 18:30:00Z]) ==
|
||
~U[2026-03-28 18:00:00Z]
|
||
end
|
||
end
|
||
|
||
describe "hrrr_url/3" do
|
||
test "builds surface product URL" do
|
||
url = HrrrClient.hrrr_url(~D[2026-03-28], 18, :surface)
|
||
|
||
assert url ==
|
||
"https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260328/conus/hrrr.t18z.wrfsfcf00.grib2"
|
||
end
|
||
|
||
test "builds pressure product URL" do
|
||
url = HrrrClient.hrrr_url(~D[2026-03-28], 6, :pressure)
|
||
|
||
assert url ==
|
||
"https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260328/conus/hrrr.t06z.wrfprsf00.grib2"
|
||
end
|
||
|
||
test "pads single-digit hours" do
|
||
url = HrrrClient.hrrr_url(~D[2026-03-28], 3, :surface)
|
||
assert String.contains?(url, "hrrr.t03z")
|
||
end
|
||
end
|
||
|
||
describe "parse_idx/1" do
|
||
@sample_idx """
|
||
1:0:d=2026032818:TMP:2 m above ground:anl:
|
||
2:1234567:d=2026032818:DPT:2 m above ground:anl:
|
||
3:2345678:d=2026032818:PRES:surface:anl:
|
||
4:3456789:d=2026032818:HPBL:surface:anl:
|
||
5:4567890:d=2026032818:PWAT:entire atmosphere (considered as a single layer):anl:
|
||
6:5678901:d=2026032818:TMP:1000 mb:anl:
|
||
"""
|
||
|
||
test "parses idx into structured list" do
|
||
entries = HrrrClient.parse_idx(@sample_idx)
|
||
|
||
assert length(entries) == 6
|
||
|
||
first = hd(entries)
|
||
assert first.msg == 1
|
||
assert first.offset == 0
|
||
assert first.var == "TMP"
|
||
assert first.level == "2 m above ground"
|
||
end
|
||
|
||
test "correctly extracts offsets" do
|
||
entries = HrrrClient.parse_idx(@sample_idx)
|
||
offsets = Enum.map(entries, & &1.offset)
|
||
assert offsets == [0, 1_234_567, 2_345_678, 3_456_789, 4_567_890, 5_678_901]
|
||
end
|
||
|
||
test "handles empty input" do
|
||
assert HrrrClient.parse_idx("") == []
|
||
end
|
||
|
||
test "skips malformed lines instead of raising" do
|
||
# NOAA S3 occasionally serves an HTML error page as the idx body
|
||
# (e.g. during brief 503 spikes). The old parser would raise
|
||
# ArgumentError on String.to_integer of a non-numeric token,
|
||
# which killed the whole PropagationGridWorker chain step.
|
||
# The tolerant parser returns the well-formed lines and drops
|
||
# the garbage.
|
||
input = """
|
||
<html><body>Service Unavailable</body></html>
|
||
1:0:d=2026032818:TMP:2 m above ground:anl:
|
||
bogus:header:line:with:no:numbers
|
||
2:1234567:d=2026032818:DPT:2 m above ground:anl:
|
||
"""
|
||
|
||
entries = HrrrClient.parse_idx(input)
|
||
|
||
assert length(entries) == 2
|
||
assert Enum.map(entries, & &1.msg) == [1, 2]
|
||
assert Enum.map(entries, & &1.var) == ["TMP", "DPT"]
|
||
end
|
||
end
|
||
|
||
describe "byte_ranges_for_messages/2" do
|
||
test "computes byte ranges from idx entries" do
|
||
entries = [
|
||
%{msg: 1, offset: 0, var: "TMP", level: "2 m above ground"},
|
||
%{msg: 2, offset: 1000, var: "DPT", level: "2 m above ground"},
|
||
%{msg: 3, offset: 2000, var: "PRES", level: "surface"},
|
||
%{msg: 4, offset: 3000, var: "HPBL", level: "surface"}
|
||
]
|
||
|
||
wanted = [
|
||
%{var: "TMP", level: "2 m above ground"},
|
||
%{var: "PRES", level: "surface"}
|
||
]
|
||
|
||
ranges = HrrrClient.byte_ranges_for_messages(entries, wanted)
|
||
|
||
assert length(ranges) == 2
|
||
assert {0, 999} in ranges
|
||
assert {2000, 2999} in ranges
|
||
end
|
||
|
||
test "handles last message in file" do
|
||
entries = [
|
||
%{msg: 1, offset: 0, var: "TMP", level: "2 m above ground"},
|
||
%{msg: 2, offset: 1000, var: "DPT", level: "2 m above ground"}
|
||
]
|
||
|
||
wanted = [%{var: "DPT", level: "2 m above ground"}]
|
||
|
||
ranges = HrrrClient.byte_ranges_for_messages(entries, wanted)
|
||
|
||
# Last message - uses a large end offset
|
||
assert length(ranges) == 1
|
||
[{start, _end}] = ranges
|
||
assert start == 1000
|
||
end
|
||
end
|
||
|
||
describe "fetch_profile/3" do
|
||
test "succeeds when ranges are downloaded individually" do
|
||
grib_data = File.read!("test/fixtures/grib2/hrrr_tmp_2m.grib2")
|
||
|
||
# Idx with multiple surface variables to trigger multi-range download
|
||
grib_size = byte_size(grib_data)
|
||
|
||
idx_text = """
|
||
1:0:d=2026032818:TMP:2 m above ground:anl:
|
||
2:#{grib_size}:d=2026032818:DPT:2 m above ground:anl:
|
||
3:#{grib_size * 2}:d=2026032818:PRES:surface:anl:
|
||
"""
|
||
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
if String.ends_with?(conn.request_path, ".idx") do
|
||
Plug.Conn.send_resp(conn, 200, idx_text)
|
||
else
|
||
[range] = Plug.Conn.get_req_header(conn, "range")
|
||
|
||
if String.contains?(range, ",") do
|
||
# Multi-range: S3 ignores Range header, returns full file
|
||
Plug.Conn.send_resp(conn, 200, "full file")
|
||
else
|
||
# Single range: S3 supports this fine
|
||
Plug.Conn.send_resp(conn, 206, grib_data)
|
||
end
|
||
end
|
||
end)
|
||
|
||
assert {:ok, profile} = HrrrClient.fetch_profile(32.90, -97.04, ~U[2026-03-28 18:00:00Z])
|
||
assert is_float(profile.surface_temp_c)
|
||
assert is_struct(profile.run_time, DateTime)
|
||
end
|
||
|
||
test "caches idx responses across calls to the same HRRR run" do
|
||
# `.idx` files are static once a model run is published. The 114s
|
||
# `hrrr_fetch_grid` span in prod spends ~10s re-fetching the same idx
|
||
# URL across forecast hours / products. Cache eliminates that per-run.
|
||
grib_data = File.read!("test/fixtures/grib2/hrrr_tmp_2m.grib2")
|
||
grib_size = byte_size(grib_data)
|
||
|
||
idx_text = """
|
||
1:0:d=2026032818:TMP:2 m above ground:anl:
|
||
2:#{grib_size}:d=2026032818:DPT:2 m above ground:anl:
|
||
3:#{grib_size * 2}:d=2026032818:PRES:surface:anl:
|
||
"""
|
||
|
||
Microwaveprop.Cache.invalidate(
|
||
{:hrrr_idx, "https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260328/conus/hrrr.t18z.wrfsfcf00.grib2.idx"}
|
||
)
|
||
|
||
# `do_fetch_profile` fetches TWO idx URLs (surface + pressure). Also drop
|
||
# the pressure cache so the test starts from a deterministic state —
|
||
# without this, whether this assertion passes depends on seed ordering.
|
||
Microwaveprop.Cache.invalidate(
|
||
{:hrrr_idx, "https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260328/conus/hrrr.t18z.wrfprsf00.grib2.idx"}
|
||
)
|
||
|
||
{:ok, counter} = Agent.start_link(fn -> %{idx: 0, grib: 0} end)
|
||
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
if String.ends_with?(conn.request_path, ".idx") do
|
||
Agent.update(counter, &Map.update!(&1, :idx, fn n -> n + 1 end))
|
||
Plug.Conn.send_resp(conn, 200, idx_text)
|
||
else
|
||
Agent.update(counter, &Map.update!(&1, :grib, fn n -> n + 1 end))
|
||
[range] = Plug.Conn.get_req_header(conn, "range")
|
||
|
||
if String.contains?(range, ",") do
|
||
Plug.Conn.send_resp(conn, 200, "full file")
|
||
else
|
||
Plug.Conn.send_resp(conn, 206, grib_data)
|
||
end
|
||
end
|
||
end)
|
||
|
||
valid_time = ~U[2026-03-28 18:00:00Z]
|
||
|
||
assert {:ok, _} = HrrrClient.fetch_profile(32.90, -97.04, valid_time)
|
||
assert {:ok, _} = HrrrClient.fetch_profile(32.90, -97.04, valid_time)
|
||
|
||
%{idx: idx_count, grib: grib_count} = Agent.get(counter, & &1)
|
||
|
||
# fetch_profile pulls 2 idx URLs (surface + pressure) on the first call;
|
||
# the second call must be a full cache hit, so the total stays at 2.
|
||
assert idx_count == 2, "idx should be cached — expected 2 total fetches, got #{idx_count}"
|
||
assert grib_count > 0, "grib fetches should still happen"
|
||
end
|
||
|
||
test "forecast_hour opt rewrites the URL to the requested f-hour file" do
|
||
# The skew-T page passes through the cycle's run_time and a positive
|
||
# forecast_hour to render a future valid_time. Verify the request
|
||
# actually targets `wrfprsf06.grib2` (not `wrfprsf00.grib2`).
|
||
grib_data = File.read!("test/fixtures/grib2/hrrr_tmp_2m.grib2")
|
||
grib_size = byte_size(grib_data)
|
||
|
||
idx_text = """
|
||
1:0:d=2026032818:TMP:2 m above ground:6 hour fcst:
|
||
2:#{grib_size}:d=2026032818:DPT:2 m above ground:6 hour fcst:
|
||
3:#{grib_size * 2}:d=2026032818:PRES:surface:6 hour fcst:
|
||
"""
|
||
|
||
{:ok, paths} = Agent.start_link(fn -> [] end)
|
||
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
Agent.update(paths, fn list -> [conn.request_path | list] end)
|
||
|
||
if String.ends_with?(conn.request_path, ".idx") do
|
||
Plug.Conn.send_resp(conn, 200, idx_text)
|
||
else
|
||
[_range] = Plug.Conn.get_req_header(conn, "range")
|
||
Plug.Conn.send_resp(conn, 206, grib_data)
|
||
end
|
||
end)
|
||
|
||
assert {:ok, profile} =
|
||
HrrrClient.fetch_profile(32.90, -97.04, ~U[2026-03-28 18:00:00Z], forecast_hour: 6)
|
||
|
||
requested = Agent.get(paths, & &1)
|
||
|
||
assert Enum.any?(requested, &String.contains?(&1, "wrfprsf06.grib2")),
|
||
"expected wrfprsf06.grib2 in #{inspect(requested)}"
|
||
|
||
refute Enum.any?(requested, &String.contains?(&1, "wrfprsf00.grib2")),
|
||
"did not expect wrfprsf00.grib2 in #{inspect(requested)}"
|
||
|
||
assert profile.forecast_hour == 6
|
||
end
|
||
end
|
||
|
||
describe "build_profile/1" do
|
||
test "assembles profile from parsed data" do
|
||
parsed = %{
|
||
"TMP:1000 mb" => 298.0,
|
||
"DPT:1000 mb" => 291.0,
|
||
"HGT:1000 mb" => 110.0,
|
||
"TMP:975 mb" => 296.0,
|
||
"DPT:975 mb" => 289.0,
|
||
"HGT:975 mb" => 350.0,
|
||
"TMP:950 mb" => 294.0,
|
||
"DPT:950 mb" => 287.0,
|
||
"HGT:950 mb" => 590.0,
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0
|
||
}
|
||
|
||
result = HrrrClient.build_profile(parsed)
|
||
|
||
assert result.surface_temp_c == 299.0 - 273.15
|
||
assert result.surface_dewpoint_c == 292.0 - 273.15
|
||
assert_in_delta result.surface_pressure_mb, 1013.5, 0.1
|
||
assert result.hpbl_m == 1500.0
|
||
assert result.pwat_mm == 25.0
|
||
assert length(result.profile) == 3
|
||
|
||
first_level = hd(result.profile)
|
||
assert first_level["pres"] == 1000.0
|
||
assert first_level["tmpc"] == 298.0 - 273.15
|
||
assert first_level["dwpc"] == 291.0 - 273.15
|
||
assert first_level["hght"] == 110.0
|
||
end
|
||
|
||
test "includes wind, cloud cover, and precip fields" do
|
||
parsed = %{
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0,
|
||
"UGRD:10 m above ground" => 3.5,
|
||
"VGRD:10 m above ground" => -2.1,
|
||
"TCDC:entire atmosphere" => 75.0,
|
||
"APCP:surface" => 1.2
|
||
}
|
||
|
||
result = HrrrClient.build_profile(parsed)
|
||
|
||
assert result.wind_u == 3.5
|
||
assert result.wind_v == -2.1
|
||
assert result.cloud_cover_pct == 75.0
|
||
assert result.precip_mm == 1.2
|
||
end
|
||
|
||
test "wind, cloud, and precip fields are nil when missing" do
|
||
parsed = %{
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0
|
||
}
|
||
|
||
result = HrrrClient.build_profile(parsed)
|
||
|
||
assert result.wind_u == nil
|
||
assert result.wind_v == nil
|
||
assert result.cloud_cover_pct == nil
|
||
assert result.precip_mm == nil
|
||
end
|
||
|
||
test "skips pressure levels with missing data" do
|
||
parsed = %{
|
||
"TMP:1000 mb" => 298.0,
|
||
"DPT:1000 mb" => 291.0,
|
||
"HGT:1000 mb" => 110.0,
|
||
# 975 mb missing HGT
|
||
"TMP:975 mb" => 296.0,
|
||
"DPT:975 mb" => 289.0,
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0
|
||
}
|
||
|
||
result = HrrrClient.build_profile(parsed)
|
||
assert length(result.profile) == 1
|
||
end
|
||
|
||
test "includes upper-air levels above 700 mb for the skew-T plot" do
|
||
parsed = %{
|
||
"TMP:700 mb" => 275.0,
|
||
"DPT:700 mb" => 265.0,
|
||
"HGT:700 mb" => 3100.0,
|
||
"TMP:500 mb" => 253.0,
|
||
"DPT:500 mb" => 238.0,
|
||
"HGT:500 mb" => 5800.0,
|
||
"TMP:300 mb" => 228.0,
|
||
"DPT:300 mb" => 200.0,
|
||
"HGT:300 mb" => 9400.0,
|
||
"TMP:100 mb" => 205.0,
|
||
"DPT:100 mb" => 180.0,
|
||
"HGT:100 mb" => 16_300.0,
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0
|
||
}
|
||
|
||
result = HrrrClient.build_profile(parsed)
|
||
pressures = Enum.map(result.profile, & &1["pres"])
|
||
|
||
assert 500.0 in pressures
|
||
assert 300.0 in pressures
|
||
assert 100.0 in pressures
|
||
|
||
level_500 = Enum.find(result.profile, &(&1["pres"] == 500.0))
|
||
assert_in_delta level_500["tmpc"], 253.0 - 273.15, 0.001
|
||
assert_in_delta level_500["dwpc"], 238.0 - 273.15, 0.001
|
||
assert level_500["hght"] == 5800.0
|
||
end
|
||
end
|
||
|
||
describe "pressure_messages/1" do
|
||
test ":grid returns only the near-surface levels needed for scoring" do
|
||
messages = HrrrClient.pressure_messages(:grid)
|
||
|
||
# 13 levels × 3 vars (TMP/DPT/HGT)
|
||
assert length(messages) == 39
|
||
|
||
levels =
|
||
messages
|
||
|> Enum.map(fn m ->
|
||
[num_str, _] = String.split(m.level, " ")
|
||
String.to_integer(num_str)
|
||
end)
|
||
|> Enum.uniq()
|
||
|> Enum.sort()
|
||
|
||
# Narrow list: 1000 → 700 mb, every 25 mb
|
||
assert Enum.max(levels) == 1000
|
||
assert Enum.min(levels) == 700
|
||
assert 700 in levels
|
||
assert 900 in levels
|
||
|
||
# Upper-air levels must be absent — they're the whole reason the grid
|
||
# worker OOMs when this function returns the full profile list.
|
||
refute 500 in levels
|
||
refute 300 in levels
|
||
refute 100 in levels
|
||
end
|
||
|
||
test ":profile returns full troposphere + lower stratosphere for the skew-T" do
|
||
messages = HrrrClient.pressure_messages(:profile)
|
||
|
||
# 25 levels × 3 vars
|
||
assert length(messages) == 75
|
||
|
||
levels =
|
||
messages
|
||
|> Enum.map(fn m ->
|
||
[num_str, _] = String.split(m.level, " ")
|
||
String.to_integer(num_str)
|
||
end)
|
||
|> Enum.uniq()
|
||
|> Enum.sort()
|
||
|
||
assert Enum.max(levels) == 1000
|
||
assert Enum.min(levels) == 100
|
||
assert 500 in levels
|
||
assert 300 in levels
|
||
assert 100 in levels
|
||
end
|
||
|
||
test ":grid and :profile both request TMP, DPT and HGT at every level" do
|
||
for variant <- [:grid, :profile] do
|
||
messages = HrrrClient.pressure_messages(variant)
|
||
vars = messages |> Enum.map(& &1.var) |> Enum.uniq() |> Enum.sort()
|
||
assert vars == ["DPT", "HGT", "TMP"]
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "surface_messages/0" do
|
||
test "returns list of surface message descriptors" do
|
||
messages = HrrrClient.surface_messages()
|
||
|
||
assert is_list(messages)
|
||
assert length(messages) == 9
|
||
|
||
vars = Enum.map(messages, & &1.var)
|
||
assert "TMP" in vars
|
||
assert "DPT" in vars
|
||
assert "PRES" in vars
|
||
assert "HPBL" in vars
|
||
assert "PWAT" in vars
|
||
assert "UGRD" in vars
|
||
assert "VGRD" in vars
|
||
assert "TCDC" in vars
|
||
assert "APCP" in vars
|
||
end
|
||
|
||
test "includes wind messages at 10 m above ground" do
|
||
messages = HrrrClient.surface_messages()
|
||
|
||
ugrd = Enum.find(messages, &(&1.var == "UGRD"))
|
||
assert ugrd.level == "10 m above ground"
|
||
|
||
vgrd = Enum.find(messages, &(&1.var == "VGRD"))
|
||
assert vgrd.level == "10 m above ground"
|
||
end
|
||
|
||
test "includes cloud cover for entire atmosphere" do
|
||
messages = HrrrClient.surface_messages()
|
||
tcdc = Enum.find(messages, &(&1.var == "TCDC"))
|
||
assert tcdc.level == "entire atmosphere"
|
||
end
|
||
|
||
test "includes precipitation at surface" do
|
||
messages = HrrrClient.surface_messages()
|
||
apcp = Enum.find(messages, &(&1.var == "APCP"))
|
||
assert apcp.level == "surface"
|
||
end
|
||
end
|
||
|
||
describe "download_grib_ranges_to_file/3" do
|
||
test "writes bytes to file in offset-ascending order even when fetched out of order" do
|
||
# Two non-adjacent ranges (so they don't get merged). Responses
|
||
# deliberately return with the second range "arriving" first to
|
||
# simulate out-of-order completion under parallel fetches; the
|
||
# final file must still be ordered by offset.
|
||
ranges = [{0, 9}, {100, 119}]
|
||
range_a = String.duplicate("A", 10)
|
||
range_b = String.duplicate("B", 20)
|
||
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
[range] = Plug.Conn.get_req_header(conn, "range")
|
||
|
||
case range do
|
||
"bytes=0-9" ->
|
||
Plug.Conn.send_resp(conn, 206, range_a)
|
||
|
||
"bytes=100-119" ->
|
||
Plug.Conn.send_resp(conn, 206, range_b)
|
||
end
|
||
end)
|
||
|
||
tmp = Path.join(System.tmp_dir!(), "hrrr_download_test_#{System.unique_integer([:positive])}.grib2")
|
||
|
||
try do
|
||
assert :ok = HrrrClient.download_grib_ranges_to_file("http://test/grib", ranges, tmp)
|
||
assert File.read!(tmp) == range_a <> range_b
|
||
after
|
||
File.rm(tmp)
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "parse_idx/1 property" do
|
||
use ExUnitProperties
|
||
|
||
property "never raises on arbitrary ascii-ish input and always returns a list of maps" do
|
||
check all(
|
||
lines <-
|
||
list_of(
|
||
string(:printable, max_length: 80),
|
||
max_length: 12
|
||
)
|
||
) do
|
||
text = Enum.join(lines, "\n")
|
||
result = HrrrClient.parse_idx(text)
|
||
|
||
assert is_list(result)
|
||
|
||
for entry <- result do
|
||
assert is_map(entry)
|
||
assert is_integer(entry.msg)
|
||
assert is_integer(entry.offset)
|
||
end
|
||
end
|
||
end
|
||
|
||
property "only parses lines whose first two fields are integers" do
|
||
check all(
|
||
msg <- integer(1..200),
|
||
offset <- integer(0..1_000_000_000),
|
||
var <- string(:alphanumeric, min_length: 1, max_length: 6),
|
||
level <- string(:alphanumeric, min_length: 1, max_length: 12)
|
||
) do
|
||
line = "#{msg}:#{offset}:d=2026032818:#{var}:#{level}:anl:"
|
||
[entry] = HrrrClient.parse_idx(line)
|
||
|
||
assert entry.msg == msg
|
||
assert entry.offset == offset
|
||
assert entry.var == var
|
||
assert entry.level == level
|
||
end
|
||
end
|
||
|
||
test "returns [] for input that is only whitespace / comment garbage" do
|
||
assert HrrrClient.parse_idx(" \n\n\n") == []
|
||
assert HrrrClient.parse_idx("# this is a comment\nno-colons-here") == []
|
||
end
|
||
|
||
test "returns [] for a line whose offset field is non-numeric" do
|
||
assert HrrrClient.parse_idx("1:notanumber:d=x:TMP:surface:anl:") == []
|
||
end
|
||
end
|
||
|
||
describe "fetch_profile/3 error paths" do
|
||
# Use a distinct date/hour from the success-path tests above so that
|
||
# `Microwaveprop.Cache` entries written by these error cases don't
|
||
# poison the shared ETS cache and flake the async success tests.
|
||
@error_date ~D[2024-01-15]
|
||
@error_dt ~U[2024-01-15 06:00:00Z]
|
||
|
||
setup do
|
||
# Invalidate before and after so reruns of a single test and
|
||
# neighbouring async suites both start from a clean cache.
|
||
invalidate = fn ->
|
||
for product <- [:surface, :pressure] do
|
||
url = HrrrClient.hrrr_url(@error_date, 6, product)
|
||
Microwaveprop.Cache.invalidate({:hrrr_idx, url <> ".idx"})
|
||
end
|
||
end
|
||
|
||
invalidate.()
|
||
on_exit(invalidate)
|
||
:ok
|
||
end
|
||
|
||
test "idx HTTP 500 on the surface product returns {:error, _}" do
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
if String.ends_with?(conn.request_path, ".idx") do
|
||
Plug.Conn.send_resp(conn, 500, "boom")
|
||
else
|
||
Plug.Conn.send_resp(conn, 200, "")
|
||
end
|
||
end)
|
||
|
||
assert {:error, reason} = HrrrClient.fetch_profile(32.90, -97.04, @error_dt)
|
||
assert is_binary(reason)
|
||
assert reason =~ "HTTP 500"
|
||
end
|
||
|
||
test "idx transport timeout returns {:error, _}" do
|
||
Req.Test.stub(HrrrClient, fn conn ->
|
||
Req.Test.transport_error(conn, :timeout)
|
||
end)
|
||
|
||
assert {:error, reason} = HrrrClient.fetch_profile(32.90, -97.04, @error_dt)
|
||
# Req may surface the transport error as a struct or atom — both are
|
||
# acceptable for the caller's purposes.
|
||
assert reason
|
||
end
|
||
end
|
||
|
||
describe "byte_ranges_for_messages/2 property" do
|
||
use ExUnitProperties
|
||
|
||
property "every produced range has start <= stop and stop > 0 for a sorted offset list" do
|
||
check all(
|
||
# Generate a strictly increasing offset sequence for a small
|
||
# idx, then ask for a random subset of it back.
|
||
count <- integer(1..8),
|
||
gaps <- list_of(integer(1..10_000), length: count),
|
||
wanted_idxs <- list_of(integer(0..(count - 1)), max_length: count)
|
||
) do
|
||
entries =
|
||
gaps
|
||
|> Enum.scan(&(&1 + &2))
|
||
|> Enum.with_index(1)
|
||
|> Enum.map(fn {offset, msg} ->
|
||
%{msg: msg, offset: offset, var: "V#{msg}", level: "L#{msg}"}
|
||
end)
|
||
|
||
wanted =
|
||
wanted_idxs
|
||
|> Enum.uniq()
|
||
|> Enum.map(fn i ->
|
||
e = Enum.at(entries, i)
|
||
%{var: e.var, level: e.level}
|
||
end)
|
||
|
||
ranges = HrrrClient.byte_ranges_for_messages(entries, wanted)
|
||
|
||
for {start, stop} <- ranges do
|
||
assert start <= stop
|
||
assert stop > 0
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "merge_grid_data/2" do
|
||
test "merges surface and pressure grids by point key" do
|
||
point_a = {32.90, -97.04}
|
||
point_b = {33.10, -96.80}
|
||
|
||
sfc_grid = %{
|
||
point_a => %{
|
||
"TMP:2 m above ground" => 299.0,
|
||
"DPT:2 m above ground" => 292.0,
|
||
"PRES:surface" => 101_350.0,
|
||
"HPBL:surface" => 1500.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 25.0,
|
||
"UGRD:10 m above ground" => 3.5,
|
||
"VGRD:10 m above ground" => -2.1,
|
||
"TCDC:entire atmosphere" => 75.0,
|
||
"APCP:surface" => 1.2
|
||
},
|
||
point_b => %{
|
||
"TMP:2 m above ground" => 297.0,
|
||
"DPT:2 m above ground" => 290.0,
|
||
"PRES:surface" => 101_200.0,
|
||
"HPBL:surface" => 1200.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 22.0,
|
||
"UGRD:10 m above ground" => 2.0,
|
||
"VGRD:10 m above ground" => -1.5,
|
||
"TCDC:entire atmosphere" => 50.0,
|
||
"APCP:surface" => 0.0
|
||
}
|
||
}
|
||
|
||
prs_grid = %{
|
||
point_a => %{
|
||
"TMP:1000 mb" => 298.0,
|
||
"DPT:1000 mb" => 291.0,
|
||
"HGT:1000 mb" => 110.0
|
||
},
|
||
point_b => %{
|
||
"TMP:1000 mb" => 296.0,
|
||
"DPT:1000 mb" => 289.0,
|
||
"HGT:1000 mb" => 105.0
|
||
}
|
||
}
|
||
|
||
merged = HrrrClient.merge_grid_data(sfc_grid, prs_grid)
|
||
|
||
assert map_size(merged) == 2
|
||
assert merged[point_a]["TMP:2 m above ground"] == 299.0
|
||
assert merged[point_a]["TMP:1000 mb"] == 298.0
|
||
assert merged[point_b]["TMP:2 m above ground"] == 297.0
|
||
assert merged[point_b]["TMP:1000 mb"] == 296.0
|
||
end
|
||
|
||
test "handles empty pressure data" do
|
||
point = {32.90, -97.04}
|
||
|
||
sfc_grid = %{
|
||
point => %{"TMP:2 m above ground" => 299.0}
|
||
}
|
||
|
||
merged = HrrrClient.merge_grid_data(sfc_grid, %{})
|
||
|
||
assert map_size(merged) == 1
|
||
assert merged[point]["TMP:2 m above ground"] == 299.0
|
||
end
|
||
end
|
||
end
|