From 604140220a3435fc152bf0c652e825658516fdf9 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 10 Apr 2026 08:39:01 -0500 Subject: [PATCH] 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. --- lib/microwaveprop/propagation/region.ex | 111 ++++++++++++++++++ lib/microwaveprop/propagation/scorer.ex | 27 ++++- .../microwaveprop/propagation/region_test.exs | 57 +++++++++ .../microwaveprop/propagation/scorer_test.exs | 42 ++++--- 4 files changed, 218 insertions(+), 19 deletions(-) create mode 100644 lib/microwaveprop/propagation/region.ex create mode 100644 test/microwaveprop/propagation/region_test.exs diff --git a/lib/microwaveprop/propagation/region.ex b/lib/microwaveprop/propagation/region.ex new file mode 100644 index 00000000..642d818c --- /dev/null +++ b/lib/microwaveprop/propagation/region.ex @@ -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 diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 042498e5..501f908f 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -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), diff --git a/test/microwaveprop/propagation/region_test.exs b/test/microwaveprop/propagation/region_test.exs new file mode 100644 index 00000000..90fc1e85 --- /dev/null +++ b/test/microwaveprop/propagation/region_test.exs @@ -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 diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs index aca076ad..cad88678 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -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