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/lib/microwaveprop/propagation/scorer.ex b/lib/microwaveprop/propagation/scorer.ex new file mode 100644 index 00000000..583c38dd --- /dev/null +++ b/lib/microwaveprop/propagation/scorer.ex @@ -0,0 +1,335 @@ +defmodule Microwaveprop.Propagation.Scorer do + @moduledoc """ + Scoring functions for microwave propagation conditions. + + Each of the 9 factors produces a 0-100 score. The composite score is + a weighted sum using weights from `BandConfig`. All thresholds and + parameters are read from `BandConfig` — no hardcoded magic numbers here. + """ + + alias Microwaveprop.Propagation.BandConfig + + @shallow_bl_threshold_m BandConfig.shallow_bl_threshold_m() + + # ── Temperature conversion helpers ──────────────────────────────── + + @doc "Converts Fahrenheit to Celsius. Returns nil for nil input." + @spec f_to_c(number() | nil) :: float() | nil + def f_to_c(nil), do: nil + def f_to_c(f), do: (f - 32) * 5 / 9 + + @doc "Converts Celsius to Fahrenheit. Returns nil for nil input." + @spec c_to_f(number() | nil) :: float() | nil + def c_to_f(nil), do: nil + def c_to_f(c), do: c * 9 / 5 + 32 + + # ── Derived value helpers ───────────────────────────────────────── + + @doc """ + Computes absolute humidity in g/m3 from temperature and dewpoint (both Celsius). + + Formula: 217 * (6.112 * exp(17.67 * Td / (Td + 243.5))) / (T + 273.15) + """ + @spec absolute_humidity(number(), number()) :: float() + def absolute_humidity(temp_c, dewpoint_c) do + e_sat = 6.112 * :math.exp(17.67 * dewpoint_c / (dewpoint_c + 243.5)) + 217.0 * e_sat / (temp_c + 273.15) + end + + @doc "Computes wind speed in knots from u and v components in m/s. Returns nil if either is nil." + @spec wind_speed_kts(number() | nil, number() | nil) :: float() | nil + def wind_speed_kts(nil, _v), do: nil + def wind_speed_kts(_u, nil), do: nil + + def wind_speed_kts(u_ms, v_ms) do + :math.sqrt(u_ms * u_ms + v_ms * v_ms) * 1.94384 + end + + @doc "Converts precipitation accumulation (mm) to rate (mm/hr). Returns 0.0 for nil, zero, or negative." + @spec precip_to_rate_mmhr(number() | nil) :: float() + def precip_to_rate_mmhr(nil), do: 0.0 + def precip_to_rate_mmhr(mm) when mm > 0, do: mm / 1 + def precip_to_rate_mmhr(_mm), do: 0.0 + + # ── Factor 1: Humidity ──────────────────────────────────────────── + + @doc """ + Scores absolute humidity (g/m3) for the given band. + + Beneficial bands (10 GHz): higher humidity improves ducting. + Harmful bands (24 GHz+): humidity causes absorption loss. + """ + @spec score_humidity(number(), map()) :: integer() + def score_humidity(abs_humidity, %{humidity_effect: :beneficial}) do + thresholds = BandConfig.humidity_beneficial_thresholds() + + case Enum.find(thresholds, fn {max_h, _score} -> abs_humidity < max_h end) do + {_max, score} -> score + nil -> BandConfig.humidity_beneficial_default() + end + end + + def score_humidity(abs_humidity, %{humidity_effect: :harmful, humidity_penalty: penalty}) do + r = abs_humidity * penalty + + cond do + r <= 6 -> 100 + r <= 9 -> round(95 - (r - 6) / 3 * 20) + r <= 13 -> round(75 - (r - 9) / 4 * 30) + r <= 18 -> round(45 - (r - 13) / 5 * 35) + true -> max(0, round(10 - (r - 18) * 2)) + end + end + + # ── Factor 2: Time of day ───────────────────────────────────────── + + @doc """ + Scores time of day for propagation conditions. + + Returns {score, label} where score is 0-100 and label describes the period. + Uses CDT (March-October) or CST (November-February) offset. + """ + @spec score_time_of_day(integer(), integer(), integer()) :: {integer(), String.t()} + def score_time_of_day(utc_hour, utc_minute, month) do + offset = if month in 3..10, do: -5, else: -6 + local = :math.fmod(utc_hour + utc_minute / 60 + offset + 24, 24) + sunrise = Enum.at(BandConfig.sunrise_table(), month - 1) + d = local - sunrise + + classify_time_period(d, local) + end + + defp classify_time_period(d, _local) when d >= -1.5 and d <= 1.5, do: {100, "Peak — inversion maximum"} + + defp classify_time_period(d, _local) when d > 1.5 and d <= 3.0, do: {78, "Good — inversion eroding"} + + defp classify_time_period(d, _local) when d > -3.0 and d < -1.5, do: {82, "Pre-dawn — inversion building"} + + defp classify_time_period(d, _local) when d > 3.0 and d <= 6.0, do: {38, "Marginal — boundary layer mixing"} + + defp classify_time_period(_d, local) when local >= 20 or local <= 1, do: {72, "Evening — cooling, inversion reforming"} + + defp classify_time_period(d, _local) when d > 6, do: {18, "Afternoon — full convective mixing"} + + defp classify_time_period(_d, _local), do: {55, "Night — gradual cooling"} + + # ── Factor 3: Temp-dewpoint depression ──────────────────────────── + + @doc """ + Scores the temperature-dewpoint depression (both in Fahrenheit). + + Beneficial bands: moisture helps (tight depression = moist = better ducting, + but too tight means fog which is bad). + Harmful bands: dry air is better (wide depression = less absorption). + """ + @spec score_td_depression(number(), number(), map()) :: integer() + def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :beneficial}) do + dep = temp_f - dewpoint_f + + cond do + dep < 3 -> 40 + dep < 8 -> 75 + dep < 14 -> 85 + dep < 22 -> 70 + true -> 55 + end + end + + def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :harmful}) do + dep = temp_f - dewpoint_f + + cond do + dep > 22 -> 96 + dep > 14 -> 80 + dep > 8 -> 60 + dep > 4 -> 38 + true -> 18 + end + end + + # ── Factor 4: Refractivity ─────────────────────────────────────── + + @doc """ + Scores minimum refractivity gradient and boundary layer depth. + + Nil gradient returns 50 (unknown). Otherwise walks thresholds from + BandConfig, falling back to shallow BL score or default. + """ + @spec score_refractivity(number() | nil, number() | nil, map()) :: integer() + def score_refractivity(nil, _bl_depth_m, _band_config), do: 50 + + def score_refractivity(min_gradient, bl_depth_m, %{humidity_effect: effect}) do + thresholds = BandConfig.refractivity_thresholds() + + case find_refractivity_threshold(min_gradient, thresholds, effect) do + {:ok, score} -> score + :none -> refractivity_fallback(bl_depth_m, effect) + end + end + + defp refractivity_fallback(bl_depth_m, _effect) when bl_depth_m != nil and bl_depth_m < @shallow_bl_threshold_m do + BandConfig.shallow_bl_score() + end + + defp refractivity_fallback(_bl_depth_m, effect) do + {beneficial_default, harmful_default} = BandConfig.refractivity_default() + + case effect do + :beneficial -> beneficial_default + :harmful -> harmful_default + end + end + + defp find_refractivity_threshold(gradient, thresholds, effect) do + case Enum.find(thresholds, fn {max_grad, _ben, _harm} -> gradient < max_grad end) do + {_max, beneficial_score, harmful_score} -> + score = + case effect do + :beneficial -> beneficial_score + :harmful -> harmful_score + end + + {:ok, score} + + nil -> + :none + end + end + + # ── Factor 5: Sky cover ────────────────────────────────────────── + + @doc "Scores sky cover percentage (0 = clear, 100 = overcast). Nil returns 50." + @spec score_sky(number() | nil) :: integer() + def score_sky(nil), do: 50 + + def score_sky(pct) do + cond do + pct <= 6 -> 100 + pct <= 25 -> 88 + pct <= 50 -> 60 + pct <= 87 -> 25 + true -> 5 + end + end + + # ── Factor 6: Season ───────────────────────────────────────────── + + @doc "Scores the seasonal effect for the given month and band config." + @spec score_season(integer(), map()) :: integer() + def score_season(month, %{seasonal_base: base_map, seasonal_adj: adj_map}) do + base = Map.get(base_map, month, 50) + adj = Map.get(adj_map, month, 0) + min(100, max(0, base + adj)) + end + + # ── Factor 7: Wind ─────────────────────────────────────────────── + + @doc "Scores wind speed in knots. Nil returns 50." + @spec score_wind(number() | nil) :: integer() + def score_wind(nil), do: 50 + + def score_wind(speed_kts) do + cond do + speed_kts < 5 -> 100 + speed_kts < 10 -> 90 + speed_kts < 15 -> 75 + speed_kts < 20 -> 55 + speed_kts < 25 -> 35 + true -> 15 + end + end + + # ── Factor 8: Rain attenuation ─────────────────────────────────── + + @doc """ + Scores rain attenuation for the given rate (mm/hr) and band config. + + Uses ITU-R P.838 specific attenuation: gamma = k * R^alpha (dB/km). + """ + @spec score_rain(number() | nil, map()) :: integer() + def score_rain(nil, _band_config), do: 100 + def score_rain(rate, _band_config) when rate == 0, do: 100 + + def score_rain(rain_rate_mmhr, %{rain_k: k, rain_alpha: alpha}) do + gamma = k * :math.pow(rain_rate_mmhr, alpha) + + cond do + gamma < 0.1 -> 95 + gamma < 0.5 -> 75 + gamma < 1.0 -> 50 + gamma < 2.0 -> 25 + gamma < 5.0 -> 10 + true -> 0 + end + end + + # ── Factor 9: Pressure trend ───────────────────────────────────── + + @doc """ + Scores barometric pressure and trend. + + Without previous reading, scores based on absolute pressure. + With previous reading, scores based on pressure change (delta). + """ + @spec score_pressure(number(), number() | nil) :: integer() + def score_pressure(current_mb, nil) do + cond do + current_mb > 1025 -> 55 + current_mb > 1018 -> 65 + current_mb > 1010 -> 60 + current_mb > 1005 -> 55 + true -> 40 + end + end + + def score_pressure(current_mb, previous_mb) do + delta = current_mb - previous_mb + + cond do + delta > 2.5 -> 80 + delta > 0.8 -> 70 + delta > -0.5 -> 60 + delta > -2.0 -> 65 + true -> 45 + end + end + + # ── Composite score ────────────────────────────────────────────── + + @doc """ + Computes the weighted composite propagation score. + + Takes a conditions map and band config, returns %{score: 0-100, factors: %{...}}. + """ + @spec composite_score(map(), map()) :: %{score: integer(), factors: map()} + def composite_score(conditions, band_config) do + {tod_score, _label} = + score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month) + + factors = %{ + humidity: score_humidity(conditions.abs_humidity, band_config), + time_of_day: tod_score, + td_depression: score_td_depression(conditions.temp_f, conditions.dewpoint_f, band_config), + refractivity: + score_refractivity( + conditions.min_refractivity_gradient, + conditions.bl_depth_m, + band_config + ), + sky: score_sky(conditions.sky_cover_pct), + season: score_season(conditions.month, band_config), + wind: score_wind(conditions.wind_speed_kts), + rain: score_rain(conditions.rain_rate_mmhr, band_config), + pressure: score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb) + } + + weights = BandConfig.weights() + + weighted_sum = + Enum.reduce(factors, 0.0, fn {factor, score}, acc -> + acc + score * Map.fetch!(weights, factor) + end) + + %{score: round(weighted_sum), factors: factors} + end +end diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs new file mode 100644 index 00000000..00c40602 --- /dev/null +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -0,0 +1,486 @@ +defmodule Microwaveprop.Propagation.ScorerTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Propagation.BandConfig + alias Microwaveprop.Propagation.Scorer + + @band_10g BandConfig.get(10_000) + @band_24g BandConfig.get(24_000) + @band_75g BandConfig.get(75_000) + + # ── Helper functions ────────────────────────────────────────────── + + describe "f_to_c/1" do + test "converts 32F to 0C" do + assert_in_delta Scorer.f_to_c(32), 0.0, 0.01 + end + + test "converts 212F to 100C" do + assert_in_delta Scorer.f_to_c(212), 100.0, 0.01 + end + + test "returns nil for nil" do + assert Scorer.f_to_c(nil) == nil + end + end + + describe "c_to_f/1" do + test "converts 0C to 32F" do + assert_in_delta Scorer.c_to_f(0), 32.0, 0.01 + end + + test "converts 100C to 212F" do + assert_in_delta Scorer.c_to_f(100), 212.0, 0.01 + end + + test "returns nil for nil" do + assert Scorer.c_to_f(nil) == nil + end + end + + describe "absolute_humidity/2" do + test "calculates g/m3 for typical summer conditions" do + # ~25C temp, ~20C dewpoint -> ~17 g/m3 + ah = Scorer.absolute_humidity(25, 20) + assert ah > 15 + assert ah < 20 + end + + test "calculates g/m3 for cold dry conditions" do + # ~0C temp, ~-5C dewpoint -> ~3 g/m3 + ah = Scorer.absolute_humidity(0, -5) + assert ah > 2 + assert ah < 5 + end + end + + describe "wind_speed_kts/2" do + test "calculates wind speed from u and v components" do + # 3 m/s east + 4 m/s north = 5 m/s = ~9.72 kts + kts = Scorer.wind_speed_kts(3.0, 4.0) + assert_in_delta kts, 9.72, 0.1 + end + + test "returns nil when either component is nil" do + assert Scorer.wind_speed_kts(nil, 4.0) == nil + assert Scorer.wind_speed_kts(3.0, nil) == nil + assert Scorer.wind_speed_kts(nil, nil) == nil + end + end + + describe "precip_to_rate_mmhr/1" do + test "passes through positive values" do + assert Scorer.precip_to_rate_mmhr(2.5) == 2.5 + end + + test "returns 0.0 for zero" do + assert Scorer.precip_to_rate_mmhr(0) == 0.0 + end + + test "returns 0.0 for negative" do + assert Scorer.precip_to_rate_mmhr(-1.0) == 0.0 + end + + test "returns 0.0 for nil" do + assert Scorer.precip_to_rate_mmhr(nil) == 0.0 + end + end + + # ── score_humidity/2 ────────────────────────────────────────────── + + describe "score_humidity/2 beneficial (10 GHz)" do + test "low humidity gets first threshold score" do + # abs_humidity < 4 -> 55 + assert Scorer.score_humidity(3.0, @band_10g) == 55 + end + + test "moderate humidity gets higher score" do + # abs_humidity in 7..10 -> 82 + assert Scorer.score_humidity(9.0, @band_10g) == 82 + end + + test "high humidity gets peak score" do + # abs_humidity in 14..18 -> 95 + assert Scorer.score_humidity(16.0, @band_10g) == 95 + end + + test "very high humidity gets decreased score" do + # abs_humidity in 18..22 -> 88 + assert Scorer.score_humidity(20.0, @band_10g) == 88 + end + + test "extreme humidity gets default" do + # abs_humidity > 22 -> 75 (default) + assert Scorer.score_humidity(25.0, @band_10g) == 75 + end + end + + describe "score_humidity/2 harmful (24 GHz)" do + test "very low effective humidity gets 100" do + # r = abs_humidity * 1.6 = 2 * 1.6 = 3.2, r<=6 -> 100 + assert Scorer.score_humidity(2.0, @band_24g) == 100 + end + + test "moderate effective humidity gets reduced score" do + # r = 5 * 1.6 = 8.0, 6 < r <= 9 -> round(95 - (8-6)/3*20) = round(95 - 13.33) = 82 + assert Scorer.score_humidity(5.0, @band_24g) == 82 + end + + test "high effective humidity gets low score" do + # r = 10 * 1.6 = 16.0, 13 < r <= 18 -> round(45 - (16-13)/5*35) = round(45 - 21) = 24 + assert Scorer.score_humidity(10.0, @band_24g) == 24 + end + + test "extreme effective humidity gets near zero" do + # r = 20 * 1.6 = 32.0, r > 18 -> max(0, round(10 - (32-18)*2)) = max(0, round(10-28)) = 0 + assert Scorer.score_humidity(20.0, @band_24g) == 0 + end + end + + # ── score_time_of_day/3 ────────────────────────────────────────── + + describe "score_time_of_day/3" do + test "dawn peak in June returns 100" do + # June sunrise ~6.25, CDT offset -5 + # local = (11 + 15/60 - 5 + 24) mod 24 = 30.25 mod 24 = 6.25 + # d = 6.25 - 6.25 = 0.0, |d| <= 1.5 -> 100 + {score, label} = Scorer.score_time_of_day(11, 15, 6) + assert score == 100 + assert label =~ "Peak" + end + + test "afternoon in June returns 18" do + # local = (22 + 0/60 - 5 + 24) mod 24 = 17.0 + # d = 17.0 - 6.25 = 10.75, d > 6 -> 18 + {score, _label} = Scorer.score_time_of_day(22, 0, 6) + assert score == 18 + end + + test "pre-dawn returns 82" do + # June sunrise ~6.25, CDT offset -5 + # local = (9 + 0/60 - 5 + 24) mod 24 = 4.0 + # d = 4.0 - 6.25 = -2.25, in (-3.0, -1.5) -> 82 + {score, label} = Scorer.score_time_of_day(9, 0, 6) + assert score == 82 + assert label =~ "Pre-dawn" + end + + test "evening returns 72" do + # June CDT offset -5 + # local = (1 + 0/60 - 5 + 24) mod 24 = 20.0 + # local >= 20 -> 72 + {score, label} = Scorer.score_time_of_day(1, 0, 6) + assert score == 72 + assert label =~ "Evening" + end + + test "winter uses CST offset" do + # January, CST offset -6 + # local = (13 + 24/60 - 6 + 24) mod 24 = 7.4 + # sunrise for Jan = 7.4, d = 0.0 -> 100 + {score, _label} = Scorer.score_time_of_day(13, 24, 1) + assert score == 100 + end + end + + # ── score_td_depression/3 ──────────────────────────────────────── + + describe "score_td_depression/3 beneficial (10 GHz)" do + test "very tight depression (saturated air) returns 40" do + # dep = 72 - 70 = 2, <3 -> 40 + assert Scorer.score_td_depression(72, 70, @band_10g) == 40 + end + + test "moderate depression returns 85" do + # dep = 80 - 70 = 10, 8..14 -> 85 + assert Scorer.score_td_depression(80, 70, @band_10g) == 85 + end + + test "wide depression returns 55" do + # dep = 100 - 50 = 50, >=22 -> 55 + assert Scorer.score_td_depression(100, 50, @band_10g) == 55 + end + end + + describe "score_td_depression/3 harmful (24 GHz)" do + test "very tight depression returns 18 (bad for high freq)" do + # dep = 72 - 70 = 2, <=4 -> 18 + assert Scorer.score_td_depression(72, 70, @band_24g) == 18 + end + + test "moderate depression returns 60" do + # dep = 80 - 70 = 10, 8..14 -> 60 + assert Scorer.score_td_depression(80, 70, @band_24g) == 60 + end + + test "wide depression returns 96 (good for high freq)" do + # dep = 100 - 50 = 50, >22 -> 96 + assert Scorer.score_td_depression(100, 50, @band_24g) == 96 + end + end + + # ── score_refractivity/3 ───────────────────────────────────────── + + describe "score_refractivity/3" do + test "strong ducting gradient returns high score for beneficial" do + # gradient < -500 -> 98 for beneficial + assert Scorer.score_refractivity(-600, 500, @band_10g) == 98 + end + + test "strong ducting gradient returns 85 for harmful" do + # gradient < -500 -> 85 for harmful + assert Scorer.score_refractivity(-600, 500, @band_24g) == 85 + end + + test "moderate gradient returns appropriate score" do + # gradient < -300 but >= -500 -> 92 for beneficial + assert Scorer.score_refractivity(-400, 500, @band_10g) == 92 + end + + test "nil gradient returns 50" do + assert Scorer.score_refractivity(nil, 500, @band_10g) == 50 + end + + test "weak gradient with shallow BL returns shallow BL score" do + # gradient > -60 (no threshold match), bl_depth < 300 -> 82 + assert Scorer.score_refractivity(-30, 200, @band_10g) == 82 + end + + test "weak gradient with deep BL returns default" do + # gradient > -60, bl_depth >= 300 -> 42 + assert Scorer.score_refractivity(-30, 500, @band_10g) == 42 + end + end + + # ── score_sky/1 ────────────────────────────────────────────────── + + describe "score_sky/1" do + test "clear sky returns 100" do + assert Scorer.score_sky(5) == 100 + end + + test "few clouds returns 88" do + assert Scorer.score_sky(20) == 88 + end + + test "scattered returns 60" do + assert Scorer.score_sky(40) == 60 + end + + test "broken returns 25" do + assert Scorer.score_sky(75) == 25 + end + + test "overcast returns 5" do + assert Scorer.score_sky(95) == 5 + end + + test "nil returns 50" do + assert Scorer.score_sky(nil) == 50 + end + end + + # ── score_season/2 ─────────────────────────────────────────────── + + describe "score_season/2" do + test "10 GHz summer peak" do + # July base = 95, adj = 0 -> 95 + assert Scorer.score_season(7, @band_10g) == 95 + end + + test "10 GHz winter low" do + # March base = 22, adj = 0 -> 22 + assert Scorer.score_season(3, @band_10g) == 22 + end + + test "24 GHz winter peak" do + # November base = 96, adj = 0 -> 96 + assert Scorer.score_season(11, @band_24g) == 96 + end + + test "24 GHz summer with adjustment" do + # July base = 18, adj = -10 -> 8 + assert Scorer.score_season(7, @band_24g) == 8 + end + + test "clamped to 0-100" do + # Even if base + adj > 100, clamp + assert Scorer.score_season(1, @band_10g) >= 0 + assert Scorer.score_season(1, @band_10g) <= 100 + end + end + + # ── score_wind/1 ───────────────────────────────────────────────── + + describe "score_wind/1" do + test "calm returns 100" do + assert Scorer.score_wind(3) == 100 + end + + test "light returns 90" do + assert Scorer.score_wind(8) == 90 + end + + test "moderate returns 75" do + assert Scorer.score_wind(12) == 75 + end + + test "strong returns 35" do + assert Scorer.score_wind(22) == 35 + end + + test "very strong returns 15" do + assert Scorer.score_wind(30) == 15 + end + + test "nil returns 50" do + assert Scorer.score_wind(nil) == 50 + end + end + + # ── score_rain/2 ───────────────────────────────────────────────── + + describe "score_rain/2" do + test "no rain returns 100" do + assert Scorer.score_rain(0, @band_24g) == 100 + end + + test "nil rain returns 100" do + assert Scorer.score_rain(nil, @band_24g) == 100 + end + + test "light rain with 24 GHz" do + # gamma = 0.070 * rate^1.07 + # For rate = 2.0: gamma = 0.070 * 2.0^1.07 ≈ 0.147 + # 0.1 < gamma <= 0.5 -> 75 + assert Scorer.score_rain(2.0, @band_24g) == 75 + end + + test "heavy rain with 10 GHz" do + # gamma = 0.010 * rate^1.28 + # rate = 5.0: gamma = 0.010 * 5.0^1.28 ≈ 0.076 + # gamma < 0.1 -> 95 + assert Scorer.score_rain(5.0, @band_10g) == 95 + end + + test "heavy rain with 75 GHz gets very low score" do + # gamma = 0.345 * 10^0.84 ≈ 2.39 + # 2.0 < gamma <= 5.0 -> 10 + assert Scorer.score_rain(10.0, @band_75g) == 10 + end + end + + # ── score_pressure/2 ───────────────────────────────────────────── + + describe "score_pressure/2" do + test "high pressure without previous returns 55" do + assert Scorer.score_pressure(1028, nil) == 55 + end + + test "normal pressure without previous returns 65" do + assert Scorer.score_pressure(1020, nil) == 65 + end + + test "low pressure without previous returns 40" do + assert Scorer.score_pressure(1000, nil) == 40 + end + + test "rising pressure returns 80" do + # delta = 1020 - 1015 = 5, > 2.5 -> 80 + assert Scorer.score_pressure(1020, 1015) == 80 + end + + test "steady pressure returns 70" do + # delta = 1016 - 1015 = 1, 0.8 < delta <= 2.5 -> 70 + assert Scorer.score_pressure(1016, 1015) == 70 + end + + test "slight fall returns 60" do + # delta = 1015 - 1015.3 = -0.3, -0.5 < delta <= 0.8 -> 60 + assert Scorer.score_pressure(1015, 1015.3) == 60 + end + + test "moderate fall returns 65" do + # delta = 1014 - 1015 = -1.0, -2.0 < delta <= -0.5 -> 65 + assert Scorer.score_pressure(1014, 1015) == 65 + end + + test "sharp fall returns 45" do + # delta = 1010 - 1015 = -5.0, <= -2.0 -> 45 + assert Scorer.score_pressure(1010, 1015) == 45 + end + end + + # ── composite_score/2 ──────────────────────────────────────────── + + describe "composite_score/2" do + @conditions %{ + abs_humidity: 10.0, + temp_f: 80, + dewpoint_f: 70, + wind_speed_kts: 5, + sky_cover_pct: 20, + utc_hour: 11, + utc_minute: 15, + month: 6, + pressure_mb: 1020, + prev_pressure_mb: 1018, + rain_rate_mmhr: 0, + min_refractivity_gradient: -350, + bl_depth_m: 400 + } + + test "returns score between 0 and 100" do + result = Scorer.composite_score(@conditions, @band_10g) + assert result.score >= 0 + assert result.score <= 100 + end + + test "returns all 9 factor scores" do + result = Scorer.composite_score(@conditions, @band_10g) + assert Map.has_key?(result.factors, :humidity) + assert Map.has_key?(result.factors, :time_of_day) + assert Map.has_key?(result.factors, :td_depression) + assert Map.has_key?(result.factors, :refractivity) + assert Map.has_key?(result.factors, :sky) + assert Map.has_key?(result.factors, :season) + assert Map.has_key?(result.factors, :wind) + assert Map.has_key?(result.factors, :rain) + assert Map.has_key?(result.factors, :pressure) + end + + test "uses weights from BandConfig" do + result = Scorer.composite_score(@conditions, @band_10g) + weights = BandConfig.weights() + + # Manually compute expected score + expected = + result.factors.humidity * weights.humidity + + result.factors.time_of_day * weights.time_of_day + + result.factors.td_depression * weights.td_depression + + result.factors.refractivity * weights.refractivity + + result.factors.sky * weights.sky + + result.factors.season * weights.season + + result.factors.wind * weights.wind + + result.factors.rain * weights.rain + + result.factors.pressure * weights.pressure + + assert_in_delta result.score, round(expected), 1 + end + + test "10 GHz vs 24 GHz give different scores" do + result_10g = Scorer.composite_score(@conditions, @band_10g) + result_24g = Scorer.composite_score(@conditions, @band_24g) + # In humid summer conditions, 10 GHz should score higher + assert result_10g.score != result_24g.score + end + + test "each factor score is between 0 and 100" do + result = Scorer.composite_score(@conditions, @band_10g) + + for {_factor, score} <- result.factors do + assert score >= 0 and score <= 100 + end + end + end +end