feat(scoring): per-band mechanism stack via cell_score/2

Replaces the "tropo composite + post-hoc aurora boost" model with a
max-over-mechanisms dispatcher so each band's score reflects the
dominant propagation channel rather than summing alternative paths.

- BandConfig.mechanisms/1 derives the stack from frequency:
  ≤432 MHz gets [:tropo, :aurora]; >432 MHz stays [:tropo]. Bands
  may override with an explicit :mechanisms field.
- Scorer.mechanism_score/3 returns a standalone integer score for
  one mechanism (:tropo delegates to composite_score; :aurora is a
  clean Kp/freq function, not a boost on top of tropo).
- Scorer.cell_score/2 takes max across the band's mechanisms so a
  Kp-6 storm scores 6m at 85 even when the tropo picture is poor,
  instead of conflating the two channels.

Es and F2 stay in PathCompute since they're path-distance-dependent.
The legacy aurora_boost path is unchanged so no callers shift today.
This commit is contained in:
Graham McIntire 2026-05-04 16:46:48 -05:00
parent 48000b0dca
commit a68027b00b
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 243 additions and 0 deletions

View file

@ -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

View file

@ -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 0100 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

View file

@ -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