From 60461ffe328caa804d774d172208680417447f84 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 30 Mar 2026 16:49:21 -0500 Subject: [PATCH] Add band configuration module for propagation scoring Single source of truth for all scoring parameters: weights, thresholds, seasonal tables, and per-band coefficients for 8 microwave bands (10G through 241G). Includes ITU-R P.838-3 rain attenuation coefficients, humidity effects, refractivity scoring thresholds, and sunrise/tier definitions. --- lib/microwaveprop/propagation/band_config.ex | 328 ++++++++++++++++++ .../propagation/band_config_test.exs | 294 ++++++++++++++++ 2 files changed, 622 insertions(+) create mode 100644 lib/microwaveprop/propagation/band_config.ex create mode 100644 test/microwaveprop/propagation/band_config_test.exs diff --git a/lib/microwaveprop/propagation/band_config.ex b/lib/microwaveprop/propagation/band_config.ex new file mode 100644 index 00000000..d839f892 --- /dev/null +++ b/lib/microwaveprop/propagation/band_config.ex @@ -0,0 +1,328 @@ +defmodule Microwaveprop.Propagation.BandConfig do + @moduledoc """ + Data-driven band configuration for the propagation scoring algorithm. + + This is the single source of truth for all scoring parameters: weights, + thresholds, seasonal tables, and band-specific coefficients. When the + algorithm is tuned or new bands are added, only this module changes. + """ + + @weights %{ + humidity: 0.20, + time_of_day: 0.20, + td_depression: 0.12, + refractivity: 0.10, + sky: 0.10, + season: 0.10, + wind: 0.06, + rain: 0.08, + pressure: 0.04 + } + + @sunrise_table [7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45] + + @tiers [ + %{min_score: 80, label: "EXCELLENT", color: "#00ffa3"}, + %{min_score: 65, label: "GOOD", color: "#7dffd4"}, + %{min_score: 50, label: "MARGINAL", color: "#ffe566"}, + %{min_score: 33, label: "POOR", color: "#ff9044"}, + %{min_score: 0, label: "NEGLIGIBLE", color: "#ff4f4f"} + ] + + @humidity_beneficial_thresholds [{4, 55}, {7, 70}, {10, 82}, {14, 90}, {18, 95}, {22, 88}] + @humidity_beneficial_default 75 + + @refractivity_thresholds [ + {-500, 98, 85}, + {-300, 92, 78}, + {-200, 80, 80}, + {-100, 65, 65}, + {-60, 55, 55} + ] + @refractivity_default {42, 42} + @shallow_bl_threshold_m 300 + @shallow_bl_score 82 + + @band_configs %{ + 10_000 => %{ + freq_mhz: 10_000, + label: "10 GHz", + o2_db_km: 0.007, + h2o_coeff: 0.0, + humidity_effect: :beneficial, + humidity_penalty: 0.0, + rain_k: 0.010, + rain_alpha: 1.28, + seasonal_base: %{ + 1 => 38, + 2 => 32, + 3 => 22, + 4 => 55, + 5 => 68, + 6 => 90, + 7 => 95, + 8 => 75, + 9 => 78, + 10 => 82, + 11 => 78, + 12 => 25 + }, + seasonal_adj: %{}, + typical_range_km: 200, + extended_range_km: 500, + exceptional_range_km: 1000 + }, + 24_000 => %{ + freq_mhz: 24_000, + label: "24 GHz", + o2_db_km: 0.02, + h2o_coeff: 0.002, + humidity_effect: :harmful, + humidity_penalty: 1.6, + rain_k: 0.070, + rain_alpha: 1.07, + seasonal_base: %{ + 1 => 88, + 2 => 84, + 3 => 68, + 4 => 62, + 5 => 51, + 6 => 34, + 7 => 18, + 8 => 18, + 9 => 48, + 10 => 68, + 11 => 96, + 12 => 88 + }, + seasonal_adj: %{5 => -4, 6 => -8, 7 => -10, 8 => -10, 9 => -4}, + typical_range_km: 100, + extended_range_km: 250, + exceptional_range_km: 500 + }, + 47_000 => %{ + freq_mhz: 47_000, + label: "47 GHz", + o2_db_km: 0.04, + h2o_coeff: 0.003, + humidity_effect: :harmful, + humidity_penalty: 1.0, + rain_k: 0.187, + rain_alpha: 0.93, + seasonal_base: %{ + 1 => 90, + 2 => 88, + 3 => 78, + 4 => 68, + 5 => 55, + 6 => 38, + 7 => 22, + 8 => 22, + 9 => 48, + 10 => 74, + 11 => 96, + 12 => 90 + }, + seasonal_adj: %{}, + typical_range_km: 70, + extended_range_km: 150, + exceptional_range_km: 300 + }, + 68_000 => %{ + freq_mhz: 68_000, + label: "68 GHz", + o2_db_km: 0.90, + h2o_coeff: 0.007, + humidity_effect: :harmful, + humidity_penalty: 1.4, + rain_k: 0.310, + rain_alpha: 0.86, + seasonal_base: %{ + 1 => 90, + 2 => 88, + 3 => 78, + 4 => 65, + 5 => 50, + 6 => 32, + 7 => 18, + 8 => 18, + 9 => 44, + 10 => 70, + 11 => 92, + 12 => 90 + }, + seasonal_adj: %{}, + typical_range_km: 40, + extended_range_km: 80, + exceptional_range_km: 150 + }, + 75_000 => %{ + freq_mhz: 75_000, + label: "75 GHz", + o2_db_km: 0.012, + h2o_coeff: 0.006, + humidity_effect: :harmful, + humidity_penalty: 1.2, + rain_k: 0.345, + rain_alpha: 0.84, + seasonal_base: %{ + 1 => 90, + 2 => 90, + 3 => 80, + 4 => 68, + 5 => 55, + 6 => 38, + 7 => 22, + 8 => 22, + 9 => 48, + 10 => 74, + 11 => 96, + 12 => 90 + }, + seasonal_adj: %{}, + typical_range_km: 50, + extended_range_km: 120, + exceptional_range_km: 250 + }, + 122_000 => %{ + freq_mhz: 122_000, + label: "122 GHz", + o2_db_km: 0.80, + h2o_coeff: 0.010, + humidity_effect: :harmful, + humidity_penalty: 1.0, + rain_k: 0.498, + rain_alpha: 0.77, + seasonal_base: %{ + 1 => 92, + 2 => 90, + 3 => 78, + 4 => 62, + 5 => 45, + 6 => 28, + 7 => 15, + 8 => 15, + 9 => 38, + 10 => 68, + 11 => 92, + 12 => 92 + }, + seasonal_adj: %{}, + typical_range_km: 30, + extended_range_km: 80, + exceptional_range_km: 140 + }, + 134_000 => %{ + freq_mhz: 134_000, + label: "134 GHz", + o2_db_km: 0.08, + h2o_coeff: 0.015, + humidity_effect: :harmful, + humidity_penalty: 1.3, + rain_k: 0.520, + rain_alpha: 0.75, + seasonal_base: %{ + 1 => 92, + 2 => 90, + 3 => 78, + 4 => 65, + 5 => 48, + 6 => 30, + 7 => 18, + 8 => 18, + 9 => 42, + 10 => 70, + 11 => 92, + 12 => 92 + }, + seasonal_adj: %{}, + typical_range_km: 40, + extended_range_km: 100, + exceptional_range_km: 160 + }, + 241_000 => %{ + freq_mhz: 241_000, + label: "241 GHz", + o2_db_km: 0.08, + h2o_coeff: 0.30, + humidity_effect: :harmful, + humidity_penalty: 3.0, + rain_k: 0.550, + rain_alpha: 0.70, + seasonal_base: %{ + 1 => 95, + 2 => 92, + 3 => 75, + 4 => 55, + 5 => 35, + 6 => 15, + 7 => 8, + 8 => 8, + 9 => 30, + 10 => 65, + 11 => 95, + 12 => 95 + }, + seasonal_adj: %{}, + typical_range_km: 10, + extended_range_km: 50, + exceptional_range_km: 115 + } + } + + @doc "Returns the band config for the given frequency in MHz, or nil if not found." + @spec get(integer()) :: map() | nil + def get(freq_mhz), do: Map.get(@band_configs, freq_mhz) + + @doc "Returns all band configs sorted by frequency (ascending)." + @spec all_bands() :: [map()] + def all_bands do + @band_configs + |> Enum.sort_by(fn {freq, _config} -> freq end) + |> Enum.map(fn {_freq, config} -> config end) + end + + @doc "Returns all supported frequencies sorted ascending." + @spec all_freqs() :: [integer()] + def all_freqs do + @band_configs + |> Map.keys() + |> Enum.sort() + end + + @doc "Returns the scoring weights map. All 9 values sum to 1.0." + @spec weights() :: map() + def weights, do: @weights + + @doc "Returns the 12-element sunrise hour table (Jan-Dec, local time)." + @spec sunrise_table() :: [float()] + def sunrise_table, do: @sunrise_table + + @doc "Returns score tier definitions ordered by threshold descending." + @spec tiers() :: [map()] + def tiers, do: @tiers + + @doc "Returns humidity beneficial thresholds as {hour, score} tuples." + @spec humidity_beneficial_thresholds() :: [{integer(), integer()}] + def humidity_beneficial_thresholds, do: @humidity_beneficial_thresholds + + @doc "Returns the default humidity beneficial score." + @spec humidity_beneficial_default() :: integer() + def humidity_beneficial_default, do: @humidity_beneficial_default + + @doc "Returns refractivity thresholds as {gradient, score_beneficial, score_harmful} tuples." + @spec refractivity_thresholds() :: [{number(), number(), number()}] + def refractivity_thresholds, do: @refractivity_thresholds + + @doc "Returns the default refractivity scores as {beneficial, harmful}." + @spec refractivity_default() :: {number(), number()} + def refractivity_default, do: @refractivity_default + + @doc "Returns the shallow boundary layer threshold in meters." + @spec shallow_bl_threshold_m() :: number() + def shallow_bl_threshold_m, do: @shallow_bl_threshold_m + + @doc "Returns the score applied when boundary layer is shallow." + @spec shallow_bl_score() :: number() + def shallow_bl_score, do: @shallow_bl_score +end diff --git a/test/microwaveprop/propagation/band_config_test.exs b/test/microwaveprop/propagation/band_config_test.exs new file mode 100644 index 00000000..2b48ba51 --- /dev/null +++ b/test/microwaveprop/propagation/band_config_test.exs @@ -0,0 +1,294 @@ +defmodule Microwaveprop.Propagation.BandConfigTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Propagation.BandConfig + + @all_freqs [10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000] + + describe "get/1" do + test "returns config for 10 GHz" do + config = BandConfig.get(10_000) + assert config.freq_mhz == 10_000 + assert config.label == "10 GHz" + assert config.humidity_effect == :beneficial + assert config.humidity_penalty == 0.0 + assert config.rain_k == 0.010 + assert config.rain_alpha == 1.28 + assert config.typical_range_km == 200 + assert config.extended_range_km == 500 + assert config.exceptional_range_km == 1000 + end + + test "returns config for 24 GHz" do + config = BandConfig.get(24_000) + assert config.freq_mhz == 24_000 + assert config.label == "24 GHz" + assert config.humidity_effect == :harmful + assert config.humidity_penalty == 1.6 + assert config.rain_k == 0.070 + assert config.rain_alpha == 1.07 + assert config.typical_range_km == 100 + assert config.extended_range_km == 250 + assert config.exceptional_range_km == 500 + end + + test "returns config for 47 GHz" do + config = BandConfig.get(47_000) + assert config.freq_mhz == 47_000 + assert config.humidity_effect == :harmful + assert config.humidity_penalty == 1.0 + assert config.rain_k == 0.187 + assert config.rain_alpha == 0.93 + end + + test "returns config for 68 GHz with atmospheric absorption" do + config = BandConfig.get(68_000) + assert config.o2_db_km == 0.90 + assert config.h2o_coeff == 0.007 + assert config.humidity_penalty == 1.4 + assert config.rain_k == 0.310 + assert config.rain_alpha == 0.86 + assert config.typical_range_km == 40 + assert config.extended_range_km == 80 + assert config.exceptional_range_km == 150 + end + + test "returns config for 75 GHz" do + config = BandConfig.get(75_000) + assert config.o2_db_km == 0.012 + assert config.h2o_coeff == 0.006 + assert config.humidity_penalty == 1.2 + assert config.rain_k == 0.345 + assert config.rain_alpha == 0.84 + end + + test "returns config for 122 GHz" do + config = BandConfig.get(122_000) + assert config.o2_db_km == 0.80 + assert config.h2o_coeff == 0.010 + assert config.humidity_penalty == 1.0 + assert config.rain_k == 0.498 + assert config.rain_alpha == 0.77 + end + + test "returns config for 134 GHz" do + config = BandConfig.get(134_000) + assert config.o2_db_km == 0.08 + assert config.h2o_coeff == 0.015 + assert config.humidity_penalty == 1.3 + assert config.rain_k == 0.520 + assert config.rain_alpha == 0.75 + end + + test "returns config for 241 GHz" do + config = BandConfig.get(241_000) + assert config.o2_db_km == 0.08 + assert config.h2o_coeff == 0.30 + assert config.humidity_penalty == 3.0 + assert config.rain_k == 0.550 + assert config.rain_alpha == 0.70 + assert config.typical_range_km == 10 + assert config.extended_range_km == 50 + assert config.exceptional_range_km == 115 + end + + test "returns nil for unknown band" do + assert BandConfig.get(999) == nil + end + end + + describe "all_bands/0" do + test "returns 8 bands" do + bands = BandConfig.all_bands() + assert length(bands) == 8 + end + + test "bands are sorted by frequency" do + freqs = Enum.map(BandConfig.all_bands(), & &1.freq_mhz) + assert freqs == @all_freqs + end + end + + describe "all_freqs/0" do + test "returns sorted frequency list" do + assert BandConfig.all_freqs() == @all_freqs + end + end + + describe "weights/0" do + test "returns all 9 factor keys" do + weights = BandConfig.weights() + + expected_keys = + MapSet.new([ + :humidity, + :time_of_day, + :td_depression, + :refractivity, + :sky, + :season, + :wind, + :rain, + :pressure + ]) + + assert MapSet.new(Map.keys(weights)) == expected_keys + end + + test "weights sum to 1.0" do + total = BandConfig.weights() |> Map.values() |> Enum.sum() + assert_in_delta total, 1.0, 0.001 + end + + test "individual weights have correct values" do + weights = BandConfig.weights() + assert weights.humidity == 0.20 + assert weights.time_of_day == 0.20 + assert weights.td_depression == 0.12 + assert weights.refractivity == 0.10 + assert weights.sky == 0.10 + assert weights.season == 0.10 + assert weights.wind == 0.06 + assert weights.rain == 0.08 + assert weights.pressure == 0.04 + end + end + + describe "sunrise_table/0" do + test "returns 12 monthly values" do + table = BandConfig.sunrise_table() + assert length(table) == 12 + end + + test "returns expected sunrise hours" do + assert BandConfig.sunrise_table() == [ + 7.4, + 7.3, + 7.0, + 6.7, + 6.35, + 6.25, + 6.35, + 6.65, + 6.9, + 7.1, + 7.35, + 7.45 + ] + end + end + + describe "tiers/0" do + test "returns 5 tiers" do + tiers = BandConfig.tiers() + assert length(tiers) == 5 + end + + test "tiers are ordered by threshold descending" do + thresholds = Enum.map(BandConfig.tiers(), & &1.min_score) + assert thresholds == [80, 65, 50, 33, 0] + end + + test "tier labels and colors are correct" do + tiers = BandConfig.tiers() + [excellent, good, marginal, poor, negligible] = tiers + + assert excellent == %{min_score: 80, label: "EXCELLENT", color: "#00ffa3"} + assert good == %{min_score: 65, label: "GOOD", color: "#7dffd4"} + assert marginal == %{min_score: 50, label: "MARGINAL", color: "#ffe566"} + assert poor == %{min_score: 33, label: "POOR", color: "#ff9044"} + assert negligible == %{min_score: 0, label: "NEGLIGIBLE", color: "#ff4f4f"} + end + end + + describe "seasonal bases" do + test "every band has 12 months in seasonal_base" do + for freq <- @all_freqs do + config = BandConfig.get(freq) + assert map_size(config.seasonal_base) == 12, "#{freq} missing months in seasonal_base" + assert Enum.all?(1..12, &Map.has_key?(config.seasonal_base, &1)) + end + end + + test "10 GHz seasonal base peaks in summer" do + config = BandConfig.get(10_000) + assert config.seasonal_base[7] == 95 + assert config.seasonal_base[3] == 22 + end + + test "24 GHz seasonal base peaks in winter" do + config = BandConfig.get(24_000) + assert config.seasonal_base[11] == 96 + assert config.seasonal_base[7] == 18 + end + + test "24 GHz has seasonal adjustments" do + config = BandConfig.get(24_000) + assert config.seasonal_adj[7] == -10 + assert config.seasonal_adj[5] == -4 + end + + test "10 GHz has empty seasonal adjustments" do + config = BandConfig.get(10_000) + assert config.seasonal_adj == %{} + end + end + + describe "humidity_beneficial_thresholds/0" do + test "returns threshold tuples sorted by hour" do + thresholds = BandConfig.humidity_beneficial_thresholds() + assert thresholds == [{4, 55}, {7, 70}, {10, 82}, {14, 90}, {18, 95}, {22, 88}] + end + end + + describe "humidity_beneficial_default/0" do + test "returns 75" do + assert BandConfig.humidity_beneficial_default() == 75 + end + end + + describe "refractivity_thresholds/0" do + test "returns gradient/score tuples" do + thresholds = BandConfig.refractivity_thresholds() + + assert thresholds == [ + {-500, 98, 85}, + {-300, 92, 78}, + {-200, 80, 80}, + {-100, 65, 65}, + {-60, 55, 55} + ] + end + end + + describe "refractivity_default/0" do + test "returns default scores" do + assert BandConfig.refractivity_default() == {42, 42} + end + end + + describe "shallow_bl_threshold_m/0" do + test "returns 300" do + assert BandConfig.shallow_bl_threshold_m() == 300 + end + end + + describe "shallow_bl_score/0" do + test "returns 82" do + assert BandConfig.shallow_bl_score() == 82 + end + end + + describe "only 10 GHz is beneficial" do + test "10 GHz has beneficial humidity effect" do + assert BandConfig.get(10_000).humidity_effect == :beneficial + end + + test "all other bands have harmful humidity effect" do + for freq <- @all_freqs -- [10_000] do + config = BandConfig.get(freq) + assert config.humidity_effect == :harmful, "#{freq} should be :harmful" + end + end + end +end