prop/test/microwaveprop/weather/hrrr_client_test.exs
Graham McIntire 332308b9c3
feat(skewt): fetch full 25-level profile and drop valid_time URL param
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.
2026-04-25 15:19:34 -05:00

769 lines
25 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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