feat(scoring): aurora boost on VHF/low-UHF during geomagnetic storms

`Scorer.aurora_boost(score, kp, band_config)` is a terminal boost
applied after `composite_score` in the same shape as the existing
`commercial_link_boost`. It tracks the NOAA G-scale (Kp 5+ opens
auroral E-region paths up to ~2500 km) and is freq-gated to
≤ 432 MHz — auroral propagation is observed almost exclusively on
50/144/222, occasionally on 432, and never at microwave.

Boost magnitudes:
* Kp < 4 (quiet) — pass-through
* Kp 4 — +5
* Kp 5 (G1) — +15
* Kp 6 (G2) — +25
* Kp ≥ 7 (G3+) — +35, clamped to 100

Wired into `Propagation.score_grid_point/4` via the existing
`hrrr_profile` map: callers populate `:kp_index` once per cycle so
the per-grid-point band loop never queries the DB. The UI fallback
path (`factors_from_profile/5`) sources Kp from
`SpaceWeather.latest_kp/0` directly. Other call sites
(GEFS forecast, path-compute, contact-show) can opt in by adding
`:kp_index` to their profile map.

Diagnostic factor `:kp_index` is stashed on VHF/low-UHF results so
the operator-facing factor breakdown can show why a band scored
above its quiet-conditions baseline. Microwave bands don't carry
the diagnostic — they're never affected by the boost.

Refactored `score_with_algorithm/7` to delegate band-level
finalization to `finalize_band_result/5`, keeping the parent
function under credo's cyclomatic-complexity limit.

NOTE: this only affects the Elixir scoring path — the live grid
served by `prop_grid_rs` (Rust) does its own scoring and does not
yet honor Kp. Adding aurora to the Rust side is follow-up work.
This commit is contained in:
Graham McIntire 2026-05-04 16:02:49 -05:00
parent 1086f52c85
commit 7702b9e161
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 184 additions and 22 deletions

View file

@ -118,33 +118,48 @@ defmodule Microwaveprop.Propagation do
end end
link_degradation = hrrr_profile[:commercial_link_degradation] link_degradation = hrrr_profile[:commercial_link_degradation]
# Kp is grid-point-invariant within a cycle. Callers populate
# `:kp_index` once per scoring run (see SpaceWeather.latest_kp/0)
# so we never query the DB inside the per-point band loop. Nil
# means no aurora boost — the same fallback that quiet
# geomagnetic conditions produce.
kp_index = hrrr_profile[:kp_index]
Enum.map(BandConfig.all_bands(), fn band_config -> Enum.map(BandConfig.all_bands(), fn band_config ->
result = Scorer.composite_score(conditions, band_config) conditions
|> Scorer.composite_score(band_config)
boosted_score = |> finalize_band_result(band_config, link_degradation, kp_index, duct_info)
if link_degradation do
Scorer.commercial_link_boost(result.score, link_degradation)
else
result.score
end
result =
result
|> Map.put(:score, boosted_score)
|> Map.put(:band_mhz, band_config.freq_mhz)
result =
if link_degradation do
put_in(result, [:factors, :commercial_link_degradation], link_degradation)
else
result
end
if duct_info, do: put_in(result, [:factors, :duct_info], duct_info), else: result
end) end)
end end
# Apply terminal boosts and stash diagnostic factor entries. Split
# out of `score_with_algorithm/7` to keep its cyclomatic complexity
# within credo's per-function limit; this helper owns the
# band-level conditional plumbing instead.
defp finalize_band_result(result, band_config, link_degradation, kp_index, duct_info) do
boosted_score =
result.score
|> maybe_apply_link_boost(link_degradation)
|> Scorer.aurora_boost(kp_index, band_config)
result
|> Map.put(:score, boosted_score)
|> Map.put(:band_mhz, band_config.freq_mhz)
|> maybe_put_factor(:commercial_link_degradation, link_degradation)
|> maybe_put_kp_factor(kp_index, band_config)
|> maybe_put_factor(:duct_info, duct_info)
end
defp maybe_apply_link_boost(score, nil), do: score
defp maybe_apply_link_boost(score, link_degradation), do: Scorer.commercial_link_boost(score, link_degradation)
defp maybe_put_factor(result, _key, nil), do: result
defp maybe_put_factor(result, key, value), do: put_in(result, [:factors, key], value)
defp maybe_put_kp_factor(result, nil, _band_config), do: result
defp maybe_put_kp_factor(result, _kp, %{freq_mhz: f}) when f > 432, do: result
defp maybe_put_kp_factor(result, kp, _band_config), do: put_in(result, [:factors, :kp_index], kp)
# Pick the heavier of HRRR's hourly accumulation-derived rate and NEXRAD's # Pick the heavier of HRRR's hourly accumulation-derived rate and NEXRAD's
# reflectivity-derived rate. NEXRAD catches fast-moving convective cells that # reflectivity-derived rate. NEXRAD catches fast-moving convective cells that
# fall between HRRR hourly analyses; HRRR catches broad stratiform rain that # fall between HRRR hourly analyses; HRRR catches broad stratiform rain that
@ -574,6 +589,7 @@ defmodule Microwaveprop.Propagation do
defp factors_from_profile(band_mhz, valid_time, profile, lat, lon) do defp factors_from_profile(band_mhz, valid_time, profile, lat, lon) do
profile profile
|> Map.put(:kp_index, current_kp_index())
|> score_grid_point(valid_time, lat, lon) |> score_grid_point(valid_time, lat, lon)
|> Enum.find(fn r -> r.band_mhz == band_mhz end) |> Enum.find(fn r -> r.band_mhz == band_mhz end)
|> case do |> case do
@ -582,6 +598,20 @@ defmodule Microwaveprop.Propagation do
end end
end end
# Latest geomagnetic Kp from SWPC, used by the aurora boost. Returns
# nil if SpaceWeather hasn't been ingested yet (boost falls back to
# quiet-conditions no-op). Prefer the integer `kp_index` over
# `estimated_kp` so the threshold edges in `Scorer.aurora_boost/3`
# are deterministic during the 3-hour window between official Kp
# publications.
defp current_kp_index do
case Microwaveprop.SpaceWeather.latest_kp() do
%{kp_index: kp} when is_integer(kp) -> kp
%{estimated_kp: kp} when is_number(kp) -> trunc(kp)
_ -> nil
end
end
defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do defp latest_profile_time_within_lookback(%DateTime{} = valid_time) do
lookback_cutoff = DateTime.add(valid_time, -@fallback_profile_lookback_hours * 3600, :second) lookback_cutoff = DateTime.add(valid_time, -@fallback_profile_lookback_hours * 3600, :second)

View file

@ -271,6 +271,38 @@ defmodule Microwaveprop.Propagation.Scorer do
clamp_score(score + bonus) clamp_score(score + bonus)
end end
@doc """
Aurora-conditioned terminal boost for VHF/low-UHF bands during
active geomagnetic conditions.
Auroral E-region scatter is observed almost exclusively on
50/144/222 MHz, occasionally on 432 MHz, and essentially never at
microwave (>432 MHz). The boost magnitude follows the NOAA G-scale:
Kp 5+ storms open auroral paths up to ~2500 km, Kp 6 events are
reliably workable from much of CONUS, and Kp 7+ are continent-wide.
* `freq_mhz > 432` pass-through (microwave doesn't see aurora)
* `kp < 4` pass-through (geomagnetically quiet)
* `kp 4` (unsettled) +5 (mild)
* `kp 5` (G1) +15 (moderate)
* `kp 6` (G2) +25 (strong)
* `kp >= 7` (G3+) +35 (saturated)
Fractional `estimated_kp` from SWPC's per-minute feed rounds down
to the lower integer band so the threshold edges are deterministic.
Returns the score clamped to 0-100.
"""
@spec aurora_boost(number(), number() | nil, map()) :: integer()
def aurora_boost(score, nil, _band_config), do: clamp_score(score)
def aurora_boost(score, _kp, %{freq_mhz: f}) when f > 432, do: clamp_score(score)
def aurora_boost(score, kp, _band_config) when kp >= 7, do: clamp_score(score + 35)
def aurora_boost(score, kp, _band_config) when kp >= 6, do: clamp_score(score + 25)
def aurora_boost(score, kp, _band_config) when kp >= 5, do: clamp_score(score + 15)
def aurora_boost(score, kp, _band_config) when kp >= 4, do: clamp_score(score + 5)
def aurora_boost(score, _kp, _band_config), do: clamp_score(score)
defp clamp_score(value) do defp clamp_score(value) do
value |> round() |> max(0) |> min(100) value |> round() |> max(0) |> min(100)
end end

View file

