defmodule Microwaveprop.Propagation.Grid do @moduledoc """ Propagation scoring grid definitions at 0.125° (~14 km). Two regions, disjoint by construction: * `conus_points/0` — HRRR-sourced CONUS cells (lat 25-50, lon -125 to -66). * `hrdps_only_points/0` — Canadian cells covered by HRDPS but **not** by HRRR. Bounded north at 60°N because SRTM elevation data stops at 60°N (Stage 8 of the HRDPS plan defers Arctic coverage). The mask is a rectangular bbox minus the HRRR overlap; over-water cells (Hudson Bay, Great Lakes spillover) get scored and ignored downstream. `point_source/1` returns `:hrrr | :hrdps | :neither` so callers can route fetch/score work to the correct NWP source. """ @lat_min 25.0 @lat_max 50.0 @lon_min -125.0 @lon_max -66.0 @step 0.125 @hrdps_lat_min 49.0 @hrdps_lat_max 60.0 @hrdps_lon_min -141.0 @hrdps_lon_max -52.0 @doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing." @spec conus_points() :: [{float(), float()}] def conus_points do for lat <- float_range(@lat_min, @lat_max, @step), lon <- float_range(@lon_min, @lon_max, @step) do {Float.round(lat, 3), Float.round(lon, 3)} end end @doc "Returns the grid step size in degrees." @spec step() :: float() def step, do: @step @doc "Returns the CONUS bounding box as a map." @spec bounds() :: %{lat_min: float(), lat_max: float(), lon_min: float(), lon_max: float()} def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max} @doc """ True when the given position lies inside the CONUS bounding box (inclusive on all four edges). Callers use this to gate HRRR enrichment — contacts whose path origin is outside the grid would be enqueued forever because `hrrr_point_rs` silently writes no profiles for out-of-grid points. Accepts the canonical `%{"lat" => _, "lon" => _}` pos map. Returns `false` for nil, missing keys, or nil numeric values. """ @spec contains?(map() | nil) :: boolean() def contains?(nil), do: false def contains?(%{"lat" => lat, "lon" => lon}) when is_number(lat) and is_number(lon) do lat >= @lat_min and lat <= @lat_max and lon >= @lon_min and lon <= @lon_max end def contains?(_), do: false @doc "Returns the grid specification for wgrib2 -lola extraction." @spec wgrib2_grid_spec() :: %{ lon_start: float(), lon_count: non_neg_integer(), lon_step: float(), lat_start: float(), lat_count: non_neg_integer(), lat_step: float() } def wgrib2_grid_spec do lon_count = round((@lon_max - @lon_min) / @step) + 1 lat_count = round((@lat_max - @lat_min) / @step) + 1 %{ lon_start: @lon_min, lon_count: lon_count, lon_step: @step, lat_start: @lat_min, lat_count: lat_count, lat_step: @step } end @doc """ Canadian-only grid points. Cells inside the 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)`. """ @spec hrdps_only_points() :: [{float(), float()}] def hrdps_only_points do for lat <- float_range(@hrdps_lat_min, @hrdps_lat_max, @step), lon <- float_range(@hrdps_lon_min, @hrdps_lon_max, @step), not in_conus_bbox?(lat, lon) do {Float.round(lat, 3), Float.round(lon, 3)} end end @doc """ Returns the source NWP model for a `{lat, lon}` point: * `:hrrr` — inside the CONUS bbox; route to HrrrClient. * `:hrdps` — inside the Canadian extent (excluding HRRR overlap); route to HrdpsClient. * `:neither` — outside both. Callers should fall back to the IEMRE/RAOB enrichment paths. """ @spec point_source({float(), float()} | %{lat: number(), lon: number()}) :: :hrrr | :hrdps | :neither def point_source({lat, lon}), do: classify(lat, lon) def point_source(%{lat: lat, lon: lon}), do: classify(lat, lon) defp classify(lat, lon) do cond do in_conus_bbox?(lat, lon) -> :hrrr in_hrdps_bbox?(lat, lon) -> :hrdps true -> :neither end end defp in_conus_bbox?(lat, lon) do lat >= @lat_min and lat <= @lat_max and lon >= @lon_min and lon <= @lon_max end defp in_hrdps_bbox?(lat, lon) do lat >= @hrdps_lat_min and lat <= @hrdps_lat_max and lon >= @hrdps_lon_min and lon <= @hrdps_lon_max end defp float_range(start, stop, step) do count = round((stop - start) / step) + 1 Enum.map(0..(count - 1), fn i -> start + i * step end) end end