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
111 lines
3.6 KiB
Elixir
111 lines
3.6 KiB
Elixir
defmodule Microwaveprop.Propagation.Region do
|
|
@moduledoc """
|
|
Classifies CONUS grid points into climatological regions for
|
|
band-specific seasonal scoring adjustments.
|
|
|
|
The meteorologist's April 2026 review noted that the existing
|
|
uniform seasonal scoring is wrong: Gulf coast August is drier and
|
|
better for propagation than June/July, while Iowa August is peak
|
|
corn evapotranspiration with brutally high dewpoints. Same month,
|
|
opposite effect.
|
|
|
|
This module provides:
|
|
- `for_point/2` — classifies a lat/lon into one of ~8 regions
|
|
- `seasonal_adjustment/2` — returns a multiplier (0.7-1.3) that
|
|
the scorer applies on top of the band's `seasonal_base` score.
|
|
|
|
Region boundaries are deliberately simple bounding boxes rather
|
|
than precise climate zone shapefiles. The scoring impact of getting
|
|
a boundary pixel wrong is a few points out of 100; the impact of
|
|
having no regional adjustment at all is the entire seasonal factor
|
|
being wrong for half the country.
|
|
"""
|
|
|
|
@regions [
|
|
{:gulf_coast, {25.0, 32.0}, {-100.0, -80.0}},
|
|
{:southeast, {30.0, 37.0}, {-90.0, -75.0}},
|
|
{:southern_plains, {32.0, 38.0}, {-105.0, -93.0}},
|
|
{:corn_belt, {38.0, 48.0}, {-100.0, -82.0}},
|
|
{:northeast, {38.0, 48.0}, {-82.0, -67.0}},
|
|
{:desert_southwest, {30.0, 38.0}, {-120.0, -105.0}},
|
|
{:pacific_northwest, {42.0, 50.0}, {-125.0, -115.0}},
|
|
{:mountain_west, {38.0, 48.0}, {-115.0, -100.0}}
|
|
]
|
|
|
|
@doc """
|
|
Classify a lat/lon point into a climatological region.
|
|
|
|
Returns an atom like `:gulf_coast`, `:corn_belt`, etc.
|
|
Returns `:other` for points outside any defined region (including
|
|
non-CONUS).
|
|
"""
|
|
@spec for_point(float, float) :: atom
|
|
def for_point(lat, lon) do
|
|
Enum.find_value(@regions, :other, fn {name, {lat_min, lat_max}, {lon_min, lon_max}} ->
|
|
if lat >= lat_min and lat <= lat_max and lon >= lon_min and lon <= lon_max do
|
|
name
|
|
end
|
|
end)
|
|
end
|
|
|
|
# Regional seasonal adjustments: multipliers on the band's seasonal_base
|
|
# score by month. 1.0 = no change; > 1.0 = better than the base suggests;
|
|
# < 1.0 = worse. Only months/regions where the uniform base is known to
|
|
# be wrong get non-1.0 values.
|
|
#
|
|
# These are hand-tuned starting points based on the meteorologist's
|
|
# qualitative guidance. Phase 9 recalibration will refine them from
|
|
# backtest data.
|
|
@seasonal_adjustments %{
|
|
gulf_coast: %{
|
|
# Gulf August is drier than June/July → better propagation
|
|
6 => 0.95,
|
|
7 => 0.95,
|
|
8 => 1.15,
|
|
9 => 1.10
|
|
},
|
|
corn_belt: %{
|
|
# Iowa July/August: corn evapotranspiration → high dewpoints → worse
|
|
6 => 1.05,
|
|
7 => 0.85,
|
|
8 => 0.80,
|
|
9 => 0.95
|
|
},
|
|
southeast: %{
|
|
# Similar to Gulf but less extreme
|
|
7 => 0.97,
|
|
8 => 1.08
|
|
},
|
|
southern_plains: %{
|
|
# TX panhandle drier than Gulf but wetter than desert
|
|
8 => 1.05
|
|
},
|
|
desert_southwest: %{
|
|
# Monsoon July/Aug brings moisture → briefly good ducting
|
|
7 => 1.10,
|
|
8 => 1.10,
|
|
9 => 1.05
|
|
},
|
|
pacific_northwest: %{
|
|
# Marine layer summer inversions
|
|
6 => 1.15,
|
|
7 => 1.20,
|
|
8 => 1.20,
|
|
9 => 1.10
|
|
}
|
|
}
|
|
|
|
@doc """
|
|
Seasonal score multiplier for a given region and month (1-12).
|
|
|
|
Returns a float in [0.7, 1.3]. The scorer multiplies the band's
|
|
`seasonal_base[month]` by this value. Unknown regions or months
|
|
without an adjustment return 1.0.
|
|
"""
|
|
@spec seasonal_adjustment(atom, integer) :: float
|
|
def seasonal_adjustment(region, month) do
|
|
@seasonal_adjustments
|
|
|> Map.get(region, %{})
|
|
|> Map.get(month, 1.0)
|
|
end
|
|
end
|