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.
This commit is contained in:
Graham McIntire 2026-05-08 13:02:15 -05:00
parent 680ffa8cf2
commit f8dee5e670
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 146 additions and 7 deletions

View file

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

View file

@ -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<Level> = Vec::with_capacity(fetcher::grid_level_keys().len());
let levels: Vec<V> = 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 }

View file

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