diff --git a/lib/microwaveprop/propagation/band_config.ex b/lib/microwaveprop/propagation/band_config.ex index 0d4caf78..19c858da 100644 --- a/lib/microwaveprop/propagation/band_config.ex +++ b/lib/microwaveprop/propagation/band_config.ex @@ -906,4 +906,31 @@ defmodule Microwaveprop.Propagation.BandConfig do @doc "Returns the default refractivity scores as {beneficial, harmful}." @spec refractivity_default() :: {number(), number()} def refractivity_default, do: @refractivity_default + + @doc """ + Returns the propagation-mechanism stack for a band. + + Each band has a list of physical processes that can deliver a + contact at that frequency. Cell-level scoring takes the max over + these mechanisms — they're alternative paths, not additive — so + e.g. a Kp-6 aurora opening can light up 6 m even when the tropo + picture is poor. + + Defaults are derived from frequency: + - ≤ 432 MHz → `[:tropo, :aurora]` (auroral E-region scatter is a + 50 / 144 / 222 phenomenon, occasionally on 432 MHz, essentially + never above) + - > 432 MHz → `[:tropo]` + + Bands can override by setting `:mechanisms` directly in + `@band_configs`. Path-level scoring (in `Propagation.PathCompute`) + layers `:es` and `:f2` on top for the bands where those reach; + those are inherently path-distance-dependent so they don't enter + cell-level scoring. + """ + @spec mechanisms(map() | nil) :: [atom()] + def mechanisms(nil), do: [:tropo] + def mechanisms(%{mechanisms: list}) when is_list(list), do: list + def mechanisms(%{freq_mhz: f}) when f <= 432, do: [:tropo, :aurora] + def mechanisms(%{freq_mhz: _}), do: [:tropo] end diff --git a/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex index 37461a5f..94c47a4c 100644 --- a/lib/microwaveprop/propagation/scorer.ex +++ b/lib/microwaveprop/propagation/scorer.ex @@ -303,6 +303,74 @@ defmodule Microwaveprop.Propagation.Scorer do 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) + @doc """ + Standalone score for one propagation mechanism on this band. + + Returns a 0–100 integer. Unlike `aurora_boost/3` this does not + add to anything — it's the score *of that mechanism alone*, so + `cell_score/2` can take the max across the band's mechanism stack + rather than summing tropo with a post-hoc bonus. + + ## `:tropo` + + Delegates to `composite_score/2`'s integer score — the existing + weighted sum of HRRR-derived factors. + + ## `:aurora` + + Auroral E-region scatter is observed on 50 / 144 / 222 MHz, + occasionally on 432 MHz, essentially never above. Scores follow + the NOAA G-scale ceiling for each Kp band: + + * `kp` nil or `freq_mhz > 432` → 0 + * `kp < 4` → 0 (geomagnetically quiet) + * `kp 4` (unsettled) → 40 + * `kp 5` (G1) → 65 + * `kp 6` (G2) → 85 + * `kp ≥ 7` (G3+) → 100 + + Fractional `estimated_kp` from SWPC's per-minute feed truncates to + the lower integer band so threshold edges stay deterministic. + """ + @spec mechanism_score(atom(), map(), map()) :: integer() + def mechanism_score(:tropo, conditions, band_config) do + composite_score(conditions, band_config).score + end + + def mechanism_score(:aurora, conditions, band_config) do + aurora_only_score(conditions[:kp_index], band_config) + end + + @doc """ + Cell-level propagation score for a band: `max` over the band's + active mechanisms. + + The mechanism list comes from `BandConfig.mechanisms/1`. Tropo + applies everywhere; aurora applies on VHF / low UHF when Kp is + elevated. Es and F2 are inherently path-dependent and don't enter + cell-level scoring — `Propagation.PathCompute` handles those. + + Conditions must include whatever each mechanism reads. For + `:aurora` that's `:kp_index`; for `:tropo` it's the usual + composite-score inputs. + """ + @spec cell_score(map(), map()) :: integer() + def cell_score(conditions, band_config) do + band_config + |> BandConfig.mechanisms() + |> Enum.map(&mechanism_score(&1, conditions, band_config)) + |> Enum.max() + |> clamp_score() + end + + defp aurora_only_score(nil, _band_config), do: 0 + defp aurora_only_score(_kp, %{freq_mhz: f}) when f > 432, do: 0 + defp aurora_only_score(kp, _band_config) when kp >= 7, do: 100 + defp aurora_only_score(kp, _band_config) when kp >= 6, do: 85 + defp aurora_only_score(kp, _band_config) when kp >= 5, do: 65 + defp aurora_only_score(kp, _band_config) when kp >= 4, do: 40 + defp aurora_only_score(_kp, _band_config), do: 0 + 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 64d5c3a8..02f150a6 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -944,4 +944,152 @@ defmodule Microwaveprop.Propagation.ScorerTest do assert cond_map.longitude == -97.0 end end + + # ── mechanism_score/3 + cell_score/2 ──────────────────────────── + # + # Per-band propagation rules: each band's mechanism stack + # determines which physical processes can deliver a contact. + # `cell_score` takes the max over the band's active mechanisms so + # an aurora opening can score a 6 m cell even when the tropo + # picture is poor — they're alternative paths, not additive. + + describe "mechanism_score/3 :aurora" do + @vhf_2m %{freq_mhz: 144} + @uhf_70cm %{freq_mhz: 432} + @uhf_23cm %{freq_mhz: 1_296} + + test "nil Kp returns 0" do + assert Scorer.mechanism_score(:aurora, %{kp_index: nil}, @vhf_2m) == 0 + end + + test "freq > 432 MHz returns 0 regardless of Kp" do + assert Scorer.mechanism_score(:aurora, %{kp_index: 7}, @uhf_23cm) == 0 + end + + test "Kp < 4 returns 0 (geomagnetically quiet)" do + assert Scorer.mechanism_score(:aurora, %{kp_index: 0}, @vhf_2m) == 0 + assert Scorer.mechanism_score(:aurora, %{kp_index: 3}, @vhf_2m) == 0 + end + + test "ramps with Kp on VHF" do + assert Scorer.mechanism_score(:aurora, %{kp_index: 4}, @vhf_2m) == 40 + assert Scorer.mechanism_score(:aurora, %{kp_index: 5}, @vhf_2m) == 65 + assert Scorer.mechanism_score(:aurora, %{kp_index: 6}, @vhf_2m) == 85 + assert Scorer.mechanism_score(:aurora, %{kp_index: 7}, @vhf_2m) == 100 + assert Scorer.mechanism_score(:aurora, %{kp_index: 9}, @vhf_2m) == 100 + end + + test "70 cm is included — auroral propagation does occur there" do + assert Scorer.mechanism_score(:aurora, %{kp_index: 6}, @uhf_70cm) == 85 + end + + test "fractional Kp truncates to the lower band" do + assert Scorer.mechanism_score(:aurora, %{kp_index: 4.67}, @vhf_2m) == 40 + assert Scorer.mechanism_score(:aurora, %{kp_index: 5.33}, @vhf_2m) == 65 + end + end + + describe "mechanism_score/3 :tropo" do + test "delegates to composite_score and returns the integer score" do + conditions = %{ + abs_humidity: 8.0, + temp_f: 70.0, + dewpoint_f: 60.0, + min_refractivity_gradient: -100.0, + bl_depth_m: 500.0, + month: 7, + rain_rate_mmhr: 0.0, + pwat_mm: 30.0, + tod_score: 60, + sky_score: 70, + wind_score: 80, + pressure_score: 75, + latitude: 33.0, + longitude: -97.0 + } + + band = BandConfig.get(10_000) + expected = Scorer.composite_score(conditions, band).score + assert Scorer.mechanism_score(:tropo, conditions, band) == expected + end + end + + describe "cell_score/2" do + @tropo_conditions %{ + abs_humidity: 8.0, + temp_f: 70.0, + dewpoint_f: 60.0, + min_refractivity_gradient: -150.0, + bl_depth_m: 400.0, + month: 7, + rain_rate_mmhr: 0.0, + pwat_mm: 35.0, + tod_score: 60, + sky_score: 70, + wind_score: 80, + pressure_score: 75, + latitude: 33.0, + longitude: -97.0 + } + + test "tropo-only band (>432 MHz) returns the tropo score" do + band = BandConfig.get(10_000) + tropo = Scorer.composite_score(@tropo_conditions, band).score + assert Scorer.cell_score(@tropo_conditions, band) == tropo + end + + test "VHF in a Kp 6 storm returns aurora when it beats tropo" do + # Atmosphere set up to deliver a low tropo (anti-duct: zero + # gradient, deep BL, no moisture). Aurora at Kp 6 == 85 wins. + bad = %{ + @tropo_conditions + | abs_humidity: 1.0, + temp_f: 30.0, + dewpoint_f: 0.0, + min_refractivity_gradient: 0.0, + bl_depth_m: 1500.0, + month: 1, + rain_rate_mmhr: 5.0, + pwat_mm: 5.0, + tod_score: 30, + sky_score: 5, + wind_score: 20, + pressure_score: 30 + } + + band = BandConfig.get(144) + conditions = Map.put(bad, :kp_index, 6) + tropo = Scorer.composite_score(conditions, band).score + score = Scorer.cell_score(conditions, band) + assert score == max(tropo, 85) + assert score == 85 + end + + test "VHF with quiet Kp returns the tropo score" do + band = BandConfig.get(144) + conditions = Map.put(@tropo_conditions, :kp_index, 0) + tropo = Scorer.composite_score(conditions, band).score + assert Scorer.cell_score(conditions, band) == tropo + end + end + + describe "BandConfig.mechanisms/1" do + test "VHF / low UHF (≤432 MHz) gets tropo + aurora" do + assert BandConfig.mechanisms(BandConfig.get(50)) == [:tropo, :aurora] + assert BandConfig.mechanisms(BandConfig.get(144)) == [:tropo, :aurora] + assert BandConfig.mechanisms(BandConfig.get(222)) == [:tropo, :aurora] + assert BandConfig.mechanisms(BandConfig.get(432)) == [:tropo, :aurora] + end + + test "microwave (>432 MHz) is tropo-only" do + assert BandConfig.mechanisms(BandConfig.get(902)) == [:tropo] + assert BandConfig.mechanisms(BandConfig.get(1_296)) == [:tropo] + assert BandConfig.mechanisms(BandConfig.get(10_000)) == [:tropo] + assert BandConfig.mechanisms(BandConfig.get(241_000)) == [:tropo] + end + + test "an explicit :mechanisms override on a band wins" do + assert BandConfig.mechanisms(%{freq_mhz: 144, mechanisms: [:tropo]}) == [:tropo] + end + end end