714 lines
29 KiB
Elixir
714 lines
29 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
|
||
|
||
# ── Temperature conversion helpers ────────────────────────────────
|
||
alias Microwaveprop.Propagation.Region
|
||
|
||
@doc """
|
||
Public entry point: scores propagation conditions for a given band (in MHz).
|
||
|
||
Returns `%{score: integer, contributions: [...], breakdown: %{...}, band_mhz: integer}`
|
||
or `nil` when the band is not configured.
|
||
"""
|
||
@spec score(map(), integer()) :: map() | nil
|
||
def score(conditions, band_mhz) do
|
||
case BandConfig.get(band_mhz) do
|
||
nil -> nil
|
||
band_config -> build_score_result(conditions, band_config, band_mhz)
|
||
end
|
||
end
|
||
|
||
defp build_score_result(conditions, band_config, band_mhz) do
|
||
%{score: score, factors: factors} = composite_score(conditions, band_config)
|
||
|
||
contributions =
|
||
factors
|
||
|> Enum.map(fn {factor, value} -> %{factor: factor, score: value} end)
|
||
|> Enum.sort_by(& &1.score, :desc)
|
||
|
||
%{
|
||
score: score,
|
||
contributions: contributions,
|
||
breakdown: factors,
|
||
band_mhz: band_mhz
|
||
}
|
||
end
|
||
|
||
# Compile-time inverse of the Marshall-Palmer b exponent (1/1.6).
|
||
# Pre-computed to avoid one float division per rain pixel in the
|
||
# dBZ → rain-rate hot path.
|
||
@mp_inv_b 1 / 1.6
|
||
|
||
@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
|
||
|
||
@doc """
|
||
Converts NEXRAD composite reflectivity (dBZ) to rain rate (mm/hr) via the
|
||
Marshall-Palmer Z-R relationship `Z = 200 * R^1.6`, so `R = (Z/200)^(1/1.6)`
|
||
with `Z = 10^(dBZ/10)`.
|
||
|
||
Clipping rules:
|
||
* Below 5 dBZ — not rain (ground clutter / clear air). Returns 0.0.
|
||
* Above 150 mm/hr — hail contamination (55+ dBZ can return >100 mm/hr
|
||
from pure Marshall-Palmer). The ITU-R P.838 rain table tops out at
|
||
~150 mm/hr so we clip there to stay inside the calibrated regime.
|
||
"""
|
||
@spec dbz_to_rain_rate_mmhr(number() | nil) :: float()
|
||
def dbz_to_rain_rate_mmhr(nil), do: 0.0
|
||
def dbz_to_rain_rate_mmhr(dbz) when dbz < 5.0, do: 0.0
|
||
|
||
def dbz_to_rain_rate_mmhr(dbz) do
|
||
z = :math.pow(10, dbz / 10)
|
||
r = :math.pow(z / 200, @mp_inv_b)
|
||
min(r, 150.0)
|
||
end
|
||
|
||
# ── 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
|
||
score_humidity_harmful(abs_humidity * penalty)
|
||
end
|
||
|
||
defp score_humidity_harmful(r) when r <= 6, do: 100
|
||
defp score_humidity_harmful(r) when r <= 9, do: round(95 - (r - 6) / 3 * 20)
|
||
defp score_humidity_harmful(r) when r <= 13, do: round(75 - (r - 9) / 4 * 30)
|
||
defp score_humidity_harmful(r) when r <= 18, do: round(45 - (r - 13) / 5 * 35)
|
||
defp score_humidity_harmful(r), do: max(0, round(10 - (r - 18) * 2))
|
||
|
||
# ── 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
|
||
score_td_beneficial(temp_f - dewpoint_f)
|
||
end
|
||
|
||
def score_td_depression(temp_f, dewpoint_f, %{humidity_effect: :harmful}) do
|
||
score_td_harmful(temp_f - dewpoint_f)
|
||
end
|
||
|
||
defp score_td_beneficial(dep) when dep < 3, do: 40
|
||
defp score_td_beneficial(dep) when dep < 8, do: 75
|
||
defp score_td_beneficial(dep) when dep < 14, do: 85
|
||
defp score_td_beneficial(dep) when dep < 22, do: 70
|
||
defp score_td_beneficial(_dep), do: 55
|
||
|
||
defp score_td_harmful(dep) when dep > 22, do: 96
|
||
defp score_td_harmful(dep) when dep > 14, do: 80
|
||
defp score_td_harmful(dep) when dep > 8, do: 60
|
||
defp score_td_harmful(dep) when dep > 4, do: 38
|
||
defp score_td_harmful(_dep), do: 18
|
||
|
||
# ── Factor 4: Refractivity ───────────────────────────────────────
|
||
|
||
@doc """
|
||
Scores minimum refractivity gradient and boundary layer depth.
|
||
|
||
Nil gradient returns 50 (unknown). Otherwise walks thresholds from
|
||
BandConfig and then applies an HPBL multiplier: shallow boundary layers
|
||
amplify trapping (surface inversion → steep gradient → ducting), deep
|
||
boundary layers indicate convective mixing that disrupts ducts.
|
||
|
||
As of the 2026-04-25 algo revisions the HPBL boundary-layer multiplier
|
||
is retired — `bl_depth_m` is accepted (and the schema still stores it
|
||
for diagnostics) but it does not modify the score. See
|
||
`docs/algo-reports/2026-04-25-algo-revisions.md` Recommendation 2: on
|
||
the n=47,418 10 GHz HRRR-matched corpus rho_hpbl was +0.004, well below
|
||
the 0.05 noise floor; the bin tables agreed within ±5 % across
|
||
200–2,000 m. The previously reported "shallow-BL → longer-distance"
|
||
effect was a small-sample artefact and disappeared once the matched
|
||
corpus grew past ~5,000 contacts.
|
||
"""
|
||
@spec score_refractivity(number() | nil, number() | nil, map()) :: integer()
|
||
def score_refractivity(min_gradient, bl_depth_m, band_config) do
|
||
score_refractivity(min_gradient, bl_depth_m, nil, band_config)
|
||
end
|
||
|
||
@doc """
|
||
Scores refractivity, ignoring `best_duct_band_ghz`.
|
||
|
||
Accepts the native-profile duct band so existing call sites
|
||
(`Scorer.score_grid_point/2`, `Recalibrator`, the Rust comparator)
|
||
continue to compile, but the previous 1.15× boost was retired in the
|
||
2026-04-25 revisions: at 10 GHz on n=52,341 matched contacts, cells
|
||
whose native duct supported the band ran *shorter* (198 km, n=173)
|
||
than no-duct cells (211 km, n=44,658). Refractivity is now the
|
||
gradient-only score.
|
||
"""
|
||
@spec score_refractivity(number() | nil, number() | nil, number() | nil, map()) :: integer()
|
||
def score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, band_config) do
|
||
score_refractivity(min_gradient, bl_depth_m, best_duct_band_ghz, nil, band_config)
|
||
end
|
||
|
||
@doc """
|
||
Scores refractivity, ignoring `best_duct_band_ghz` and `bulk_richardson`.
|
||
|
||
The Bulk Richardson gate existed only to suppress the native-duct
|
||
boost during mechanically-mixed conditions. With the boost retired
|
||
(see 4-arity doc), Richardson is irrelevant — every input collapses
|
||
onto the gradient-only base score. Arity preserved so prod call
|
||
sites don't need an emergency rewrite.
|
||
"""
|
||
@spec score_refractivity(number() | nil, number() | nil, number() | nil, number() | nil, map()) ::
|
||
integer()
|
||
def score_refractivity(nil, _bl_depth_m, _best_duct_band_ghz, _bulk_richardson, _band_config), do: 50
|
||
|
||
def score_refractivity(min_gradient, _bl_depth_m, _best_duct_band_ghz, _bulk_richardson, %{humidity_effect: effect}) do
|
||
thresholds = BandConfig.refractivity_thresholds()
|
||
|
||
case find_refractivity_threshold(min_gradient, thresholds, effect) do
|
||
{:ok, score} ->
|
||
score
|
||
|
||
:none ->
|
||
{beneficial_default, harmful_default} = BandConfig.refractivity_default()
|
||
if effect == :beneficial, do: beneficial_default, else: harmful_default
|
||
end
|
||
end
|
||
|
||
@doc """
|
||
Inverse-sensor boost from commercial LOS link degradation.
|
||
|
||
Nearby short-path commercial links (11/24/68 GHz in the DFW area) act as a
|
||
physical refractivity anomaly sensor: when their rx_power drops 5+ dB below
|
||
the 7-day baseline *without* an equipment cause, the same multipath fading
|
||
that hurts their short Fresnel zone is what carries the beyond-LOS paths.
|
||
This is a terminal boost applied once per composite score, not per-band —
|
||
the underlying physics is band-agnostic at the resolution we can measure.
|
||
|
||
Takes the incoming composite (or any 0-100) score and a degradation map
|
||
from `Commercial.link_degradation_at/3`; returns a boosted integer score.
|
||
|
||
* nil or empty result (`n_links == 0`) — no-op
|
||
* <3 dB — noise floor, no-op
|
||
* 3–8 dB — mild boost (+0 to +10)
|
||
* ≥8 dB — strong boost (+10 to +25, clamped ≤ 100)
|
||
"""
|
||
@spec commercial_link_boost(number(), map() | nil) :: integer()
|
||
def commercial_link_boost(score, nil), do: clamp_score(score)
|
||
|
||
def commercial_link_boost(score, %{n_links: 0}), do: clamp_score(score)
|
||
|
||
def commercial_link_boost(score, %{degradation_db: db}) when db < 3.0, do: clamp_score(score)
|
||
|
||
def commercial_link_boost(score, %{degradation_db: db}) when db < 8.0 do
|
||
# 3 dB → +2; 7.9 dB → ~+10 (linear interpolation)
|
||
bonus = 2 + (db - 3.0) * 8.0 / 5.0
|
||
clamp_score(score + bonus)
|
||
end
|
||
|
||
def commercial_link_boost(score, %{degradation_db: db}) do
|
||
# 8 dB → +10; 16 dB → +25 (linear)
|
||
bonus = min(25.0, 10 + (db - 8.0) * 15.0 / 8.0)
|
||
clamp_score(score + bonus)
|
||
end
|
||
|
||
@doc """
|
||
Aurora-conditioned terminal boost for VHF/low-UHF bands during
|
||
active geomagnetic conditions.
|
||
|
||
Auroral E-region scatter is observed almost exclusively on
|
||
50/144/222 MHz, occasionally on 432 MHz, and essentially never at
|
||
microwave (>432 MHz). The boost magnitude follows the NOAA G-scale:
|
||
Kp 5+ storms open auroral paths up to ~2500 km, Kp 6 events are
|
||
reliably workable from much of CONUS, and Kp 7+ are continent-wide.
|
||
|
||
* `freq_mhz > 432` — pass-through (microwave doesn't see aurora)
|
||
* `kp < 4` — pass-through (geomagnetically quiet)
|
||
* `kp 4` (unsettled) — +5 (mild)
|
||
* `kp 5` (G1) — +15 (moderate)
|
||
* `kp 6` (G2) — +25 (strong)
|
||
* `kp >= 7` (G3+) — +35 (saturated)
|
||
|
||
Fractional `estimated_kp` from SWPC's per-minute feed rounds down
|
||
to the lower integer band so the threshold edges are deterministic.
|
||
Returns the score clamped to 0-100.
|
||
"""
|
||
@spec aurora_boost(number(), number() | nil, map()) :: integer()
|
||
def aurora_boost(score, nil, _band_config), do: clamp_score(score)
|
||
|
||
def aurora_boost(score, _kp, %{freq_mhz: f}) when f > 432, do: clamp_score(score)
|
||
|
||
def aurora_boost(score, kp, _band_config) when kp >= 7, do: clamp_score(score + 35)
|
||
def aurora_boost(score, kp, _band_config) when kp >= 6, do: clamp_score(score + 25)
|
||
def aurora_boost(score, kp, _band_config) when kp >= 5, do: clamp_score(score + 15)
|
||
def aurora_boost(score, kp, _band_config) when kp >= 4, do: clamp_score(score + 5)
|
||
def aurora_boost(score, _kp, _band_config), do: clamp_score(score)
|
||
|
||
@doc """
|
||
Standalone score for one propagation mechanism on this band.
|
||
|
||
Returns a 0–100 integer. Unlike `aurora_boost/3` this does not
|
||
add to anything — it's the score *of that mechanism alone*, so
|
||
`cell_score/2` can take the max across the band's mechanism stack
|
||
rather than summing tropo with a post-hoc bonus.
|
||
|
||
## `:tropo`
|
||
|
||
Delegates to `composite_score/2`'s integer score — the existing
|
||
weighted sum of HRRR-derived factors.
|
||
|
||
## `:aurora`
|
||
|
||
Auroral E-region scatter is observed on 50 / 144 / 222 MHz,
|
||
occasionally on 432 MHz, essentially never above. Scores follow
|
||
the NOAA G-scale ceiling for each Kp band:
|
||
|
||
* `kp` nil or `freq_mhz > 432` → 0
|
||
* `kp < 4` → 0 (geomagnetically quiet)
|
||
* `kp 4` (unsettled) → 40
|
||
* `kp 5` (G1) → 65
|
||
* `kp 6` (G2) → 85
|
||
* `kp ≥ 7` (G3+) → 100
|
||
|
||
Fractional `estimated_kp` from SWPC's per-minute feed truncates to
|
||
the lower integer band so threshold edges stay deterministic.
|
||
"""
|
||
@spec mechanism_score(atom(), map(), map()) :: integer()
|
||
def mechanism_score(:tropo, conditions, band_config) do
|
||
composite_score(conditions, band_config).score
|
||
end
|
||
|
||
def mechanism_score(:aurora, conditions, band_config) do
|
||
aurora_only_score(conditions[:kp_index], band_config)
|
||
end
|
||
|
||
@doc """
|
||
Cell-level propagation score for a band: `max` over the band's
|
||
active mechanisms.
|
||
|
||
The mechanism list comes from `BandConfig.mechanisms/1`. Tropo
|
||
applies everywhere; aurora applies on VHF / low UHF when Kp is
|
||
elevated. Es and F2 are inherently path-dependent and don't enter
|
||
cell-level scoring — `Propagation.PathCompute` handles those.
|
||
|
||
Conditions must include whatever each mechanism reads. For
|
||
`:aurora` that's `:kp_index`; for `:tropo` it's the usual
|
||
composite-score inputs.
|
||
"""
|
||
@spec cell_score(map(), map()) :: integer()
|
||
def cell_score(conditions, band_config) do
|
||
band_config
|
||
|> BandConfig.mechanisms()
|
||
|> Enum.map(&mechanism_score(&1, conditions, band_config))
|
||
|> Enum.max()
|
||
|> clamp_score()
|
||
end
|
||
|
||
defp aurora_only_score(nil, _band_config), do: 0
|
||
defp aurora_only_score(_kp, %{freq_mhz: f}) when f > 432, do: 0
|
||
defp aurora_only_score(kp, _band_config) when kp >= 7, do: 100
|
||
defp aurora_only_score(kp, _band_config) when kp >= 6, do: 85
|
||
defp aurora_only_score(kp, _band_config) when kp >= 5, do: 65
|
||
defp aurora_only_score(kp, _band_config) when kp >= 4, do: 40
|
||
defp aurora_only_score(_kp, _band_config), do: 0
|
||
|
||
defp clamp_score(value) do
|
||
value |> round() |> max(0) |> min(100)
|
||
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) when pct <= 6, do: 100
|
||
def score_sky(pct) when pct <= 25, do: 88
|
||
def score_sky(pct) when pct <= 50, do: 60
|
||
def score_sky(pct) when pct <= 87, do: 25
|
||
def score_sky(_pct), do: 5
|
||
|
||
# ── Factor 6: Season ─────────────────────────────────────────────
|
||
|
||
@doc """
|
||
Scores the seasonal effect for the given month and band config.
|
||
|
||
When lat/lon are provided, applies a regional adjustment multiplier
|
||
from `Propagation.Region` on top of the band's `seasonal_base` +
|
||
`seasonal_adj`. This corrects for the meteorologist's observation
|
||
that Gulf coast August is better than the uniform base suggests,
|
||
while Corn Belt August is worse.
|
||
"""
|
||
@spec score_season(integer(), float | nil, float | nil, map()) :: integer()
|
||
def score_season(month, lat, lon, %{seasonal_base: base_map, seasonal_adj: adj_map}) do
|
||
base = Map.get(base_map, month, 50)
|
||
adj = Map.get(adj_map, month, 0)
|
||
|
||
region_mult =
|
||
if is_number(lat) and is_number(lon) do
|
||
region = Region.for_point(lat, lon)
|
||
Region.seasonal_adjustment(region, month)
|
||
else
|
||
1.0
|
||
end
|
||
|
||
round(min(100, max(0, (base + adj) * region_mult)))
|
||
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) when speed_kts < 5, do: 100
|
||
def score_wind(speed_kts) when speed_kts < 10, do: 90
|
||
def score_wind(speed_kts) when speed_kts < 15, do: 75
|
||
def score_wind(speed_kts) when speed_kts < 20, do: 55
|
||
def score_wind(speed_kts) when speed_kts < 25, do: 35
|
||
def score_wind(_speed_kts), do: 15
|
||
|
||
# ── 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
|
||
score_rain_gamma(k * :math.pow(rain_rate_mmhr, alpha))
|
||
end
|
||
|
||
defp score_rain_gamma(gamma) when gamma < 0.1, do: 95
|
||
defp score_rain_gamma(gamma) when gamma < 0.5, do: 75
|
||
defp score_rain_gamma(gamma) when gamma < 1.0, do: 50
|
||
defp score_rain_gamma(gamma) when gamma < 2.0, do: 25
|
||
defp score_rain_gamma(gamma) when gamma < 5.0, do: 10
|
||
defp score_rain_gamma(_gamma), do: 0
|
||
|
||
# ── 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: score_pwat_beneficial(pwat_mm)
|
||
def score_pwat(pwat_mm, %{humidity_effect: :harmful}), do: score_pwat_harmful(pwat_mm)
|
||
|
||
defp score_pwat_beneficial(pwat) when pwat < 10, do: 55
|
||
defp score_pwat_beneficial(pwat) when pwat < 20, do: 75
|
||
defp score_pwat_beneficial(pwat) when pwat < 30, do: 90
|
||
defp score_pwat_beneficial(pwat) when pwat < 40, do: 70
|
||
defp score_pwat_beneficial(_pwat), do: 50
|
||
|
||
defp score_pwat_harmful(pwat) when pwat < 10, do: 95
|
||
defp score_pwat_harmful(pwat) when pwat < 20, do: 80
|
||
defp score_pwat_harmful(pwat) when pwat < 30, do: 60
|
||
defp score_pwat_harmful(pwat) when pwat < 40, do: 35
|
||
defp score_pwat_harmful(_pwat), do: 15
|
||
|
||
# ── 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: score_pressure_absolute(current_mb)
|
||
def score_pressure(current_mb, previous_mb), do: score_pressure_delta(current_mb - previous_mb)
|
||
|
||
defp score_pressure_absolute(mb) when mb < 980, do: 88
|
||
defp score_pressure_absolute(mb) when mb < 990, do: 82
|
||
defp score_pressure_absolute(mb) when mb < 1000, do: 70
|
||
defp score_pressure_absolute(mb) when mb < 1010, do: 55
|
||
defp score_pressure_absolute(mb) when mb < 1020, do: 40
|
||
defp score_pressure_absolute(_mb), do: 30
|
||
|
||
defp score_pressure_delta(delta) when delta > 2.5, do: 80
|
||
defp score_pressure_delta(delta) when delta > 0.8, do: 70
|
||
defp score_pressure_delta(delta) when delta > -0.5, do: 60
|
||
defp score_pressure_delta(delta) when delta > -2.0, do: 65
|
||
defp score_pressure_delta(_delta), do: 45
|
||
|
||
# ── Composite score ──────────────────────────────────────────────
|
||
|
||
@doc """
|
||
Precompute the four band-invariant factors so the grid scorer can
|
||
reuse them across all 17 band iterations for a single point. Saves
|
||
~30% of the scoring loop by hoisting shared arithmetic out of the
|
||
per-band call.
|
||
"""
|
||
@spec precompute_band_invariants(map()) :: %{
|
||
tod_score: integer(),
|
||
sky_score: integer(),
|
||
wind_score: integer(),
|
||
pressure_score: integer()
|
||
}
|
||
def precompute_band_invariants(conditions) do
|
||
{tod_score, _label} =
|
||
score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude)
|
||
|
||
%{
|
||
tod_score: tod_score,
|
||
sky_score: score_sky(conditions.sky_cover_pct),
|
||
wind_score: score_wind(conditions.wind_speed_kts),
|
||
pressure_score: score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)
|
||
}
|
||
end
|
||
|
||
@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
|
||
# Contract: band invariants are either all precomputed (via
|
||
# `precompute_band_invariants/1`, typically merged in by the grid
|
||
# scorer for per-point reuse across bands) or all absent. We check
|
||
# one key and derive the rest from the same branch so we never
|
||
# silently mix cached and freshly-computed values.
|
||
tod_score =
|
||
conditions[:tod_score] ||
|
||
elem(score_time_of_day(conditions.utc_hour, conditions.utc_minute, conditions.month, conditions.longitude), 0)
|
||
|
||
sky_score = conditions[:sky_score] || score_sky(conditions.sky_cover_pct)
|
||
wind_score = conditions[:wind_score] || score_wind(conditions.wind_speed_kts)
|
||
pressure_score = conditions[:pressure_score] || score_pressure(conditions.pressure_mb, conditions.prev_pressure_mb)
|
||
|
||
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,
|
||
conditions[:best_duct_band_ghz],
|
||
conditions[:bulk_richardson],
|
||
band_config
|
||
),
|
||
sky: sky_score,
|
||
season: score_season(conditions.month, conditions[:latitude], conditions[:longitude], band_config),
|
||
wind: wind_score,
|
||
rain: score_rain(conditions.rain_rate_mmhr, band_config),
|
||
pwat: score_pwat(conditions[:pwat_mm], band_config),
|
||
pressure: pressure_score
|
||
}
|
||
|
||
weights = BandConfig.weights(band_config)
|
||
|
||
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
|
||
|
||
@doc """
|
||
Merges multiple HRRR profiles along a path into a single conditions map.
|
||
|
||
Strategy:
|
||
- Beneficial factors (refractivity, pressure): use BEST (most favorable) along path
|
||
- Harmful factors (rain, wind): use WORST (least favorable) along path
|
||
- Other factors (temp, dewpoint, PWAT, BL depth): use path AVERAGE
|
||
- Time/season/sky: taken from first profile (same for entire path)
|
||
"""
|
||
@spec path_integrated_conditions([map()], map()) :: map() | nil
|
||
def path_integrated_conditions(profiles, contact) do
|
||
extracted = extract_profile_fields(profiles)
|
||
build_path_conditions(extracted, contact)
|
||
end
|
||
|
||
@path_fields [
|
||
{:temps, :surface_temp_c},
|
||
{:dewpoints, :surface_dewpoint_c},
|
||
{:pressures, :surface_pressure_mb},
|
||
{:gradients, :min_refractivity_gradient},
|
||
{:bl_depths, :hpbl_m},
|
||
{:pwats, :pwat_mm}
|
||
]
|
||
|
||
defp extract_profile_fields(profiles) do
|
||
empty = Map.new(@path_fields, fn {bucket, _} -> {bucket, []} end)
|
||
Enum.reduce(profiles, empty, &accumulate_profile/2)
|
||
end
|
||
|
||
defp accumulate_profile(profile, acc) do
|
||
Enum.reduce(@path_fields, acc, fn {bucket, key}, acc ->
|
||
maybe_prepend(acc, bucket, Map.get(profile, key))
|
||
end)
|
||
end
|
||
|
||
defp maybe_prepend(acc, _bucket, nil), do: acc
|
||
defp maybe_prepend(acc, bucket, value), do: Map.update!(acc, bucket, &[value | &1])
|
||
|
||
defp build_path_conditions(%{temps: []}, _contact), do: nil
|
||
defp build_path_conditions(%{dewpoints: []}, _contact), do: nil
|
||
|
||
defp build_path_conditions(%{temps: temps, dewpoints: dewpoints} = buckets, contact) do
|
||
lon = path_longitude(contact)
|
||
{sum_t, count_t} = Enum.reduce(temps, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
|
||
avg_temp_c = sum_t / count_t
|
||
{sum_d, count_d} = Enum.reduce(dewpoints, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
|
||
avg_dewpoint_c = sum_d / count_d
|
||
|
||
%{
|
||
abs_humidity: absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
||
temp_f: c_to_f(avg_temp_c),
|
||
dewpoint_f: c_to_f(avg_dewpoint_c),
|
||
wind_speed_kts: nil,
|
||
sky_cover_pct: nil,
|
||
utc_hour: path_hour(contact),
|
||
utc_minute: path_minute(contact),
|
||
month: path_month(contact),
|
||
longitude: lon,
|
||
pressure_mb: Enum.min(buckets.pressures, fn -> nil end),
|
||
prev_pressure_mb: nil,
|
||
rain_rate_mmhr: 0.0,
|
||
min_refractivity_gradient: Enum.min(buckets.gradients, fn -> nil end),
|
||
bl_depth_m: safe_avg(buckets.bl_depths),
|
||
pwat_mm: safe_avg(buckets.pwats)
|
||
}
|
||
end
|
||
|
||
defp safe_avg([]), do: nil
|
||
|
||
defp safe_avg(list) do
|
||
{sum, count} = Enum.reduce(list, {0, 0}, fn x, {s, c} -> {s + x, c + 1} end)
|
||
sum / count
|
||
end
|
||
|
||
# Extract longitude from a contact, which may be a schema struct with
|
||
# `pos1["lon"]` or a plain map with `:longitude`. Falls back to CONUS-centre.
|
||
defp path_longitude(%{pos1: %{} = pos1}), do: Map.get(pos1, "lon", -97.0)
|
||
defp path_longitude(%{longitude: lon}) when is_number(lon), do: lon
|
||
defp path_longitude(_contact), do: -97.0
|
||
|
||
# Extract hour/minute/month from a contact, which may be a schema struct
|
||
# with `qso_timestamp` or a plain map with direct keys.
|
||
defp path_hour(%{qso_timestamp: %{hour: h}}), do: h
|
||
defp path_hour(%{utc_hour: h}), do: h
|
||
|
||
defp path_minute(%{qso_timestamp: %{minute: m}}), do: m
|
||
defp path_minute(%{utc_minute: m}), do: m
|
||
|
||
defp path_month(%{qso_timestamp: %{month: m}}), do: m
|
||
defp path_month(%{month: m}), do: m
|
||
end
|