prop/lib/microwaveprop/propagation/region.ex
Graham McIntire 2fd88a94ea
Some checks failed
Build and Push / Build and Push Docker Image (push) Waiting to run
Build prop-grid-rs / Test, build, push (push) Failing after 3m41s
fix(propagation): 7 pipeline bugs + validation harnesses + model improvements
Phase A — 7 pipeline bugs:
- retain_scores_window: fix timeline self-deletion (NOTIFY payload run_time|valid_time)
- Rust/Elixir weight divergence: Rust loads band_weights.json at startup
- Aurora boost ported to Rust (Kp query + per-cell boost for bands <= 432 MHz)
- Commercial-link boost applied in Rust per-cell scoring
- f00 native gradient preferred over pressure-level gradient
- HRDPS files: greedy regex fixed, now visible to timeline/prune/retain
- GEFS/HRRR collision: GEFS namespace as .gefs.prop, merge at read

Phase B — Validation harness:
- scripts/validate_algo.py: out-of-sample Spearman rho(score,distance) + baselines
- docs/algo-reports/validation-2026-08-01.{json,md}

Phase C — Forecast-skill evaluation:
- scripts/validate_forecast.py: skill degradation by lead time (0h-24h)
- docs/algo-reports/forecast-2026-08-01.{json,md}

Phase D — Calibration + model improvements:
- recalibrate.py: validation gate before weight deploy
- Recalibrator: Nx.max(0.0) replaces Nx.abs(), L2 regularization, val-set integrity
- ML train/serve defaults unified; prop_compare null-handling skew fixed
- Latitude-aware sunrise: solar-declination sunrise_hour(lat,month) (Elixir + Rust)
- Path scoring: wind/sky/rain from HRRR (was ~30% of composite weight silent)
- Region multiplier documented as unvalidated; PWAT/refractivity doc-vs-code noted
- algo.md: synced scoring sections, marked retired features, D5/D6 changelog
- Credo: path_compute cyclomatic complexity bumped (6->10 fields) — informational only
2026-08-01 19:28:41 -05:00

118 lines
4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
#
# ⚠ VALIDATION STATUS: hand-tuned, never backtest-validated.
# These multipliers (0.71.3 on the 11%-weight season factor) are based
# on the meteorologist's qualitative guidance from April 2026. A full
# backtest evaluation against out-of-sample contacts is pending.
# Until validated, treat these as experimental priors — the uniform
# seasonal_base tables documented in algo.md Part 4 §5 already provide
# the data-calibrated seasonal signal from RAOB ducting probabilities.
#
# Phase 9 recalibration: refine from backtest data, or remove if no
# measurable improvement in holdout ρ(score, distance).
@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