Each fix is covered by a regression test that fails on `main` and passes on this commit. Round 1 (initial review): * propagation: thread `latitude` into the conditions map so `score_season/4` actually picks up regional multipliers * hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at a future cycle that NOAA hasn't published yet * radio: spherical-vector great-circle midpoint replaces the arithmetic mean — anti-meridian paths no longer fold to Greenwich * weather: `reconcile_weather_statuses` scales the longitude band by `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude * radio/maidenhead: clamp 90°/180° below the field-bucket overflow so `from_latlon` never emits invalid characters like 'S' * prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native Duct Boost actually fires * propagation/region (Elixir + Rust): inclusive upper bounds so points exactly at lat_max get the regional multiplier * weather/sounding_params (Elixir + Rust): drop the 10 m gradient floor so HRRR's thin near-surface layers stop hiding sharp ducts * weather/sounding_params: when the profile ends inside a duct, finalize it with the highest sample as the top instead of throwing it away (Rust port already correct) Round 2 (post-fix sweep): * radio + commercial: single canonical haversine in Radio (atan2 form); Commercial delegates instead of carrying a second copy that could disagree at threshold distances * prop_grid_rs/profiles_file: `snap_coords` matches Elixir's step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so Rust-keyed and Elixir-keyed profile maps land on the same cell * weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse` uniformly — wgrib2 dropping the trailing `.0` from a longitude no longer crashes the whole chain step
213 lines
7.2 KiB
Elixir
213 lines
7.2 KiB
Elixir
defmodule Microwaveprop.Weather.GridSnapPropertyTest do
|
|
@moduledoc """
|
|
Property tests for the coarse-grid / time-slot snapping helpers that
|
|
scattered across the Weather layer. Each of these functions maps a
|
|
continuous input onto a discrete set (the IEMRE 0.125° grid, top-of-hour
|
|
HRRR slots, 5-minute NEXRAD frames, 3-hourly NARR analyses). The
|
|
invariants every such snap must satisfy are idempotence, bounded
|
|
distance from the input, and grid-membership of the output.
|
|
"""
|
|
use ExUnit.Case, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.HrrrClient
|
|
alias Microwaveprop.Weather.NarrClient
|
|
alias Microwaveprop.Weather.NexradClient
|
|
|
|
describe "Weather.round_to_iemre_grid/2" do
|
|
property "is idempotent" do
|
|
check all(
|
|
lat <- float(min: -89.9, max: 89.9),
|
|
lon <- float(min: -179.9, max: 179.9)
|
|
) do
|
|
once = Weather.round_to_iemre_grid(lat, lon)
|
|
twice = then(once, fn {la, lo} -> Weather.round_to_iemre_grid(la, lo) end)
|
|
assert once == twice
|
|
end
|
|
end
|
|
|
|
property "output coordinates are multiples of 0.125°" do
|
|
check all(
|
|
lat <- float(min: -89.9, max: 89.9),
|
|
lon <- float(min: -179.9, max: 179.9)
|
|
) do
|
|
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)
|
|
# 0.125 = 1/8, so 8 * coord must be an integer.
|
|
assert abs(rlat * 8 - round(rlat * 8)) < 1.0e-9
|
|
assert abs(rlon * 8 - round(rlon * 8)) < 1.0e-9
|
|
end
|
|
end
|
|
|
|
property "output is within half a grid cell (0.0625°) of the input" do
|
|
check all(
|
|
lat <- float(min: -89.9, max: 89.9),
|
|
lon <- float(min: -179.9, max: 179.9)
|
|
) do
|
|
{rlat, rlon} = Weather.round_to_iemre_grid(lat, lon)
|
|
# Float.round uses banker's rounding so the worst-case gap is
|
|
# exactly 0.0625; allow a tiny float epsilon.
|
|
assert abs(rlat - lat) <= 0.0625 + 1.0e-9
|
|
assert abs(rlon - lon) <= 0.0625 + 1.0e-9
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "HrrrClient.nearest_hrrr_hour/1" do
|
|
property "is idempotent — once snapped, always snapped" do
|
|
check all(dt <- datetime_generator()) do
|
|
once = HrrrClient.nearest_hrrr_hour(dt)
|
|
twice = HrrrClient.nearest_hrrr_hour(once)
|
|
assert DateTime.compare(once, twice) == :eq
|
|
end
|
|
end
|
|
|
|
property "output is always exactly on the hour" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = HrrrClient.nearest_hrrr_hour(dt)
|
|
assert result.minute == 0
|
|
assert result.second == 0
|
|
end
|
|
end
|
|
|
|
property "output is at or before the input by at most one hour" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = HrrrClient.nearest_hrrr_hour(dt)
|
|
# Round-down only — the result is always the published cycle
|
|
# at or before `dt`, never a future cycle that may not exist.
|
|
delta_sec = DateTime.diff(dt, result, :second)
|
|
assert delta_sec >= 0
|
|
assert delta_sec < 60 * 60
|
|
end
|
|
end
|
|
|
|
property "always rounds down (never points at a future cycle)" do
|
|
check all(dt <- datetime_generator(minute_range: 30..59)) do
|
|
result = HrrrClient.nearest_hrrr_hour(dt)
|
|
# result is strictly before the input — the input minute > 0
|
|
# ensures a non-zero offset.
|
|
assert DateTime.before?(result, dt)
|
|
assert result.hour == dt.hour
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "NexradClient.round_to_5min/1" do
|
|
property "is idempotent" do
|
|
check all(dt <- datetime_generator()) do
|
|
once = NexradClient.round_to_5min(dt)
|
|
twice = NexradClient.round_to_5min(once)
|
|
assert DateTime.compare(once, twice) == :eq
|
|
end
|
|
end
|
|
|
|
property "output minute is a multiple of 5" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NexradClient.round_to_5min(dt)
|
|
assert rem(result.minute, 5) == 0
|
|
assert result.second == 0
|
|
end
|
|
end
|
|
|
|
property "output is never later than the input (floor, not round)" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NexradClient.round_to_5min(dt)
|
|
assert DateTime.compare(result, dt) in [:lt, :eq]
|
|
end
|
|
end
|
|
|
|
property "output is within 5 minutes of input" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NexradClient.round_to_5min(dt)
|
|
delta_sec = DateTime.diff(dt, result, :second)
|
|
assert delta_sec >= 0
|
|
assert delta_sec < 5 * 60
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "NarrClient.snap_to_analysis_hour/1" do
|
|
property "is idempotent" do
|
|
check all(dt <- datetime_generator()) do
|
|
once = NarrClient.snap_to_analysis_hour(dt)
|
|
twice = NarrClient.snap_to_analysis_hour(once)
|
|
assert DateTime.compare(once, twice) == :eq
|
|
end
|
|
end
|
|
|
|
property "output hour is in {0, 3, 6, 9, 12, 15, 18, 21}" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NarrClient.snap_to_analysis_hour(dt)
|
|
assert result.hour in [0, 3, 6, 9, 12, 15, 18, 21]
|
|
assert result.minute == 0
|
|
assert result.second == 0
|
|
end
|
|
end
|
|
|
|
property "output is never later than the input (floors to slot)" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NarrClient.snap_to_analysis_hour(dt)
|
|
assert DateTime.compare(result, dt) in [:lt, :eq]
|
|
end
|
|
end
|
|
|
|
property "output is within 3 hours of input" do
|
|
check all(dt <- datetime_generator()) do
|
|
result = NarrClient.snap_to_analysis_hour(dt)
|
|
delta_sec = DateTime.diff(dt, result, :second)
|
|
assert delta_sec >= 0
|
|
assert delta_sec < 3 * 3600
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "NarrClient.in_coverage?/1" do
|
|
property "coverage_end is the first excluded timestamp (exclusive upper bound)" do
|
|
coverage_end = NarrClient.coverage_end()
|
|
refute NarrClient.in_coverage?(coverage_end)
|
|
one_sec_before = DateTime.add(coverage_end, -1, :second)
|
|
assert NarrClient.in_coverage?(one_sec_before)
|
|
end
|
|
|
|
property "any DateTime after coverage_end is out of coverage" do
|
|
coverage_end = NarrClient.coverage_end()
|
|
|
|
check all(seconds_after <- integer(1..100_000_000)) do
|
|
dt = DateTime.add(coverage_end, seconds_after, :second)
|
|
refute NarrClient.in_coverage?(dt)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Generate DateTimes across the HRRR archive era. Avoiding DST
|
|
# discontinuities and month-boundary edge cases by staying well inside
|
|
# a single month — snap_to_analysis_hour / nearest_hrrr_hour are
|
|
# purely field-arithmetic on the DateTime struct, so this is enough
|
|
# to exercise every branch.
|
|
defp datetime_generator(opts \\ []) do
|
|
minute_range = Keyword.get(opts, :minute_range, 0..59)
|
|
|
|
gen all(
|
|
year <- integer(2019..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour <- integer(0..23),
|
|
minute <- integer(minute_range),
|
|
second <- integer(0..59)
|
|
) do
|
|
%DateTime{
|
|
year: year,
|
|
month: month,
|
|
day: day,
|
|
hour: hour,
|
|
minute: minute,
|
|
second: second,
|
|
microsecond: {0, 0},
|
|
std_offset: 0,
|
|
utc_offset: 0,
|
|
zone_abbr: "UTC",
|
|
time_zone: "Etc/UTC"
|
|
}
|
|
end
|
|
end
|
|
end
|