Add propagation scoring algorithm with 9 weighted factors
Implements BandConfig (data-driven thresholds for 8 bands) and Scorer module with humidity, time-of-day, TD depression, refractivity, sky cover, season, wind, rain attenuation, and pressure trend scoring. Composite score applies BandConfig weights (sum to 1.0) across all factors, producing 0-100 score per band. 10 GHz uses beneficial humidity/ducting model; 24 GHz+ uses harmful absorption model.
This commit is contained in:
parent
3228835636
commit
2698f33cd3
3 changed files with 1149 additions and 0 deletions
328
lib/microwaveprop/propagation/band_config.ex
Normal file
328
lib/microwaveprop/propagation/band_config.ex
Normal file
|
|
@ -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
|
||||
335
lib/microwaveprop/propagation/scorer.ex
Normal file
335
lib/microwaveprop/propagation/scorer.ex
Normal file
|
|
@ -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
|
||||
486
test/microwaveprop/propagation/scorer_test.exs
Normal file
486
test/microwaveprop/propagation/scorer_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue