From 32e7cccc4048b71b6cd801b386feb546fecd4bfe Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 24 Apr 2026 13:52:58 -0500 Subject: [PATCH] fix(weather): accept Rust profile shape in SoundingParams + WeatherLayers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust f01..f18 pipeline writes ProfilesFile cells, and the Rust hrrr_points worker writes hrrr_profiles rows, with profile entries keyed "pres_mb" / "hght_m" / "tmpc" / "dwpc" (string keys pre- atomization; atom keys post-ProfilesFile.read). Elixir's SoundingParams.derive and WeatherLayers.sort_profile filtered on "pres" / "hght" — so every derived field returned nil for any Rust-provided profile. Visible symptoms on /weather: N-gradient, Refractivity, T @ 850mb, Td @ 850mb, Lapse Rate, Inversion, Inv. Base all rendered no overlay. For per-contact analysis, min_refractivity_gradient from the Rust HRRR point worker's rows silently dropped. - SoundingParams.normalize_profile_entry/1: single-source normalizer accepting legacy, Rust-string, and Rust-atom shapes; returns the canonical "pres"/"hght"/"tmpc"/"dwpc"/"drct"/"sknt" shape. - SoundingParams.derive/1 and WeatherLayers.sort_profile/1 both run every entry through it before the rest of the existing logic. - Weather.build_grid_cache_row/4 now falls back to native_min_gradient when SoundingParams can't derive one (surface-only profiles). Also: - Default /weather overlay changed to Temperature (LiveView assign + JS fallback in the hook's dataset default) per user request. --- assets/js/weather_map_hook.ts | 2 +- lib/microwaveprop/weather.ex | 16 ++++--- lib/microwaveprop/weather/sounding_params.ex | 43 ++++++++++++++++++- lib/microwaveprop/weather/weather_layers.ex | 5 +++ .../live/weather_map_live.ex | 2 +- test/microwaveprop/weather_test.exs | 37 ++++++++++++++++ 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/assets/js/weather_map_hook.ts b/assets/js/weather_map_hook.ts index 18953767..601f9d7b 100644 --- a/assets/js/weather_map_hook.ts +++ b/assets/js/weather_map_hook.ts @@ -390,7 +390,7 @@ export const WeatherMap: WeatherMapHook = { this.weatherOverlay = null this.weatherData = [] this.gridLookup = new Map() - this.selectedLayer = (this.el.dataset.selectedLayer || "refractivity_gradient") as LayerId + this.selectedLayer = (this.el.dataset.selectedLayer || "temperature") as LayerId const storedLayer = localStorage.getItem("weatherMap.selectedLayer") if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) { diff --git a/lib/microwaveprop/weather.ex b/lib/microwaveprop/weather.ex index 72e5b22b..2e211f27 100644 --- a/lib/microwaveprop/weather.ex +++ b/lib/microwaveprop/weather.ex @@ -895,13 +895,15 @@ defmodule Microwaveprop.Weather do if is_nil(temp_c) or temp_c < -80 or temp_c > 60 do [] else + # `SoundingParams.derive` + `WeatherLayers.sort_profile` both call + # `SoundingParams.normalize_profile_entry/1` internally, so we can + # hand either shape (legacy string-keyed or Rust atom-keyed) through + # unchanged. `surface_refractivity` and `min_refractivity_gradient` + # aren't persisted by Rust — they flow through from the per-cell + # sounding derivation; `refractivity_gradient` falls back to the + # `native_min_gradient` scalar the Rust f01..f18 pipeline does write. sounding = derive_sounding(profile[:profile]) - # Prefer explicit values from the profile map (Rust-written ProfilesFile - # persists derived sounding params alongside the raw profile) and fall - # back to deriving from the profile list when absent. `prefer/3` uses - # `Map.fetch/2` so a legitimate `false` / `0` persisted value is not - # silently clobbered by a `||` fallback. row = %{ lat: lat, lon: lon, @@ -910,7 +912,9 @@ defmodule Microwaveprop.Weather do dewpoint_depression: depression(temp_c, profile[:surface_dewpoint_c]), bl_height: profile[:hpbl_m], pwat: profile[:pwat_mm], - refractivity_gradient: prefer(profile, :min_refractivity_gradient, sounding[:min_refractivity_gradient]), + refractivity_gradient: + prefer(profile, :min_refractivity_gradient, sounding[:min_refractivity_gradient]) || + profile[:native_min_gradient], ducting: prefer(profile, :ducting_detected, sounding[:ducting_detected]), surface_pressure_mb: profile[:surface_pressure_mb], surface_temp_c: temp_c, diff --git a/lib/microwaveprop/weather/sounding_params.ex b/lib/microwaveprop/weather/sounding_params.ex index 3f7e74b5..976c1930 100644 --- a/lib/microwaveprop/weather/sounding_params.ex +++ b/lib/microwaveprop/weather/sounding_params.ex @@ -17,13 +17,22 @@ defmodule Microwaveprop.Weather.SoundingParams do @doc """ Derive atmospheric propagation parameters from a sounding profile. - Profile is a list of maps with keys: "pres", "hght", "tmpc", "dwpc", "drct", "sknt". - Returns nil if fewer than 3 valid levels. + Accepts either: + * Legacy shape — string keys `"pres"` / `"hght"` / `"tmpc"` / `"dwpc"` + (Elixir's HrrrClient, NARR, GEFS, UWYO clients). + * Rust shape — `"pres_mb"` / `"hght_m"` / `"tmpc"` / `"dwpc"` (string + or atom keys from the Rust hrrr_points worker and the f01..f18 + ProfilesFile cells). + + Both are normalized at entry to the canonical legacy shape so the + rest of this module can assume `p["pres"]` / `p["hght"]` without per- + source branching. Returns nil if fewer than 3 valid levels. """ @spec derive([map()]) :: map() | nil def derive(profile) when is_list(profile) do sorted = profile + |> Enum.map(&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) @@ -34,6 +43,36 @@ defmodule Microwaveprop.Weather.SoundingParams do end end + @doc """ + Normalize a single profile entry to the canonical string-keyed shape + (`"pres"` / `"hght"` / `"tmpc"` / `"dwpc"`). Tolerates: + * The already-canonical shape (returned unchanged). + * Rust's `"pres_mb"` / `"hght_m"` string keys. + * Atom-keyed variants from `ProfilesFile.read/1` after Elixir's + atomization pass. + + Exposed so `WeatherLayers` and other profile consumers share the + same input contract. + """ + @spec normalize_profile_entry(map()) :: map() + def normalize_profile_entry(%{"pres" => _} = entry), do: entry + + def normalize_profile_entry(entry) when is_map(entry) do + %{ + "pres" => pick(entry, ["pres_mb", :pres_mb, :pres]), + "hght" => pick(entry, ["hght_m", :hght_m, "hght", :hght]), + "tmpc" => pick(entry, ["tmpc", :tmpc]), + "dwpc" => pick(entry, ["dwpc", :dwpc]), + "drct" => pick(entry, ["drct", :drct]), + "sknt" => pick(entry, ["sknt", :sknt]) + } + end + + def normalize_profile_entry(entry), do: entry + + # Return the first non-nil value found under any of the candidate keys. + defp pick(map, keys), do: Enum.find_value(keys, fn k -> Map.get(map, k) end) + defp do_derive(sorted) do sfc = hd(sorted) sfc_hght = sfc["hght"] diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index d1a676a5..e1846996 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -34,7 +34,12 @@ defmodule Microwaveprop.Weather.WeatherLayers 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 diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 15cdb736..10a3fb8b 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -168,7 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do assign(socket, page_title: "Weather Map", layers: @layers, - selected_layer: "refractivity_gradient", + selected_layer: "temperature", initial_data_json: Jason.encode!(data), valid_time: initial_vt, valid_times: valid_times, diff --git a/test/microwaveprop/weather_test.exs b/test/microwaveprop/weather_test.exs index 209eeb67..59146778 100644 --- a/test/microwaveprop/weather_test.exs +++ b/test/microwaveprop/weather_test.exs @@ -26,6 +26,43 @@ defmodule Microwaveprop.WeatherTest do lon: -97.30 } + describe "build_grid_cache_rows/3 — Rust ProfilesFile shape" do + @rust_profile %{ + surface_temp_c: 25.0, + surface_dewpoint_c: 18.0, + surface_pressure_mb: 1013.0, + hpbl_m: 1500.0, + pwat_mm: 35.0, + native_min_gradient: -120.0, + profile: [ + %{pres_mb: 1013.0, hght_m: 50.0, tmpc: 25.0, dwpc: 18.0}, + %{pres_mb: 925.0, hght_m: 800.0, tmpc: 18.0, dwpc: 14.0}, + %{pres_mb: 850.0, hght_m: 1500.0, tmpc: 12.0, dwpc: 9.0}, + %{pres_mb: 700.0, hght_m: 3000.0, tmpc: 0.0, dwpc: -5.0} + ] + } + + test "derives upper-air + refractivity fields from an atom-keyed Rust profile" do + grid_data = %{{32.875, -97.0} => @rust_profile} + valid_time = ~U[2026-04-24 16:00:00Z] + + [row] = Weather.build_grid_cache_rows(grid_data, valid_time) + + # Upper-air fields flow from the normalized profile. + assert is_number(row.temp_850mb) + assert is_number(row.dewpoint_850mb) + assert is_number(row.lapse_rate) + assert is_number(row.inversion_strength) + + # Surface refractivity is derived when Rust doesn't emit it. + assert is_number(row.surface_refractivity) + + # Gradient uses the native_min_gradient fallback when + # SoundingParams can't derive one (or matches when it can). + assert is_number(row.refractivity_gradient) + end + end + describe "reconcile_weather_statuses/0" do alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact