prop/test/microwaveprop/backtest/features_test.exs
Graham McIntire e7f0f03bf0 Fix wgrib2 -lola binary parsing and add native_surface_refractivity
wgrib2 -lola ... bin writes Fortran unformatted records (4-byte
length header + data + 4-byte length trailer per message).
parse_lola_binary was treating the binary as tightly packed,
causing every message after the first to read from the wrong
offset — values came out as garbage across all grid points.

Fix: account for the 8-byte record overhead per message when
computing the data offset for each message's grid values.

This bug affects both the existing propagation grid extraction
(which may have been producing subtly wrong scores) and the new
native-level extraction (which was producing obviously wrong
values). The fix is a one-line stride change.

Also adds Backtest.Features.native_surface_refractivity for the
Phase 1 sanity check, plus a tighter wgrib2 match pattern that
selects only hybrid-level messages from the native file.
2026-04-10 08:13:33 -05:00

96 lines
3.1 KiB
Elixir

defmodule Microwaveprop.Backtest.FeaturesTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Backtest.Features
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrNativeProfile
@point_lat 32.9
@point_lon -97.0
@valid_time ~U[2026-03-28 18:00:00Z]
defp create_profile(attrs) do
base = %{
valid_time: @valid_time,
lat: @point_lat,
lon: @point_lon
}
{:ok, _} = Weather.upsert_hrrr_profile(Map.merge(base, attrs))
end
describe "naive_gradient/3" do
test "returns the nearest profile's min_refractivity_gradient" do
create_profile(%{min_refractivity_gradient: -250.0})
assert Features.naive_gradient(@point_lat, @point_lon, @valid_time) == -250.0
end
test "returns nil when no profile is within match window" do
assert Features.naive_gradient(@point_lat, @point_lon, @valid_time) == nil
end
end
describe "td_depression/3" do
test "returns surface_temp_c minus surface_dewpoint_c" do
create_profile(%{surface_temp_c: 20.0, surface_dewpoint_c: 14.0})
assert Features.td_depression(@point_lat, @point_lon, @valid_time) == 6.0
end
test "returns nil when profile has no surface data" do
create_profile(%{surface_temp_c: nil, surface_dewpoint_c: nil})
assert Features.td_depression(@point_lat, @point_lon, @valid_time) == nil
end
end
describe "time_of_day/3" do
test "returns UTC hour + minute fraction and never calls the database" do
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 06:30:00Z]) == 6.5
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 00:00:00Z]) == 0.0
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 23:45:00Z]) == 23.75
end
end
describe "pressure/3" do
test "returns surface_pressure_mb from the nearest profile" do
create_profile(%{surface_pressure_mb: 1013.2})
assert Features.pressure(@point_lat, @point_lon, @valid_time) == 1013.2
end
test "returns nil when no profile exists" do
assert Features.pressure(@point_lat, @point_lon, @valid_time) == nil
end
end
describe "native_surface_refractivity/3" do
test "computes N-units from native profile surface scalars" do
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: @valid_time,
lat: @point_lat,
lon: @point_lon,
level_count: 1,
heights_m: [10.0],
temp_k: [295.0],
spfh: [0.010],
pressure_pa: [101_325.0],
u_wind_ms: [2.0],
v_wind_ms: [1.0],
tke_m2s2: [0.5],
surface_temp_k: 295.0,
surface_spfh: 0.010,
surface_pressure_pa: 101_325.0
})
|> Repo.insert()
result = Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time)
assert is_float(result)
# Typical surface N is 250-400 in temperate latitudes
assert result > 200.0 and result < 500.0
end
test "returns nil when no native profile exists" do
assert Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time) == nil
end
end
end