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.weatherOverlay = null
|
||||||
this.weatherData = []
|
this.weatherData = []
|
||||||
this.gridLookup = new Map()
|
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")
|
const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
|
||||||
if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.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
|
if is_nil(temp_c) or temp_c < -80 or temp_c > 60 do
|
||||||
[]
|
[]
|
||||||
else
|
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])
|
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 = %{
|
row = %{
|
||||||
lat: lat,
|
lat: lat,
|
||||||
lon: lon,
|
lon: lon,
|
||||||
|
|
@ -910,7 +912,9 @@ defmodule Microwaveprop.Weather do
|
||||||
dewpoint_depression: depression(temp_c, profile[:surface_dewpoint_c]),
|
dewpoint_depression: depression(temp_c, profile[:surface_dewpoint_c]),
|
||||||
bl_height: profile[:hpbl_m],
|
bl_height: profile[:hpbl_m],
|
||||||
pwat: profile[:pwat_mm],
|
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]),
|
ducting: prefer(profile, :ducting_detected, sounding[:ducting_detected]),
|
||||||
surface_pressure_mb: profile[:surface_pressure_mb],
|
surface_pressure_mb: profile[:surface_pressure_mb],
|
||||||
surface_temp_c: temp_c,
|
surface_temp_c: temp_c,
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,22 @@ defmodule Microwaveprop.Weather.SoundingParams do
|
||||||
@doc """
|
@doc """
|
||||||
Derive atmospheric propagation parameters from a sounding profile.
|
Derive atmospheric propagation parameters from a sounding profile.
|
||||||
|
|
||||||
Profile is a list of maps with keys: "pres", "hght", "tmpc", "dwpc", "drct", "sknt".
|
Accepts either:
|
||||||
Returns nil if fewer than 3 valid levels.
|
* 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
|
@spec derive([map()]) :: map() | nil
|
||||||
def derive(profile) when is_list(profile) do
|
def derive(profile) when is_list(profile) do
|
||||||
sorted =
|
sorted =
|
||||||
profile
|
profile
|
||||||
|
|> Enum.map(&normalize_profile_entry/1)
|
||||||
|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|
|> 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)
|
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
|
||||||
|
|
||||||
|
|
@ -34,6 +43,36 @@ defmodule Microwaveprop.Weather.SoundingParams do
|
||||||
end
|
end
|
||||||
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
|
defp do_derive(sorted) do
|
||||||
sfc = hd(sorted)
|
sfc = hd(sorted)
|
||||||
sfc_hght = sfc["hght"]
|
sfc_hght = sfc["hght"]
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
||||||
defp sort_profile([]), do: []
|
defp sort_profile([]), do: []
|
||||||
|
|
||||||
defp sort_profile(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
|
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.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|
||||||
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
|
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
||||||
assign(socket,
|
assign(socket,
|
||||||
page_title: "Weather Map",
|
page_title: "Weather Map",
|
||||||
layers: @layers,
|
layers: @layers,
|
||||||
selected_layer: "refractivity_gradient",
|
selected_layer: "temperature",
|
||||||
initial_data_json: Jason.encode!(data),
|
initial_data_json: Jason.encode!(data),
|
||||||
valid_time: initial_vt,
|
valid_time: initial_vt,
|
||||||
valid_times: valid_times,
|
valid_times: valid_times,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,43 @@ defmodule Microwaveprop.WeatherTest do
|
||||||
lon: -97.30
|
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
|
describe "reconcile_weather_statuses/0" do
|
||||||
alias Microwaveprop.Radio
|
alias Microwaveprop.Radio
|
||||||
alias Microwaveprop.Radio.Contact
|
alias Microwaveprop.Radio.Contact
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue