- Fix score_pressure crash on nil pressure_mb (coastal HRRR points) - Set 10-min timeout on grid score upsert transaction (was :infinity) - Single DELETE for prune_old_scores instead of N queries in a loop - Remove dead load_hrrr_refractivity that loaded 95k rows into nil map - Pass selected_time to point_detail to skip latest_valid_time sub-query - Batch station existence checks (1 query per path point, not per station) - Batch solar index upserts via insert_all in chunks of 500 - Batch backfill_distances via single UPDATE FROM VALUES statement - Add is_grid_point boolean + partial index to hrrr_profiles (replaces non-sargable modular arithmetic filter on every weather map query) - Add partial index on contacts(qso_timestamp) WHERE pos1 IS NOT NULL - Move backfill enqueue to Oban worker so UI returns immediately
370 lines
13 KiB
Elixir
370 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() | nil, number() | nil) :: integer()
|
|
def score_pressure(nil, _previous_mb), do: 50
|
|
|
|
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
|