prop/lib/microwaveprop/propagation/scorer.ex
Graham McIntire 316fb2fbc7
Fix low-severity bugs and re-enable Credo checks
- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
2026-05-29 17:29:22 -05:00

667 lines
27 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# 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
2002,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
* 38 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 0100 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 = contact.pos1["lon"] || -97.0
{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: contact.qso_timestamp.hour,
utc_minute: contact.qso_timestamp.minute,
month: contact.qso_timestamp.month,
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
end