diff --git a/lib/microwaveprop/propagation.ex b/lib/microwaveprop/propagation.ex index 2bf742d0..b680bcd4 100644 --- a/lib/microwaveprop/propagation.ex +++ b/lib/microwaveprop/propagation.ex @@ -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) diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 2979132f..37461a5f 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -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 diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs index f2208efc..64d5c3a8 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -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 diff --git a/test/microwaveprop/propagation_test.exs b/test/microwaveprop/propagation_test.exs index ce63106a..06b9e359 100644 --- a/test/microwaveprop/propagation_test.exs +++ b/test/microwaveprop/propagation_test.exs @@ -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