From f8dee5e670b87adbed4a4ec52cea716197dc85c0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 8 May 2026 13:02:15 -0500 Subject: [PATCH] fix: pre-compute refractivity in Rust profile entries, fall back in SkewT Rust cell_to_profile_entry now writes surface_refractivity and min_refractivity_gradient at the cell level so Elixir consumers have a guaranteed fallback when SoundingParams.derive hits levels without dewpoint. The SkewT build_profile_assigns falls back to these pre-computed cell keys when the derived map has nil for those fields. --- lib/microwaveprop_web/live/skewt_live.ex | 29 ++++++- rust/prop_grid_rs/src/pipeline.rs | 83 ++++++++++++++++++- .../weather/sounding_params_test.exs | 41 +++++++++ 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/lib/microwaveprop_web/live/skewt_live.ex b/lib/microwaveprop_web/live/skewt_live.ex index 72bd9f7e..539d4b59 100644 --- a/lib/microwaveprop_web/live/skewt_live.ex +++ b/lib/microwaveprop_web/live/skewt_live.ex @@ -199,19 +199,28 @@ defmodule MicrowavepropWeb.SkewtLive do cell -> profile = extract_profile(cell) - build_profile_assigns(profile) + build_profile_assigns(profile, cell) end end - defp build_profile_assigns([]), do: {nil, nil, nil} + defp build_profile_assigns([], _cell), do: {nil, nil, nil} - defp build_profile_assigns(profile) do + defp build_profile_assigns(profile, cell) do sounding = SoundingParams.derive(profile) spc = SkewtParams.derive(profile) # Merge so the LiveView template only has to read one map. # SoundingParams keys (atoms) and SkewtParams keys (atoms) # are disjoint by construction. - derived = Map.merge(sounding || %{}, spc) + derived = + (sounding || %{}) + |> Map.merge(spc) + # Fall back to pre-computed values from the Rust ProfilesFile entry. + # SoundingParams.derive needs dewpoint at every level to compute + # refractivity; when any level is missing it, the scalar comes back + # nil. The Rust writer pre-computes these from the same levels + # (filtering identically) and writes them at the top of the cell. + |> fill_from_cell(cell, :surface_refractivity) + |> fill_from_cell(cell, :min_refractivity_gradient) svg = SkewtSvg.render(profile, @@ -222,6 +231,18 @@ defmodule MicrowavepropWeb.SkewtLive do {profile, derived, svg} end + # Use a top-level cell key as fallback when the derived map has nil for + # that key. Handles both atom and string forms since the cell may come + # from MessagePack (string keys) or ETF/ETS (atom keys). + defp fill_from_cell(derived, cell, key) do + if derived[key] != nil do + derived + else + fallback = cell[key] || cell[Atom.to_string(key)] + if fallback != nil, do: Map.put(derived, key, fallback), else: derived + end + end + # The grid-cached profile only contains 13 pressure levels (1000→700 mb) # because the hourly chain trims its fetch to keep memory bounded. For # the skew-T page we want the full 25-level set (1000→100 mb), so issue diff --git a/rust/prop_grid_rs/src/pipeline.rs b/rust/prop_grid_rs/src/pipeline.rs index 4dd0b7be..a2162a75 100644 --- a/rust/prop_grid_rs/src/pipeline.rs +++ b/rust/prop_grid_rs/src/pipeline.rs @@ -806,6 +806,59 @@ mod tests { decoded.n_rows as usize * decoded.n_cols as usize ); } + + /// cell_to_profile_entry includes pre-computed surface_refractivity + /// and min_refractivity_gradient so Elixir consumers (Skew-T, + /// contact detail) have a guaranteed fallback when SoundingParams.derive + /// hits a level without dewpoint. + #[test] + fn profile_entry_includes_refractivity_scalars() { + let mut cell = CellValues::new(); + cell.insert("TMP:2 m above ground".into(), 298.15); + cell.insert("DPT:2 m above ground".into(), 293.15); + cell.insert("PRES:surface".into(), 101_300.0); + cell.insert("HPBL:surface".into(), 1500.0); + cell.insert( + "PWAT:entire atmosphere (considered as a single layer)".into(), + 30.0, + ); + cell.insert("TMP:1000 mb".into(), 295.0); + cell.insert("DPT:1000 mb".into(), 290.0); + cell.insert("HGT:1000 mb".into(), 100.0); + cell.insert("TMP:925 mb".into(), 292.0); + cell.insert("DPT:925 mb".into(), 285.0); + cell.insert("HGT:925 mb".into(), 800.0); + cell.insert("TMP:850 mb".into(), 289.0); + cell.insert("DPT:850 mb".into(), 280.0); + cell.insert("HGT:850 mb".into(), 1500.0); + + let entry = cell_to_profile_entry(32.9, -97.0, &cell); + let map = entry.profile.as_map().expect("profile is a map"); + let has_sr = map.iter().any(|(k, _)| k.as_str() == Some("surface_refractivity")); + let has_mg = map.iter().any(|(k, _)| k.as_str() == Some("min_refractivity_gradient")); + assert!(has_sr, "surface_refractivity key missing"); + assert!(has_mg, "min_refractivity_gradient key missing"); + } + + /// When every pressure level is missing dewpoint, surface_refractivity + /// and min_refractivity_gradient are omitted. + #[test] + fn profile_entry_omits_refractivity_when_no_dewpoint() { + let mut cell = CellValues::new(); + cell.insert("TMP:2 m above ground".into(), 298.15); + cell.insert("PRES:surface".into(), 101_300.0); + cell.insert("TMP:1000 mb".into(), 295.0); + cell.insert("HGT:1000 mb".into(), 100.0); + cell.insert("TMP:925 mb".into(), 292.0); + cell.insert("HGT:925 mb".into(), 800.0); + + let entry = cell_to_profile_entry(32.9, -97.0, &cell); + let map = entry.profile.as_map().expect("profile is a map"); + let has_sr = map.iter().any(|(k, _)| k.as_str() == Some("surface_refractivity")); + let has_mg = map.iter().any(|(k, _)| k.as_str() == Some("min_refractivity_gradient")); + assert!(!has_sr, "surface_refractivity should be absent without dewpoint"); + assert!(!has_mg, "min_refractivity_gradient should be absent without dewpoint"); + } } // ===================================================================== @@ -1151,18 +1204,32 @@ fn cell_to_profile_entry(lat: f64, lon: f64, cell: &CellValues) -> CellEntry { // Pressure-level profile list for SoundingParams.derive on the // Elixir side. Mirrors the fields derive expects. + // Also collect typed `Level` structs so we can pre-compute + // surface_refractivity and min_refractivity_gradient — the Elixir + // Skew-T page needs these but SoundingParams.derive will produce nil + // when any level is missing dewpoint. Pre-computing in Rust (which + // filters the same way) gives the Elixir side a guaranteed fallback. + let mut typed_levels: Vec = Vec::with_capacity(fetcher::grid_level_keys().len()); let levels: Vec = fetcher::grid_level_keys() .iter() .filter_map(|k| { let t = cell.get(k.tmp.as_str()).copied()?; let d = cell.get(k.dpt.as_str()).copied(); let h = cell.get(k.hgt.as_str()).copied()?; + let tmpc = (t as f64) - 273.15; + let dwpc = d.map(|dv| (dv as f64) - 273.15); + typed_levels.push(Level { + pres_mb: k.pres_mb, + hght_m: h as f64, + tmpc, + dwpc, + }); let mut pairs: Vec<(V, V)> = Vec::with_capacity(4); pairs.push((V::String("pres_mb".into()), V::F64(k.pres_mb))); pairs.push((V::String("hght_m".into()), V::F64(h as f64))); - pairs.push((V::String("tmpc".into()), V::F64((t as f64) - 273.15))); - if let Some(dv) = d { - pairs.push((V::String("dwpc".into()), V::F64((dv as f64) - 273.15))); + pairs.push((V::String("tmpc".into()), V::F64(tmpc))); + if let Some(dv) = dwpc { + pairs.push((V::String("dwpc".into()), V::F64(dv))); } Some(V::Map(pairs)) }) @@ -1171,6 +1238,16 @@ fn cell_to_profile_entry(lat: f64, lon: f64, cell: &CellValues) -> CellEntry { kv.push(("profile", V::Array(levels))); } + // Pre-compute refractivity scalars so Elixir consumers (Skew-T, + // contact detail) have a guaranteed fallback when the pressure-level + // profile is missing dewpoint on some levels. + if let Some(sr) = sounding_params::surface_refractivity(&typed_levels) { + kv.push(("surface_refractivity", V::F64(sr))); + } + if let Some(mg) = sounding_params::min_refractivity_gradient(typed_levels) { + kv.push(("min_refractivity_gradient", V::F64(mg))); + } + let profile = value_map(kv); CellEntry { lat, lon, profile } diff --git a/test/microwaveprop/weather/sounding_params_test.exs b/test/microwaveprop/weather/sounding_params_test.exs index fcf36adb..eda4e22e 100644 --- a/test/microwaveprop/weather/sounding_params_test.exs +++ b/test/microwaveprop/weather/sounding_params_test.exs @@ -194,5 +194,46 @@ defmodule Microwaveprop.Weather.SoundingParamsTest do result = SoundingParams.derive(profile_with_nils) assert result.level_count == 3 end + + test "tolerates Rust-style keys (pres_mb, hght_m) via normalization" do + rust_profile = [ + %{"pres_mb" => 1013.0, "hght_m" => 171, "tmpc" => 25.0, "dwpc" => 15.0}, + %{"pres_mb" => 925.0, "hght_m" => 800, "tmpc" => 20.0, "dwpc" => 12.0}, + %{"pres_mb" => 850.0, "hght_m" => 1500, "tmpc" => 15.0, "dwpc" => 8.0} + ] + + result = SoundingParams.derive(rust_profile) + assert result.level_count == 3 + assert result.surface_refractivity > 300 + assert result.min_refractivity_gradient < 0 + end + + test "returns nil refractivity when every level is missing dewpoint" do + no_dwpc = [ + %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0}, + %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0}, + %{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0} + ] + + result = SoundingParams.derive(no_dwpc) + assert result.surface_refractivity == nil + assert result.min_refractivity_gradient == nil + end + + test "computes refractivity from levels that have dewpoint, ignoring those that don't" do + mixed_dwpc = [ + %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0}, + %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0}, + %{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0} + ] + + result = SoundingParams.derive(mixed_dwpc) + # Surface level has no dwpc, so surface_n uses the first level that does (925 mb) + assert result.surface_refractivity != nil + assert result.surface_refractivity > 300 + # Two levels with dewpoint → one gradient → min_gradient is that value + assert result.min_refractivity_gradient != nil + assert result.min_refractivity_gradient < 0 + end end end