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:
parent
1086f52c85
commit
7702b9e161
4 changed files with 184 additions and 22 deletions
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue