prop/lib/microwaveprop/propagation/region.ex
Graham McIntire 604140220a Phase 7: Regionalized seasonal scoring
Add Propagation.Region module with 8 CONUS climate zones (gulf_coast,
southeast, southern_plains, corn_belt, northeast, desert_southwest,
pacific_northwest, mountain_west) and per-region monthly seasonal
adjustment multipliers.

The scorer's score_season now takes lat/lon and applies a regional
multiplier from Region.seasonal_adjustment on top of the band's
seasonal_base + seasonal_adj. Gulf coast August gets a 1.15x boost
(drier, better for ducting) while Corn Belt August gets a 0.80x
penalty (corn evapotranspiration = miserable dewpoints).

Adjustments are hand-tuned starting points from the meteorologist's
qualitative guidance. Phase 9 recalibration will refine them from
backtest data.
2026-04-10 08:39:01 -05:00

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