@ -430,6 +430,62 @@ defmodule Microwaveprop.Propagation.ScorerTest do
end end
end end
describe "aurora_boost/3" do
# Auroral E-region scatter is a 50/144/222 phenomenon (rare on
# 432, essentially never above). Boost magnitude tracks the NOAA
# G-scale: Kp 5+ events open paths up to ~2500 km. Below Kp 4 the
# geomagnetic field is quiet and the boost is a no-op.
@vhf2m %{freq_mhz: 144}
@uhf %{freq_mhz: 432}
@microwave10g %{freq_mhz: 10_000}
test "nil Kp is a no-op" do
assert Scorer.aurora_boost(60, nil, @vhf2m) == 60
end
test "microwave bands (>432 MHz) pass through regardless of Kp" do
assert Scorer.aurora_boost(60, 7, @microwave10g) == 60
assert Scorer.aurora_boost(60, 9, @microwave10g) == 60
end
test "geomagnetically quiet (Kp < 4) is a no-op on VHF" do
assert Scorer.aurora_boost(60, 0, @vhf2m) == 60
assert Scorer.aurora_boost(60, 3, @vhf2m) == 60
end
test "Kp 4 (unsettled) is a small boost on VHF" do
assert Scorer.aurora_boost(60, 4, @vhf2m) == 65
end
test "Kp 5 (G1 storm) is a moderate boost on VHF" do
assert Scorer.aurora_boost(60, 5, @vhf2m) == 75
end
test "Kp 6 (G2 storm) is a strong boost on VHF" do
assert Scorer.aurora_boost(60, 6, @vhf2m) == 85
end
test "Kp 7+ (G3+ storm) saturates the boost" do
assert Scorer.aurora_boost(60, 7, @vhf2m) == 95
assert Scorer.aurora_boost(60, 9, @vhf2m) == 95
end
test "boost is clamped to 100" do
assert Scorer.aurora_boost(80, 7, @vhf2m) == 100
end
test "70cm (432 MHz) is included — auroral propagation does occur there" do
assert Scorer.aurora_boost(60, 6, @uhf) == 85
end
test "fractional Kp (estimated_kp) rounds down to the band" do
# 4.67 → still in the Kp-4 (unsettled) band, +5 boost
assert Scorer.aurora_boost(60, 4.67, @vhf2m) == 65
# 5.33 → into Kp-5 (G1) band, +15 boost
assert Scorer.aurora_boost(60, 5.33, @vhf2m) == 75
end
end
# ── score_sky/1 ────────────────────────────────────────────────── # ── score_sky/1 ──────────────────────────────────────────────────
describe "score_sky/1" do describe "score_sky/1" do

View file

@ -61,6 +61,50 @@ defmodule Microwaveprop.PropagationTest do
end) end)
end end
test "kp_index in the profile boosts VHF/UHF scores during geomagnetic storms" do
# Same profile, once geomagnetically quiet, once during a Kp 6
# event. The aurora boost lifts 50/144/222/432 scores and is a
# no-op at microwave (>432 MHz).
base_profile = %{
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1013.0,
hpbl_m: 500.0,
wind_u: 3.0,
wind_v: 2.0,
cloud_cover_pct: 15.0,
precip_mm: 0.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 25.0, "dwpc" => 18.0, "hght" => 100.0},
%{"pres" => 950.0, "tmpc" => 19.0, "dwpc" => 10.0, "hght" => 600.0}
]
}
valid_time = ~U[2026-07-15 13:00:00Z]
quiet = Propagation.score_grid_point(base_profile, valid_time, 33.0, -97.0)
stormy =
base_profile
|> Map.put(:kp_index, 6)
|> Propagation.score_grid_point(valid_time, 33.0, -97.0)
assert Enum.find(stormy, &(&1.band_mhz == 144)).score >
Enum.find(quiet, &(&1.band_mhz == 144)).score
assert Enum.find(stormy, &(&1.band_mhz == 432)).score >
Enum.find(quiet, &(&1.band_mhz == 432)).score
# 10 GHz must be unchanged — aurora doesn't reach microwave.
assert Enum.find(stormy, &(&1.band_mhz == 10_000)).score ==
Enum.find(quiet, &(&1.band_mhz == 10_000)).score
# VHF result should expose kp_index in factors for UI/diagnostics.
assert Enum.find(stormy, &(&1.band_mhz == 144)).factors.kp_index == 6
# Microwave should NOT expose kp_index — the boost doesn't apply there.
refute Map.has_key?(Enum.find(stormy, &(&1.band_mhz == 10_000)).factors, :kp_index)
end
test "NEXRAD reflectivity drops the 24 GHz rain score when HRRR precip_mm is 0" do test "NEXRAD reflectivity drops the 24 GHz rain score when HRRR precip_mm is 0" do
# Same profile, once without NEXRAD and once with a heavy-rain dBZ. The # Same profile, once without NEXRAD and once with a heavy-rain dBZ. The
# 24 GHz rain-score factor must drop because NEXRAD can report rain that # 24 GHz rain-score factor must drop because NEXRAD can report rain that