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