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:
parent
48000b0dca
commit
a68027b00b
3 changed files with 243 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue