Stage 3 (Elixir foundation) of the HRDPS plan. Lands the scaffolding that lets the Rust prop-grid-rs HRDPS branch be wired up cleanly when it ships, without further DB or Elixir-side changes. What ships: - `grid_tasks.source` column (default 'hrrr', backfill is a no-op). Replaces the (run_time, forecast_hour, kind) unique index with a 4-column key (..., source) so HRRR and HRDPS analysis/forecast rows for the same cycle coexist. - `Grid.hrdps_only_points/0` — Canadian cells inside HRDPS bbox (49-60°N, -141 to -52°W) but outside HRRR's CONUS bbox. Disjoint from `conus_points/0` by construction so the two grids never double-write the same (lat, lon). - `Grid.point_source/1` — `:hrrr | :hrdps | :neither` classification for a (lat, lon) pair. Routes per-QSO enrichment to the right NWP source. - `GridTaskEnqueuer.seed_with_analysis/2` and `seed/2` accept a `:source` option; defaults to "hrrr" so existing callers are unchanged. - `HrdpsGridWorker` cycle seeder mirroring PropagationGridWorker's shape but for HRDPS's 4×/day cadence. `:hrdps` queue added to base Oban config. What's deferred: - The Rust `prop-grid-rs` worker doesn't yet branch on `task.source`. Until that ships, `HrdpsGridWorker` stays out of runtime.exs cron — it would just produce rows that the Rust worker mishandles as HRRR. Module doc explicitly notes the dormant state. - Map overlay extension and FreshnessMonitor HRDPS staleness deferred to follow-up sessions once real data flows. Activation path documented in the updated plan file. Coverage cap recorded as Stage 8 decision: 60°N for v1 (SRTM stops there). Arctic CDEM deferred to a follow-up plan.
174 lines
5.6 KiB
Elixir
174 lines
5.6 KiB
Elixir
defmodule Microwaveprop.Propagation.GridTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Propagation.Grid
|
|
|
|
describe "conus_points/0" do
|
|
test "generates grid at 0.125 degree resolution" do
|
|
points = Grid.conus_points()
|
|
assert length(points) > 5000
|
|
assert length(points) < 100_000
|
|
end
|
|
|
|
test "all points within CONUS bounds" do
|
|
Enum.each(Grid.conus_points(), fn {lat, lon} ->
|
|
assert lat >= 25.0 and lat <= 50.0
|
|
assert lon >= -125.0 and lon <= -66.0
|
|
end)
|
|
end
|
|
|
|
test "points are on 0.125 degree grid" do
|
|
Enum.each(Grid.conus_points(), fn {lat, lon} ->
|
|
assert Float.round(lat * 8, 0) == lat * 8
|
|
assert Float.round(lon * 8, 0) == lon * 8
|
|
end)
|
|
end
|
|
|
|
test "first point is at SW corner" do
|
|
[{lat, lon} | _] = Grid.conus_points()
|
|
assert lat == 25.0
|
|
assert lon == -125.0
|
|
end
|
|
|
|
test "last point is at NE corner" do
|
|
points = Grid.conus_points()
|
|
{lat, lon} = List.last(points)
|
|
assert lat == 50.0
|
|
assert lon == -66.0
|
|
end
|
|
end
|
|
|
|
describe "step/0" do
|
|
test "returns 0.125" do
|
|
assert Grid.step() == 0.125
|
|
end
|
|
end
|
|
|
|
describe "bounds/0" do
|
|
test "returns CONUS bounding box" do
|
|
bounds = Grid.bounds()
|
|
assert bounds.lat_min == 25.0
|
|
assert bounds.lat_max == 50.0
|
|
assert bounds.lon_min == -125.0
|
|
assert bounds.lon_max == -66.0
|
|
end
|
|
end
|
|
|
|
describe "contains?/1" do
|
|
test "true for DFW (firmly inside the box)" do
|
|
assert Grid.contains?(%{"lat" => 32.9, "lon" => -97.0})
|
|
end
|
|
|
|
test "true for every corner of the CONUS bounding box (inclusive)" do
|
|
assert Grid.contains?(%{"lat" => 25.0, "lon" => -125.0})
|
|
assert Grid.contains?(%{"lat" => 50.0, "lon" => -66.0})
|
|
assert Grid.contains?(%{"lat" => 25.0, "lon" => -66.0})
|
|
assert Grid.contains?(%{"lat" => 50.0, "lon" => -125.0})
|
|
end
|
|
|
|
test "false for OCONUS positions (UK, Winnipeg, Alberta, etc.)" do
|
|
# 27 stuck hrrr-queued contacts in prod landed on points like
|
|
# these. Add them as regression fixtures.
|
|
refute Grid.contains?(%{"lat" => 51.4, "lon" => 0.47})
|
|
refute Grid.contains?(%{"lat" => 51.39, "lon" => -114.04})
|
|
refute Grid.contains?(%{"lat" => 50.1875, "lon" => -97.625})
|
|
end
|
|
|
|
test "false for nil pos or missing keys" do
|
|
refute Grid.contains?(nil)
|
|
refute Grid.contains?(%{})
|
|
refute Grid.contains?(%{"lat" => 32.9})
|
|
refute Grid.contains?(%{"lon" => -97.0})
|
|
end
|
|
|
|
test "false when lat or lon is nil" do
|
|
refute Grid.contains?(%{"lat" => nil, "lon" => -97.0})
|
|
refute Grid.contains?(%{"lat" => 32.9, "lon" => nil})
|
|
end
|
|
end
|
|
|
|
describe "wgrib2_grid_spec/0" do
|
|
test "lon_start / lat_start match the bounding box SW corner" do
|
|
spec = Grid.wgrib2_grid_spec()
|
|
assert spec.lon_start == -125.0
|
|
assert spec.lat_start == 25.0
|
|
end
|
|
|
|
test "grid step matches Grid.step/0 on both axes" do
|
|
spec = Grid.wgrib2_grid_spec()
|
|
assert spec.lon_step == Grid.step()
|
|
assert spec.lat_step == Grid.step()
|
|
end
|
|
|
|
test "lon_count and lat_count span the full CONUS box inclusive" do
|
|
spec = Grid.wgrib2_grid_spec()
|
|
# (lon_max - lon_min) / step + 1 = (-66 - -125) / 0.125 + 1 = 473
|
|
assert spec.lon_count == 473
|
|
# (lat_max - lat_min) / step + 1 = (50 - 25) / 0.125 + 1 = 201
|
|
assert spec.lat_count == 201
|
|
end
|
|
|
|
test "total cells in wgrib2_grid_spec matches conus_points length" do
|
|
spec = Grid.wgrib2_grid_spec()
|
|
points = Grid.conus_points()
|
|
assert length(points) == spec.lon_count * spec.lat_count
|
|
end
|
|
end
|
|
|
|
describe "hrdps_only_points/0" do
|
|
test "covers cells north of HRRR's lat_max" do
|
|
points = Grid.hrdps_only_points()
|
|
# At least one cell strictly north of HRRR's lat_max=50
|
|
assert Enum.any?(points, fn {lat, _lon} -> lat > 50.0 end)
|
|
end
|
|
|
|
test "is disjoint from conus_points/0" do
|
|
conus = MapSet.new(Grid.conus_points())
|
|
hrdps = MapSet.new(Grid.hrdps_only_points())
|
|
assert MapSet.disjoint?(conus, hrdps), "hrdps_only_points must not overlap conus_points"
|
|
end
|
|
|
|
test "all points within HRDPS bbox" do
|
|
Enum.each(Grid.hrdps_only_points(), fn {lat, lon} ->
|
|
assert lat >= 49.0 and lat <= 60.0
|
|
assert lon >= -141.0 and lon <= -52.0
|
|
end)
|
|
end
|
|
|
|
test "points are on 0.125 degree grid" do
|
|
Enum.each(Grid.hrdps_only_points(), fn {lat, lon} ->
|
|
assert Float.round(lat * 8, 0) == lat * 8
|
|
assert Float.round(lon * 8, 0) == lon * 8
|
|
end)
|
|
end
|
|
|
|
test "no point is inside the CONUS bounding box" do
|
|
Enum.each(Grid.hrdps_only_points(), fn {lat, lon} ->
|
|
in_conus = lat >= 25.0 and lat <= 50.0 and lon >= -125.0 and lon <= -66.0
|
|
refute in_conus, "point #{lat},#{lon} is inside CONUS bbox"
|
|
end)
|
|
end
|
|
end
|
|
|
|
describe "point_source/1" do
|
|
test "classifies cells inside CONUS as :hrrr" do
|
|
assert Grid.point_source({32.9, -97.0}) == :hrrr
|
|
assert Grid.point_source({49.0, -98.0}) == :hrrr
|
|
end
|
|
|
|
test "classifies Canadian cells outside HRRR as :hrdps" do
|
|
assert Grid.point_source({53.32, -60.42}) == :hrdps
|
|
assert Grid.point_source({50.125, -97.0}) == :hrdps
|
|
end
|
|
|
|
test "classifies points outside both bboxes as :neither" do
|
|
assert Grid.point_source({-30.0, 150.0}) == :neither
|
|
assert Grid.point_source({65.0, -90.0}) == :neither
|
|
end
|
|
|
|
test "accepts %{lat: _, lon: _} maps as well as tuples" do
|
|
assert Grid.point_source(%{lat: 32.9, lon: -97.0}) == :hrrr
|
|
assert Grid.point_source(%{lat: 53.32, lon: -60.42}) == :hrdps
|
|
end
|
|
end
|
|
end
|