fix: three more Rust/Elixir contract drift bugs on /map and /weather
Found by auditing the ProfilesFile, hrrr_profiles, and weather-layer
contracts against what the Rust writers actually emit.
Bug 1 (HIGH) — Rust `hrrr_points` worker upserted hrrr_profiles rows
with NULL `surface_refractivity`, `min_refractivity_gradient`, and
`ducting_detected`. The Elixir HrrrClient path always wrote them
via SoundingParams.derive. Per-QSO pages that rely on these
scalars ("N:", "dN/dh:", duct badge) showed "—" for any contact
enriched through Stream C. Fixed by porting
`surface_refractivity` + `ducting_detected` into Rust's
`sounding_params.rs` and deriving at upsert time.
Bug 2 (HIGH) — Rust f01..f18 `cell_to_profile_entry` never emitted
wind_u / wind_v / cloud_cover_pct / precip_mm into ProfilesFile
cells. `Propagation.factors_for` recomputes factor scores from the
profile cell on /map click, and these four fields are read
(wind_speed_kts, sky_cover_pct, precip_to_rate_mmhr). Result: every
f01..f18 point-detail popup showed 0 for wind/sky/rain factors.
Fixed by extracting the grib values at cell_to_profile_entry time
and whitelisting the keys for atomization in
profiles_file.ex `@mp_atom_keys`.
Bug 4 (MEDIUM) — `WeatherLayers.duct_field/2` only knew the
`SoundingParams.detect_ducts` shape (`d["base"]` / `d["strength"]`).
The Rust/HrrrNativeClient shape uses `:base_m` / `:thickness_m`
atom keys (post-ProfilesFile.read atomization). On /weather, the
Duct Base / Duct Strength layers returned nil for every Rust-origin
cell. Tolerant lookup now reads either shape; uses `thickness_m`
as the "strength" proxy for the native shape (thicker duct traps
a wider band).
Skipped (verified but lower impact):
- Rust ProfilesFile aggregates ducts as scalars, not per-layer — the
`ducts` array in the popup is always empty. Requires larger
DuctMetrics refactor in Rust; deferred.
- `complete_hrrr_task` emits no NOTIFY (vs `complete` which does).
Elixir has no matching listener yet, so adding NOTIFY alone is a
no-op; the existing cron reconciler catches it.
- `:best_duct_band_ghz` fallback in propagation.ex is dead code;
cleanup, not a bug.
133 Rust tests + 2842 Elixir tests + credo green.
This commit is contained in:
parent
760024de38
commit
3f2d97735e
5 changed files with 104 additions and 8 deletions
|
|
@ -61,6 +61,10 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
|
|||
"best_duct_freq_ghz",
|
||||
"max_duct_thickness_m",
|
||||
"duct_count",
|
||||
"wind_u",
|
||||
"wind_v",
|
||||
"cloud_cover_pct",
|
||||
"precip_mm",
|
||||
"ducts",
|
||||
"base_m",
|
||||
"top_m",
|
||||
|
|
|
|||
|
|
@ -115,15 +115,34 @@ defmodule Microwaveprop.Weather.WeatherLayers do
|
|||
defp duct_field(nil, _key), do: nil
|
||||
defp duct_field([], _key), do: nil
|
||||
|
||||
# Ducts arrive in two shapes:
|
||||
#
|
||||
# * `SoundingParams.detect_ducts` emits string keys `"base"` / `"strength"`.
|
||||
# * HrrrNativeClient and the Rust f00 pipeline emit atom keys
|
||||
# `:base_m` / `:top_m` / `:thickness_m` / `:min_freq_ghz`, which get
|
||||
# atomized by `ProfilesFile.read/1` via the `@mp_atom_keys` whitelist.
|
||||
#
|
||||
# Read both shapes so `/weather` renders Duct Base / Duct Strength
|
||||
# layers regardless of producer. `:thickness_m` stands in for
|
||||
# "strength" when the native shape is in use — thicker ducts trap a
|
||||
# wider frequency range, so it's the most meaningful scalar.
|
||||
defp duct_field(ducts, "base") do
|
||||
ducts
|
||||
|> Enum.map(fn d -> d["base"] end)
|
||||
|> Enum.min()
|
||||
|> Enum.map(fn d -> d["base"] || d[:base_m] || d["base_m"] end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> safe_min()
|
||||
end
|
||||
|
||||
defp duct_field(ducts, "strength") do
|
||||
ducts
|
||||
|> Enum.map(fn d -> d["strength"] end)
|
||||
|> Enum.max()
|
||||
|> Enum.map(fn d -> d["strength"] || d[:thickness_m] || d["thickness_m"] end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> safe_max()
|
||||
end
|
||||
|
||||
defp safe_min([]), do: nil
|
||||
defp safe_min(xs), do: Enum.min(xs)
|
||||
|
||||
defp safe_max([]), do: nil
|
||||
defp safe_max(xs), do: Enum.max(xs)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ use uuid::Uuid;
|
|||
use crate::decoder::{self, CellValues};
|
||||
use crate::fetcher::{self, HrrrClient, Product};
|
||||
use crate::grid::{wgrib2_grid_spec, STEP};
|
||||
use crate::sounding_params::{self, Level};
|
||||
|
||||
// wgrib2 filter patterns are derived from static SURFACE_MESSAGES /
|
||||
// GRID_PRESSURE_LEVELS tables and never change at runtime. Building
|
||||
|
|
@ -164,32 +165,53 @@ async fn upsert_profile(
|
|||
// We build a Vec<Json<Value>> so sqlx encodes it as jsonb[] rather
|
||||
// than wrapping the whole list in a single jsonb (which yields the
|
||||
// "expression is of type jsonb" type-mismatch error at runtime).
|
||||
// We also collect a `Level` struct form in parallel so we can feed
|
||||
// `sounding_params::*` without re-parsing JSON.
|
||||
let mut typed_levels: Vec<Level> = Vec::with_capacity(fetcher::grid_level_keys().len());
|
||||
let levels: Vec<sqlx::types::Json<serde_json::Value>> = fetcher::grid_level_keys()
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let t = cell.get(k.tmp.as_str()).copied()?;
|
||||
let h = cell.get(k.hgt.as_str()).copied()?;
|
||||
let d = cell.get(k.dpt.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 m = serde_json::Map::new();
|
||||
m.insert("pres_mb".into(), serde_json::json!(k.pres_mb));
|
||||
m.insert("hght_m".into(), serde_json::json!(h as f64));
|
||||
m.insert("tmpc".into(), serde_json::json!((t as f64) - 273.15));
|
||||
if let Some(dv) = d {
|
||||
m.insert("dwpc".into(), serde_json::json!((dv as f64) - 273.15));
|
||||
m.insert("tmpc".into(), serde_json::json!(tmpc));
|
||||
if let Some(dv) = dwpc {
|
||||
m.insert("dwpc".into(), serde_json::json!(dv));
|
||||
}
|
||||
Some(sqlx::types::Json(serde_json::Value::Object(m)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Derive the scalar sounding params Elixir used to compute client-side
|
||||
// via `SoundingParams.derive/1`. Persisting them alongside the raw
|
||||
// profile means contact-detail pages + per-QSO scoring don't need to
|
||||
// re-run SoundingParams on every read.
|
||||
let surface_refractivity = sounding_params::surface_refractivity(&typed_levels);
|
||||
let min_refractivity_gradient =
|
||||
sounding_params::min_refractivity_gradient(typed_levels.clone());
|
||||
let ducting_detected = sounding_params::ducting_detected(min_refractivity_gradient);
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO hrrr_profiles
|
||||
(id, valid_time, lat, lon, run_time, profile, hpbl_m, pwat_mm,
|
||||
surface_temp_c, surface_dewpoint_c, surface_pressure_mb,
|
||||
surface_refractivity, min_refractivity_gradient, ducting_detected,
|
||||
is_grid_point, inserted_at, updated_at)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $2, $5, $6, $7, $8, $9, $10, false, NOW(), NOW())
|
||||
($1, $2, $3, $4, $2, $5, $6, $7, $8, $9, $10, $11, $12, $13, false, NOW(), NOW())
|
||||
ON CONFLICT (lat, lon, valid_time) DO UPDATE
|
||||
SET profile = EXCLUDED.profile,
|
||||
hpbl_m = EXCLUDED.hpbl_m,
|
||||
|
|
@ -197,6 +219,9 @@ async fn upsert_profile(
|
|||
surface_temp_c = EXCLUDED.surface_temp_c,
|
||||
surface_dewpoint_c = EXCLUDED.surface_dewpoint_c,
|
||||
surface_pressure_mb = EXCLUDED.surface_pressure_mb,
|
||||
surface_refractivity = EXCLUDED.surface_refractivity,
|
||||
min_refractivity_gradient = EXCLUDED.min_refractivity_gradient,
|
||||
ducting_detected = EXCLUDED.ducting_detected,
|
||||
updated_at = NOW()
|
||||
"#,
|
||||
)
|
||||
|
|
@ -210,6 +235,9 @@ async fn upsert_profile(
|
|||
.bind(surface_temp_c)
|
||||
.bind(surface_dewpoint_c)
|
||||
.bind(surface_pressure_mb)
|
||||
.bind(surface_refractivity)
|
||||
.bind(min_refractivity_gradient)
|
||||
.bind(ducting_detected)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -729,6 +729,22 @@ fn cell_to_profile_entry(lat: f64, lon: f64, cell: &CellValues) -> CellEntry {
|
|||
if let Some(&v) = cell.get("PWAT:entire atmosphere (considered as a single layer)") {
|
||||
kv.push(("pwat_mm", V::F64(v as f64)));
|
||||
}
|
||||
// Wind / cloud / precip are read by Elixir's per-QSO scorer
|
||||
// (`Propagation.factors_for` in propagation.ex) when a user clicks a
|
||||
// grid cell on /map. Without these, wind/sky/rain factors silently
|
||||
// score 0 for Rust-produced ProfilesFile cells.
|
||||
if let Some(&u) = cell.get("UGRD:10 m above ground") {
|
||||
kv.push(("wind_u", V::F64(u as f64)));
|
||||
}
|
||||
if let Some(&vv) = cell.get("VGRD:10 m above ground") {
|
||||
kv.push(("wind_v", V::F64(vv as f64)));
|
||||
}
|
||||
if let Some(&c) = cell.get("TCDC:entire atmosphere") {
|
||||
kv.push(("cloud_cover_pct", V::F64(c as f64)));
|
||||
}
|
||||
if let Some(&p) = cell.get("APCP:surface") {
|
||||
kv.push(("precip_mm", V::F64(p as f64)));
|
||||
}
|
||||
if let Some(&v) = cell.get("native_min_gradient") {
|
||||
kv.push(("native_min_gradient", V::F64(v as f64)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,35 @@ pub fn sat_vap_pres(t_c: f64) -> f64 {
|
|||
6.1121 * ((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c))).exp()
|
||||
}
|
||||
|
||||
/// Surface refractivity N = 77.6 * p / T_K + 3.73e5 * e / T_K² for the
|
||||
/// lowest-altitude level that has a dewpoint. None if no usable level.
|
||||
pub fn surface_refractivity(levels: &[Level]) -> Option<f64> {
|
||||
let usable: Vec<&Level> = levels.iter().filter(|l| l.dwpc.is_some()).collect();
|
||||
if usable.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Highest pressure = lowest altitude = "surface" in this profile.
|
||||
let sfc = usable
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
a.pres_mb
|
||||
.partial_cmp(&b.pres_mb)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.copied()?;
|
||||
let t_k = sfc.tmpc + 273.15;
|
||||
let e = sat_vap_pres(sfc.dwpc?);
|
||||
Some(77.6 * sfc.pres_mb / t_k + 3.73e5 * e / (t_k * t_k))
|
||||
}
|
||||
|
||||
/// A duct (trapping layer) is present when any part of the refractivity
|
||||
/// profile has dN/dh < -157 N/km. We use the *minimum* gradient as a
|
||||
/// cheap proxy — matches Elixir's `ducting_detected` computed from
|
||||
/// `SoundingParams.derive/1`.
|
||||
pub fn ducting_detected(min_gradient: Option<f64>) -> bool {
|
||||
matches!(min_gradient, Some(g) if g < -157.0)
|
||||
}
|
||||
|
||||
/// Minimum refractivity gradient (dN/dh, N-units per km) from a pressure
|
||||
/// profile. Returns `None` if fewer than 3 usable levels or adjacent
|
||||
/// pairs too close in height. Levels are internally sorted descending by
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue