diff --git a/lib/microwaveprop/propagation/profiles_file.ex b/lib/microwaveprop/propagation/profiles_file.ex index 094c5c17..ff738b68 100644 --- a/lib/microwaveprop/propagation/profiles_file.ex +++ b/lib/microwaveprop/propagation/profiles_file.ex @@ -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", diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex index e1846996..3b2bb904 100644 --- a/lib/microwaveprop/weather/weather_layers.ex +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -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 diff --git a/rust/prop_grid_rs/src/hrrr_points.rs b/rust/prop_grid_rs/src/hrrr_points.rs index ba0fc57e..8f7a5a0c 100644 --- a/rust/prop_grid_rs/src/hrrr_points.rs +++ b/rust/prop_grid_rs/src/hrrr_points.rs @@ -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> 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 = 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 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(()) diff --git a/rust/prop_grid_rs/src/pipeline.rs b/rust/prop_grid_rs/src/pipeline.rs index df800089..c6a5c92b 100644 --- a/rust/prop_grid_rs/src/pipeline.rs +++ b/rust/prop_grid_rs/src/pipeline.rs @@ -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))); } diff --git a/rust/prop_grid_rs/src/sounding_params.rs b/rust/prop_grid_rs/src/sounding_params.rs index 020d3a75..0e39704c 100644 --- a/rust/prop_grid_rs/src/sounding_params.rs +++ b/rust/prop_grid_rs/src/sounding_params.rs @@ -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 { + 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) -> 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