fix(weather): accept Rust profile shape in SoundingParams + WeatherLayers

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.
This commit is contained in:
Graham McIntire 2026-04-24 13:52:58 -05:00
parent 0e877f889a
commit 32e7cccc40
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 95 additions and 10 deletions

View file

@ -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) {

View file

@ -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,

View file

@ -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"]

View file

@ -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

View file

@ -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,

View file

@ -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