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.
This commit is contained in:
parent
82bf248ab7
commit
604140220a
4 changed files with 218 additions and 19 deletions
111
lib/microwaveprop/propagation/region.ex
Normal file
111
lib/microwaveprop/propagation/region.ex
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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
|
||||
|
|
@ -204,12 +204,29 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
|
||||
# ── Factor 6: Season ─────────────────────────────────────────────
|
||||
|
||||
@doc "Scores the seasonal effect for the given month and band config."
|
||||
@spec score_season(integer(), map()) :: integer()
|
||||
def score_season(month, %{seasonal_base: base_map, seasonal_adj: adj_map}) do
|
||||
@doc """
|
||||
Scores the seasonal effect for the given month and band config.
|
||||
|
||||
When lat/lon are provided, applies a regional adjustment multiplier
|
||||
from `Propagation.Region` on top of the band's `seasonal_base` +
|
||||
`seasonal_adj`. This corrects for the meteorologist's observation
|
||||
that Gulf coast August is better than the uniform base suggests,
|
||||
while Corn Belt August is worse.
|
||||
"""
|
||||
@spec score_season(integer(), float | nil, float | nil, map()) :: integer()
|
||||
def score_season(month, lat, lon, %{seasonal_base: base_map, seasonal_adj: adj_map}) do
|
||||
base = Map.get(base_map, month, 50)
|
||||
adj = Map.get(adj_map, month, 0)
|
||||
min(100, max(0, base + adj))
|
||||
|
||||
region_mult =
|
||||
if is_number(lat) and is_number(lon) do
|
||||
region = Microwaveprop.Propagation.Region.for_point(lat, lon)
|
||||
Microwaveprop.Propagation.Region.seasonal_adjustment(region, month)
|
||||
else
|
||||
1.0
|
||||
end
|
||||
|
||||
round(min(100, max(0, (base + adj) * region_mult)))
|
||||
end
|
||||
|
||||
# ── Factor 7: Wind ───────────────────────────────────────────────
|
||||
|
|
@ -341,7 +358,7 @@ defmodule Microwaveprop.Propagation.Scorer do
|
|||
band_config
|
||||
),
|
||||
sky: score_sky(conditions.sky_cover_pct),
|
||||
season: score_season(conditions.month, band_config),
|
||||
season: score_season(conditions.month, conditions[:latitude], conditions[:longitude], band_config),
|
||||
wind: score_wind(conditions.wind_speed_kts),
|
||||
rain: score_rain(conditions.rain_rate_mmhr, band_config),
|
||||
pwat: score_pwat(conditions[:pwat_mm], band_config),
|
||||
|
|
|
|||
57
test/microwaveprop/propagation/region_test.exs
Normal file
57
test/microwaveprop/propagation/region_test.exs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
defmodule Microwaveprop.Propagation.RegionTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Microwaveprop.Propagation.Region
|
||||
|
||||
describe "for_point/2" do
|
||||
test "Dallas TX is in southern_plains" do
|
||||
assert Region.for_point(32.8, -96.8) == :southern_plains
|
||||
end
|
||||
|
||||
test "Houston TX is in gulf_coast" do
|
||||
assert Region.for_point(29.8, -95.4) == :gulf_coast
|
||||
end
|
||||
|
||||
test "Des Moines IA is in corn_belt" do
|
||||
assert Region.for_point(41.6, -93.6) == :corn_belt
|
||||
end
|
||||
|
||||
test "Tucson AZ is in desert_southwest" do
|
||||
assert Region.for_point(32.2, -110.9) == :desert_southwest
|
||||
end
|
||||
|
||||
test "Seattle WA is in pacific_northwest" do
|
||||
assert Region.for_point(47.6, -122.3) == :pacific_northwest
|
||||
end
|
||||
|
||||
test "New York NY is in northeast" do
|
||||
assert Region.for_point(40.7, -74.0) == :northeast
|
||||
end
|
||||
|
||||
test "Atlanta GA is in southeast" do
|
||||
assert Region.for_point(33.7, -84.4) == :southeast
|
||||
end
|
||||
|
||||
test "Denver CO falls into a region" do
|
||||
region = Region.for_point(39.7, -104.9)
|
||||
assert is_atom(region)
|
||||
end
|
||||
|
||||
test "returns :other for points outside CONUS" do
|
||||
assert Region.for_point(19.0, -155.0) == :other
|
||||
end
|
||||
end
|
||||
|
||||
describe "seasonal_adjustment/2" do
|
||||
test "returns a multiplier for known regions and months" do
|
||||
adj = Region.seasonal_adjustment(:gulf_coast, 8)
|
||||
assert is_float(adj)
|
||||
# Gulf coast August should be drier → higher seasonal score than default
|
||||
assert adj >= 0.8 and adj <= 1.3
|
||||
end
|
||||
|
||||
test "returns 1.0 for unknown regions" do
|
||||
assert Region.seasonal_adjustment(:other, 6) == 1.0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -286,33 +286,47 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
end
|
||||
end
|
||||
|
||||
# ── score_season/2 ───────────────────────────────────────────────
|
||||
# ── score_season/4 ───────────────────────────────────────────────
|
||||
|
||||
describe "score_season/2" do
|
||||
test "10 GHz summer peak" do
|
||||
# July base = 95, adj = 0 -> 95
|
||||
assert Scorer.score_season(7, @band_10g) == 95
|
||||
describe "score_season/4" do
|
||||
test "10 GHz summer peak (no region)" do
|
||||
# July base = 95, adj = 0, no region mult -> 95
|
||||
assert Scorer.score_season(7, nil, nil, @band_10g) == 95
|
||||
end
|
||||
|
||||
test "10 GHz winter low" do
|
||||
# March base = 22, adj = 0 -> 22
|
||||
assert Scorer.score_season(3, @band_10g) == 22
|
||||
assert Scorer.score_season(3, nil, nil, @band_10g) == 22
|
||||
end
|
||||
|
||||
test "24 GHz winter peak" do
|
||||
# November base = 96, adj = 0 -> 96
|
||||
assert Scorer.score_season(11, @band_24g) == 96
|
||||
assert Scorer.score_season(11, nil, nil, @band_24g) == 96
|
||||
end
|
||||
|
||||
test "24 GHz summer with adjustment" do
|
||||
# July base = 18, adj = -10 -> 8
|
||||
assert Scorer.score_season(7, @band_24g) == 8
|
||||
assert Scorer.score_season(7, nil, nil, @band_24g) == 8
|
||||
end
|
||||
|
||||
test "clamped to 0-100" do
|
||||
# Even if base + adj > 100, clamp
|
||||
assert Scorer.score_season(1, @band_10g) >= 0
|
||||
assert Scorer.score_season(1, @band_10g) <= 100
|
||||
assert Scorer.score_season(1, nil, nil, @band_10g) >= 0
|
||||
assert Scorer.score_season(1, nil, nil, @band_10g) <= 100
|
||||
end
|
||||
|
||||
test "applies regional adjustment for Gulf coast August" do
|
||||
# Gulf coast August mult = 1.15, so score = base * 1.15
|
||||
gulf_lat = 29.0
|
||||
gulf_lon = -95.0
|
||||
no_region = Scorer.score_season(8, nil, nil, @band_10g)
|
||||
gulf_score = Scorer.score_season(8, gulf_lat, gulf_lon, @band_10g)
|
||||
assert gulf_score > no_region
|
||||
end
|
||||
|
||||
test "applies regional adjustment for Corn Belt August" do
|
||||
# Corn Belt August mult = 0.80, so score = base * 0.80
|
||||
iowa_lat = 42.0
|
||||
iowa_lon = -93.0
|
||||
no_region = Scorer.score_season(8, nil, nil, @band_10g)
|
||||
iowa_score = Scorer.score_season(8, iowa_lat, iowa_lon, @band_10g)
|
||||
assert iowa_score < no_region
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue