Score time-of-day per grid point using longitude/15 solar offset instead of hardcoded CST/CDT. Add PWAT as 10th scoring factor. Refine pressure thresholds. Update ML model and training pipeline to use local solar time.
368 lines
13 KiB
Elixir
368 lines
13 KiB
Elixir
defmodule Microwaveprop.Propagation.Scorer do
|
|
@moduledoc """
|
|
Scoring functions for microwave propagation conditions.
|
|
|
|
Each of the 10 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 longitude-based solar time offset (longitude / 15) so each grid point
|
|
gets its own local time rather than a fixed timezone offset.
|
|
"""
|
|
@spec score_time_of_day(integer(), integer(), integer(), number()) :: {integer(), String.t()}
|
|
def score_time_of_day(utc_hour, utc_minute, month, longitude) do
|
|
offset = longitude / 15
|
|
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: Precipitable water ──────────────────────────────────
|
|
|
|
@doc """
|
|
Scores precipitable water (mm).
|
|
|
|
Beneficial bands (10 GHz): moderate PWAT is optimal for refractivity.
|
|
Harmful bands (24+ GHz): lower PWAT is better (less water vapor absorption).
|
|
"""
|
|
@spec score_pwat(number() | nil, map()) :: integer()
|
|
def score_pwat(nil, _band_config), do: 60
|
|
|
|
def score_pwat(pwat_mm, %{humidity_effect: :beneficial}) do
|
|
cond do
|
|
pwat_mm < 10 -> 55
|
|
pwat_mm < 20 -> 75
|
|
pwat_mm < 30 -> 90
|
|
pwat_mm < 40 -> 70
|
|
true -> 50
|
|
end
|
|
end
|
|
|
|
def score_pwat(pwat_mm, %{humidity_effect: :harmful}) do
|
|
cond do
|
|
pwat_mm < 10 -> 95
|
|
pwat_mm < 20 -> 80
|
|
pwat_mm < 30 -> 60
|
|
pwat_mm < 40 -> 35
|
|
true -> 15
|
|
end
|
|
end
|
|
|
|
# ── Factor 10: 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 < 1005 -> 80
|
|
current_mb < 1010 -> 70
|
|
current_mb < 1015 -> 60
|
|
current_mb < 1020 -> 45
|
|
true -> 30
|
|
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, conditions.longitude)
|
|
|
|
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),
|
|
pwat: score_pwat(conditions[:pwat_mm], 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
|