prop/test/microwaveprop/weather/scalar_file_test.exs
Graham McIntire 040ebf9d3d
fix(weather): snap HRDPS reads to nearest valid_time within 6h
The /weather dual-source timeline picks times from HRRR's hourly
cadence, but HRDPS publishes 4×/day with multi-hour latency, so the
requested time almost never has an exact HRDPS dir on disk. The
controller was returning empty rows for HRDPS, leaving the Canadian
half of the map blank.

Snap to the closest HRDPS valid_time within a 6h window. /weather-ca
keeps exact-time semantics in practice because its timeline is built
from HRDPS-only listings, so the nearest is always the same time.
2026-04-30 13:46:32 -05:00

399 lines
13 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.ScalarFileTest do
# async: false — mutates the global :propagation_scores_dir env to redirect
# the on-disk artifact tree to a private tmp directory.
use ExUnit.Case, async: false
alias Microwaveprop.Weather.ScalarFile
setup do
dir =
Path.join(
System.tmp_dir!(),
"weather_scalar_file_test_#{System.unique_integer([:positive])}"
)
File.mkdir_p!(dir)
prev = Application.get_env(:microwaveprop, :propagation_scores_dir)
Application.put_env(:microwaveprop, :propagation_scores_dir, dir)
on_exit(fn ->
File.rm_rf!(dir)
if prev do
Application.put_env(:microwaveprop, :propagation_scores_dir, prev)
else
Application.delete_env(:microwaveprop, :propagation_scores_dir)
end
end)
%{dir: dir}
end
defp sample_row(lat, lon, temp \\ 22.0) do
%{
lat: lat,
lon: lon,
valid_time: ~U[2026-04-28 12:00:00Z],
temperature: temp,
dewpoint_depression: 5.0,
surface_rh: 70.0,
surface_pressure_mb: 1013.0,
surface_refractivity: 330.0,
refractivity_gradient: -45.0,
bl_height: 800.0,
pwat: 25.0,
temp_850mb: 12.0,
dewpoint_850mb: 4.0,
temp_700mb: 0.0,
dewpoint_700mb: -10.0,
lapse_rate: 6.5,
mid_lapse_rate: 6.0,
inversion_strength: 0.5,
inversion_base_m: nil,
ducting: false,
duct_base_m: nil,
duct_strength: nil
}
end
describe "base_dir/0" do
test "is rooted under the configured scores dir", %{dir: dir} do
assert ScalarFile.base_dir() == Path.join(dir, "weather_scalars")
end
end
describe "write!/2 + read_bounds/2" do
test "round-trips the rows that fall inside the requested bounds" do
valid_time = ~U[2026-04-28 12:00:00Z]
rows = [
sample_row(33.0, -97.0, 22.0),
sample_row(33.5, -97.5, 23.0),
# Outside Texas bounds below
sample_row(45.0, -90.0, 5.0)
]
ScalarFile.write!(valid_time, rows)
result =
ScalarFile.read_bounds(valid_time, %{
"south" => 32.0,
"north" => 34.5,
"west" => -98.0,
"east" => -96.0
})
lat_lons = result |> Enum.map(&{&1.lat, &1.lon}) |> Enum.sort()
assert lat_lons == [{33.0, -97.0}, {33.5, -97.5}]
end
test "returns [] when the file does not exist" do
assert ScalarFile.read_bounds(~U[2026-04-28 12:00:00Z], nil) == []
end
test "read_bounds with nil bounds returns every persisted row" do
valid_time = ~U[2026-04-28 12:00:00Z]
rows = [sample_row(33.0, -97.0), sample_row(45.0, -90.0)]
ScalarFile.write!(valid_time, rows)
assert length(ScalarFile.read_bounds(valid_time, nil)) == 2
end
test "preserves the full set of derived layer fields on round-trip" do
valid_time = ~U[2026-04-28 12:00:00Z]
[row] = [sample_row(33.0, -97.0)]
ScalarFile.write!(valid_time, [row])
[round_tripped] = ScalarFile.read_bounds(valid_time, nil)
for key <- Map.keys(row) do
assert Map.fetch!(round_tripped, key) == Map.fetch!(row, key),
"key #{inspect(key)} mismatched after round-trip"
end
end
end
describe "read_point/3" do
test "returns the snapped row for the requested lat/lon" do
valid_time = ~U[2026-04-28 12:00:00Z]
row = sample_row(33.0, -97.0)
ScalarFile.write!(valid_time, [row])
assert {:ok, fetched} = ScalarFile.read_point(valid_time, 33.0, -97.0)
assert fetched.temperature == 22.0
end
test "returns :miss when no scalar file exists" do
assert ScalarFile.read_point(~U[2026-04-28 12:00:00Z], 33.0, -97.0) == :miss
end
test "returns :miss when the cell is not in the file" do
valid_time = ~U[2026-04-28 12:00:00Z]
ScalarFile.write!(valid_time, [sample_row(33.0, -97.0)])
assert ScalarFile.read_point(valid_time, 40.0, -80.0) == :miss
end
end
describe "exists?/1 and list_valid_times/0" do
test "reflects what has been written" do
vt1 = ~U[2026-04-28 12:00:00Z]
vt2 = ~U[2026-04-28 13:00:00Z]
refute ScalarFile.exists?(vt1)
assert ScalarFile.list_valid_times() == []
ScalarFile.write!(vt1, [sample_row(33.0, -97.0)])
ScalarFile.write!(vt2, [sample_row(33.0, -97.0)])
assert ScalarFile.exists?(vt1)
assert ScalarFile.list_valid_times() == [vt1, vt2]
end
end
describe "HRDPS sibling read merge" do
test "read_bounds returns HRRR + HRDPS rows when both dirs exist" do
vt = ~U[2026-04-29 12:00:00Z]
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
rows = ScalarFile.read_bounds(vt, nil)
sorted = Enum.sort_by(rows, & &1.lat)
assert length(sorted) == 2
assert Enum.at(sorted, 0).lat == 33.0
assert Enum.at(sorted, 1).lat == 53.0
end
test "read_bounds returns HRDPS rows when only HRDPS dir exists" do
vt = ~U[2026-04-29 12:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
assert [%{lat: 53.0, lon: -60.0}] = ScalarFile.read_bounds(vt, nil)
end
test "read_point falls through to HRDPS when the HRRR chunk is absent" do
vt = ~U[2026-04-29 12:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
assert {:ok, %{lat: 53.0, lon: -60.0, temperature: 8.0}} =
ScalarFile.read_point(vt, 53.0, -60.0)
end
test "exists?/1 is true when only the HRDPS dir has chunks" do
vt = ~U[2026-04-29 12:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
assert ScalarFile.exists?(vt)
end
test "merge prefers HRRR row when both dirs cover the same lat/lon" do
vt = ~U[2026-04-29 12:00:00Z]
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 33.0, -97.0, 99.0)
[row] = ScalarFile.read_bounds(vt, nil)
assert row.temperature == 22.0
end
end
describe "HRDPS-only reads (for the /weather-ca endpoint)" do
test "read_bounds_hrdps returns only HRDPS chunks, ignoring co-located HRRR" do
vt = ~U[2026-04-29 12:00:00Z]
# Both dirs cover the same point. The HRRR-only read_bounds/2 would
# merge them (preferring HRRR); read_bounds_hrdps/2 must return ONLY
# the HRDPS row so /weather-ca shows Canadian-source data.
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
assert [%{lat: 53.0, lon: -60.0, temperature: 8.0}] =
ScalarFile.read_bounds_hrdps(vt, nil)
end
test "read_bounds_hrdps respects bounds" do
vt = ~U[2026-04-29 12:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 33.0, -97.0, 22.0)
bounds = %{"south" => 50.0, "north" => 60.0, "west" => -70.0, "east" => -50.0}
assert [%{lat: 53.0, lon: -60.0}] = ScalarFile.read_bounds_hrdps(vt, bounds)
end
test "read_bounds_hrdps returns [] when no HRDPS dir exists" do
vt = ~U[2026-04-29 12:00:00Z]
ScalarFile.write!(vt, [sample_row(33.0, -97.0, 22.0)])
assert ScalarFile.read_bounds_hrdps(vt, nil) == []
end
test "list_valid_times_hrdps returns times from HRDPS-suffixed dirs only" do
vt1 = ~U[2026-04-29 12:00:00Z]
vt2 = ~U[2026-04-29 13:00:00Z]
vt_hrrr_only = ~U[2026-04-29 14:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt1), 53.0, -60.0, 8.0)
hand_write_chunk(ScalarFile.dir_for_hrdps(vt2), 53.0, -60.0, 9.0)
ScalarFile.write!(vt_hrrr_only, [sample_row(33.0, -97.0, 22.0)])
assert ScalarFile.list_valid_times_hrdps() == [vt1, vt2]
end
end
# The /weather (dual-source) timeline picks valid_times from HRRR's
# hourly cadence. HRDPS publishes 4×/day with multi-hour latency, so
# the requested time will frequently miss any HRDPS dir. Snapping to
# the closest HRDPS dir within a window lets the Canadian overlay
# render slightly stale rather than disappear.
describe "read_bounds_hrdps_nearest/3" do
test "returns rows from the exact time when its HRDPS dir exists" do
vt = ~U[2026-04-29 12:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(vt), 53.0, -60.0, 8.0)
assert [%{lat: 53.0, lon: -60.0, temperature: 8.0}] =
ScalarFile.read_bounds_hrdps_nearest(vt, nil, 6 * 3600)
end
test "snaps to the closest HRDPS dir within the window when exact time is absent" do
requested = ~U[2026-04-29 14:00:00Z]
closer = ~U[2026-04-29 13:00:00Z]
farther = ~U[2026-04-29 10:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(closer), 53.0, -60.0, 7.0)
hand_write_chunk(ScalarFile.dir_for_hrdps(farther), 53.0, -60.0, 99.0)
assert [%{temperature: 7.0}] =
ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600)
end
test "snaps forward as well as backward (HRDPS forecast can lead requested time)" do
requested = ~U[2026-04-29 12:00:00Z]
future = ~U[2026-04-29 13:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(future), 53.0, -60.0, 11.0)
assert [%{temperature: 11.0}] =
ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600)
end
test "returns [] when no HRDPS dir exists inside the window" do
requested = ~U[2026-04-29 14:00:00Z]
stale = ~U[2026-04-29 02:00:00Z]
hand_write_chunk(ScalarFile.dir_for_hrdps(stale), 53.0, -60.0, 8.0)
# 12h gap, 6h window → not eligible.
assert ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) == []
end
test "returns [] when no HRDPS dirs exist at all" do
requested = ~U[2026-04-29 14:00:00Z]
assert ScalarFile.read_bounds_hrdps_nearest(requested, nil, 6 * 3600) == []
end
end
defp hand_write_chunk(dir, lat, lon, temp) do
File.mkdir_p!(dir)
lat_band = (lat / 5) |> Float.floor() |> trunc()
lon_band = (lon / 5) |> Float.floor() |> trunc()
path = Path.join(dir, "#{lat_band}_#{lon_band}.mp.gz")
payload =
Msgpax.pack!(
[
%{
"lat" => lat,
"lon" => lon,
"valid_time" => "2026-04-29T12:00:00Z",
"temperature" => temp
}
],
iodata: false
)
File.write!(path, :zlib.gzip(payload))
end
describe "Rust-writer compatibility (the cross-language wire format)" do
test "decodes a hand-rolled gzipped MessagePack chunk and atomizes whitelisted keys" do
valid_time = ~U[2026-04-29 12:00:00Z]
dir = Path.join(ScalarFile.dir_for(valid_time), "")
File.mkdir_p!(dir)
# Mirror exactly what `prop_grid_rs::weather_scalar_file::write_atomic`
# emits: gzipped MessagePack, top-level array of string-keyed maps.
# If this round-trip ever breaks, Elixir is reading something the
# Rust pipeline cannot produce — a wire-format regression.
payload =
Msgpax.pack!(
[
%{
"lat" => 33.0,
"lon" => -97.0,
"valid_time" => "2026-04-29T12:00:00Z",
"temperature" => 22.5,
"dewpoint_depression" => 10.0,
"surface_rh" => 50.0,
"ducting" => false,
# An out-of-whitelist key — the reader must NOT atomize this.
"unknown_extra_key" => 1.23
}
],
iodata: false
)
path = Path.join(dir, "6_-20.mp.gz")
File.write!(path, :zlib.gzip(payload))
[row] = ScalarFile.read_bounds(valid_time, nil)
# Whitelisted keys are atoms.
assert row.lat == 33.0
assert row.lon == -97.0
assert row.temperature == 22.5
assert row.ducting == false
# `valid_time` round-trips back to a DateTime.
assert row.valid_time == valid_time
# Out-of-whitelist keys stay as strings so a future field can be
# added without a deploy-order coordination problem.
assert row["unknown_extra_key"] == 1.23
end
end
describe "prune_older_than/1 and retain_window/2" do
test "prune_older_than removes files strictly before the cutoff" do
old = ~U[2026-04-27 12:00:00Z]
new = ~U[2026-04-28 12:00:00Z]
ScalarFile.write!(old, [sample_row(33.0, -97.0)])
ScalarFile.write!(new, [sample_row(33.0, -97.0)])
assert ScalarFile.prune_older_than(~U[2026-04-28 00:00:00Z]) == 1
refute ScalarFile.exists?(old)
assert ScalarFile.exists?(new)
end
test "retain_window keeps only files inside [run_time, run_time + max_forecast_hour * 3600]" do
run_time = ~U[2026-04-28 12:00:00Z]
inside = ~U[2026-04-28 14:00:00Z]
outside = ~U[2026-04-29 06:00:00Z]
ScalarFile.write!(run_time, [sample_row(33.0, -97.0)])
ScalarFile.write!(inside, [sample_row(33.0, -97.0)])
ScalarFile.write!(outside, [sample_row(33.0, -97.0)])
assert ScalarFile.retain_window(run_time, 4) == 1
assert ScalarFile.exists?(run_time)
assert ScalarFile.exists?(inside)
refute ScalarFile.exists?(outside)
end
end
end