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:
parent
680ffa8cf2
commit
f8dee5e670
3 changed files with 146 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue