The Rust f01..f18 pipeline writes ProfilesFile cells, and the Rust hrrr_points worker writes hrrr_profiles rows, with profile entries keyed "pres_mb" / "hght_m" / "tmpc" / "dwpc" (string keys pre- atomization; atom keys post-ProfilesFile.read). Elixir's SoundingParams.derive and WeatherLayers.sort_profile filtered on "pres" / "hght" — so every derived field returned nil for any Rust-provided profile. Visible symptoms on /weather: N-gradient, Refractivity, T @ 850mb, Td @ 850mb, Lapse Rate, Inversion, Inv. Base all rendered no overlay. For per-contact analysis, min_refractivity_gradient from the Rust HRRR point worker's rows silently dropped. - SoundingParams.normalize_profile_entry/1: single-source normalizer accepting legacy, Rust-string, and Rust-atom shapes; returns the canonical "pres"/"hght"/"tmpc"/"dwpc"/"drct"/"sknt" shape. - SoundingParams.derive/1 and WeatherLayers.sort_profile/1 both run every entry through it before the rest of the existing logic. - Weather.build_grid_cache_row/4 now falls back to native_min_gradient when SoundingParams can't derive one (surface-only profiles). Also: - Default /weather overlay changed to Temperature (LiveView assign + JS fallback in the hook's dataset default) per user request.
129 lines
3.6 KiB
Elixir
129 lines
3.6 KiB
Elixir
defmodule Microwaveprop.Weather.WeatherLayers do
|
|
@moduledoc "Derives scalar weather map layers from HRRR profile data."
|
|
|
|
alias Microwaveprop.Weather.SoundingParams
|
|
|
|
@doc """
|
|
Derives a map of scalar weather fields from a row containing HRRR profile data.
|
|
|
|
Expects a map with keys: `:surface_temp_c`, `:surface_dewpoint_c`,
|
|
`:surface_pressure_mb`, `:surface_refractivity`, `:profile`, `:duct_characteristics`.
|
|
|
|
Returns a map with derived fields: `:surface_rh`, `:surface_refractivity`,
|
|
`:temp_850mb`, `:dewpoint_850mb`, `:lapse_rate`, `:inversion_strength`,
|
|
`:inversion_base_m`, `:duct_base_m`, `:duct_strength`.
|
|
"""
|
|
@spec derive(map()) :: map()
|
|
def derive(row) do
|
|
sorted = sort_profile(row.profile)
|
|
|
|
%{
|
|
surface_rh: compute_rh(row.surface_temp_c, row.surface_dewpoint_c),
|
|
surface_refractivity: row.surface_refractivity,
|
|
temp_850mb: level_value(sorted, 850, "tmpc"),
|
|
dewpoint_850mb: level_value(sorted, 850, "dwpc"),
|
|
lapse_rate: compute_lapse_rate(sorted),
|
|
inversion_strength: inversion_strength(sorted),
|
|
inversion_base_m: inversion_base_m(sorted),
|
|
duct_base_m: duct_field(row.duct_characteristics, "base"),
|
|
duct_strength: duct_field(row.duct_characteristics, "strength")
|
|
}
|
|
end
|
|
|
|
defp sort_profile(nil), do: []
|
|
defp sort_profile([]), do: []
|
|
|
|
defp sort_profile(profile) do
|
|
# Accept either legacy string keys (`"pres"`, `"hght"`) or the
|
|
# Rust ProfilesFile shape (`"pres_mb"` / atom `:pres_mb` post-atomization
|
|
# via ProfilesFile.read). `SoundingParams.normalize_profile_entry/1`
|
|
# is the single source of truth for that coercion.
|
|
profile
|
|
|> Enum.map(&SoundingParams.normalize_profile_entry/1)
|
|
|> Enum.filter(fn p -> p["pres"] != nil and p["tmpc"] != nil and p["hght"] != nil end)
|
|
|> Enum.sort_by(fn p -> p["pres"] end, :desc)
|
|
end
|
|
|
|
defp compute_rh(t_c, td_c) do
|
|
100.0 * SoundingParams.sat_vap_pres(td_c) / SoundingParams.sat_vap_pres(t_c)
|
|
end
|
|
|
|
defp level_value([], _target_pres, _key), do: nil
|
|
|
|
defp level_value(sorted, target_pres, key) do
|
|
nearest = Enum.min_by(sorted, fn p -> abs(p["pres"] - target_pres) end)
|
|
|
|
if abs(nearest["pres"] - target_pres) <= 25.0 do
|
|
nearest[key]
|
|
end
|
|
end
|
|
|
|
defp compute_lapse_rate([]), do: nil
|
|
|
|
defp compute_lapse_rate(sorted) when length(sorted) < 2, do: nil
|
|
|
|
defp compute_lapse_rate(sorted) do
|
|
surface = hd(sorted)
|
|
top = List.last(sorted)
|
|
dh_km = (top["hght"] - surface["hght"]) / 1000.0
|
|
|
|
if dh_km > 0 do
|
|
(surface["tmpc"] - top["tmpc"]) / dh_km
|
|
end
|
|
end
|
|
|
|
defp inversion_strength([]), do: 0.0
|
|
|
|
defp inversion_strength(sorted) do
|
|
sorted
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.reduce(0.0, fn [lower, upper], max_str ->
|
|
dt = upper["tmpc"] - lower["tmpc"]
|
|
|
|
if dt > max_str do
|
|
dt
|
|
else
|
|
max_str
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp inversion_base_m([]), do: nil
|
|
|
|
defp inversion_base_m(sorted) do
|
|
sfc_hght = hd(sorted)["hght"]
|
|
|
|
result =
|
|
sorted
|
|
|> Enum.chunk_every(2, 1, :discard)
|
|
|> Enum.reduce({0.0, nil}, fn [lower, upper], {max_str, _base} = acc ->
|
|
dt = upper["tmpc"] - lower["tmpc"]
|
|
|
|
if dt > 0 and dt > max_str do
|
|
{dt, lower["hght"] - sfc_hght}
|
|
else
|
|
acc
|
|
end
|
|
end)
|
|
|
|
case result do
|
|
{str, base} when str > 0 -> base
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp duct_field(nil, _key), do: nil
|
|
defp duct_field([], _key), do: nil
|
|
|
|
defp duct_field(ducts, "base") do
|
|
ducts
|
|
|> Enum.map(fn d -> d["base"] end)
|
|
|> Enum.min()
|
|
end
|
|
|
|
defp duct_field(ducts, "strength") do
|
|
ducts
|
|
|> Enum.map(fn d -> d["strength"] end)
|
|
|> Enum.max()
|
|
end
|
|
end
|