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