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
|
||||
|
||||
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 ->
|
||||
result = Scorer.composite_score(conditions, band_config)
|
||||
|
||||
boosted_score =
|
||||
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
|
||||
conditions
|
||||
|> Scorer.composite_score(band_config)
|
||||
|> finalize_band_result(band_config, link_degradation, kp_index, duct_info)
|
||||
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
|
||||
# reflectivity-derived rate. NEXRAD catches fast-moving convective cells 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
|
||||
profile
|
||||
|> Map.put(:kp_index, current_kp_index())
|
||||
|> score_grid_point(valid_time, lat, lon)
|
||||
|> Enum.find(fn r -> r.band_mhz == band_mhz end)
|
||||
|> case do
|
||||
|
|
@ -582,6 +598,20 @@ defmodule Microwaveprop.Propagation do
|
|||
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
|
||||
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)
|
||||
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
|
||||
value |> round() |> max(0) |> min(100)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -430,6 +430,62 @@ defmodule Microwaveprop.Propagation.ScorerTest do
|
|||
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 ──────────────────────────────────────────────────
|
||||
|
||||
describe "score_sky/1" do
|
||||
|
|
|
|||
|
|
@ -61,6 +61,50 @@ defmodule Microwaveprop.PropagationTest do
|
|||
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
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue