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:
parent
0e877f889a
commit
32e7cccc40
6 changed files with 95 additions and 10 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue