prop/lib/microwaveprop/weather/weather_layers.ex
Graham McIntire 3f2d97735e
fix: three more Rust/Elixir contract drift bugs on /map and /weather
Found by auditing the ProfilesFile, hrrr_profiles, and weather-layer
contracts against what the Rust writers actually emit.

Bug 1 (HIGH) — Rust `hrrr_points` worker upserted hrrr_profiles rows
  with NULL `surface_refractivity`, `min_refractivity_gradient`, and
  `ducting_detected`. The Elixir HrrrClient path always wrote them
  via SoundingParams.derive. Per-QSO pages that rely on these
  scalars ("N:", "dN/dh:", duct badge) showed "—" for any contact
  enriched through Stream C. Fixed by porting
  `surface_refractivity` + `ducting_detected` into Rust's
  `sounding_params.rs` and deriving at upsert time.

Bug 2 (HIGH) — Rust f01..f18 `cell_to_profile_entry` never emitted
  wind_u / wind_v / cloud_cover_pct / precip_mm into ProfilesFile
  cells. `Propagation.factors_for` recomputes factor scores from the
  profile cell on /map click, and these four fields are read
  (wind_speed_kts, sky_cover_pct, precip_to_rate_mmhr). Result: every
  f01..f18 point-detail popup showed 0 for wind/sky/rain factors.
  Fixed by extracting the grib values at cell_to_profile_entry time
  and whitelisting the keys for atomization in
  profiles_file.ex `@mp_atom_keys`.

Bug 4 (MEDIUM) — `WeatherLayers.duct_field/2` only knew the
  `SoundingParams.detect_ducts` shape (`d["base"]` / `d["strength"]`).
  The Rust/HrrrNativeClient shape uses `:base_m` / `:thickness_m`
  atom keys (post-ProfilesFile.read atomization). On /weather, the
  Duct Base / Duct Strength layers returned nil for every Rust-origin
  cell. Tolerant lookup now reads either shape; uses `thickness_m`
  as the "strength" proxy for the native shape (thicker duct traps
  a wider band).

Skipped (verified but lower impact):
- Rust ProfilesFile aggregates ducts as scalars, not per-layer — the
  `ducts` array in the popup is always empty. Requires larger
  DuctMetrics refactor in Rust; deferred.
- `complete_hrrr_task` emits no NOTIFY (vs `complete` which does).
  Elixir has no matching listener yet, so adding NOTIFY alone is a
  no-op; the existing cron reconciler catches it.
- `:best_duct_band_ghz` fallback in propagation.ex is dead code;
  cleanup, not a bug.

133 Rust tests + 2842 Elixir tests + credo green.
2026-04-24 14:04:02 -05:00

148 lines
4.5 KiB
Elixir

defmodule Microwaveprop.Weather.WeatherLayers do
@moduledoc "Derives scalar weather map layers from HRRR profile data."
alias Microwaveprop.Weather.SoundingParams
@doc """
Derives a map of scalar weather fields from a row containing HRRR profile data.
Expects a map with keys: `:surface_temp_c`, `:surface_dewpoint_c`,
`:surface_pressure_mb`, `:surface_refractivity`, `:profile`, `:duct_characteristics`.
Returns a map with derived fields: `:surface_rh`, `:surface_refractivity`,
`:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`,
`:inversion_base_m`, `:duct_base_m`, `:duct_strength`.
"""
@spec derive(map()) :: map()
def derive(row) do
sorted = sort_profile(row.profile)
%{
surface_rh: compute_rh(row.surface_temp_c, row.surface_dewpoint_c),
surface_refractivity: row.surface_refractivity,
temp_850mb: level_value(sorted, 850, "tmpc"),
dewpoint_850mb: level_value(sorted, 850, "dwpc"),
lapse_rate: compute_lapse_rate(sorted),
inversion_strength: inversion_strength(sorted),
inversion_base_m: inversion_base_m(sorted),
duct_base_m: duct_field(row.duct_characteristics, "base"),
duct_strength: duct_field(row.duct_characteristics, "strength")
}
end
defp sort_profile(nil), do: []
defp sort_profile([]), do: []
defp sort_profile(profile) do
# Accept either legacy string keys (`"pres"`, `"hght"`) or the
# Rust ProfilesFile shape (`"pres_mb"` / atom `:pres_mb` post-atomization
# via ProfilesFile.read). `SoundingParams.normalize_profile_entry/1`
# is the single source of truth for that coercion.
profile
|> Enum.map(&SoundingParams.normalize_profile_entry/1)
|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
end
defp compute_rh(t_c, td_c) do
100.0 * SoundingParams.sat_vap_pres(td_c) / SoundingParams.sat_vap_pres(t_c)
end
defp level_value([], _target_pres, _key), do: nil
defp level_value(sorted, target_pres, key) do
nearest = Enum.min_by(sorted, fn p -> abs(p["pres"] - target_pres) end)
if abs(nearest["pres"] - target_pres) <= 25.0 do
nearest[key]
end
end
defp compute_lapse_rate([]), do: nil
defp compute_lapse_rate(sorted) when length(sorted) < 2, do: nil
defp compute_lapse_rate(sorted) do
surface = hd(sorted)
top = List.last(sorted)
dh_km = (top["hght"] - surface["hght"]) / 1000.0
if dh_km > 0 do
(surface["tmpc"] - top["tmpc"]) / dh_km
end
end
defp inversion_strength([]), do: 0.0
defp inversion_strength(sorted) do
sorted
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce(0.0, fn [lower, upper], max_str ->
dt = upper["tmpc"] - lower["tmpc"]
if dt > max_str do
dt
else
max_str
end
end)
end
defp inversion_base_m([]), do: nil
defp inversion_base_m(sorted) do
sfc_hght = hd(sorted)["hght"]
result =
sorted
|> Enum.chunk_every(2, 1, :discard)
|> Enum.reduce({0.0, nil}, fn [lower, upper], {max_str, _base} = acc ->
dt = upper["tmpc"] - lower["tmpc"]
if dt > 0 and dt > max_str do
{dt, lower["hght"] - sfc_hght}
else
acc
end
end)
case result do
{str, base} when str > 0 -> base
_ -> nil
end
end
defp duct_field(nil, _key), do: nil
defp duct_field([], _key), do: nil
# Ducts arrive in two shapes:
#
# * `SoundingParams.detect_ducts` emits string keys `"base"` / `"strength"`.
# * HrrrNativeClient and the Rust f00 pipeline emit atom keys
# `:base_m` / `:top_m` / `:thickness_m` / `:min_freq_ghz`, which get
# atomized by `ProfilesFile.read/1` via the `@mp_atom_keys` whitelist.
#
# Read both shapes so `/weather` renders Duct Base / Duct Strength
# layers regardless of producer. `:thickness_m` stands in for
# "strength" when the native shape is in use — thicker ducts trap a
# wider frequency range, so it's the most meaningful scalar.
defp duct_field(ducts, "base") do
ducts
|> Enum.map(fn d -> d["base"] || d[:base_m] || d["base_m"] end)
|> Enum.reject(&is_nil/1)
|> safe_min()
end
defp duct_field(ducts, "strength") do
ducts
|> Enum.map(fn d -> d["strength"] || d[:thickness_m] || d["thickness_m"] end)
|> Enum.reject(&is_nil/1)
|> safe_max()
end
defp safe_min([]), do: nil
defp safe_min(xs), do: Enum.min(xs)
defp safe_max([]), do: nil
defp safe_max(xs), do: Enum.max(xs)
end