diff --git a/lib/microwaveprop/weather/weather_layers.ex b/lib/microwaveprop/weather/weather_layers.ex new file mode 100644 index 00000000..6325114c --- /dev/null +++ b/lib/microwaveprop/weather/weather_layers.ex @@ -0,0 +1,123 @@ +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`. + """ + 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 + profile + |> 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 diff --git a/test/microwaveprop/weather/weather_layers_test.exs b/test/microwaveprop/weather/weather_layers_test.exs new file mode 100644 index 00000000..923c9f0b --- /dev/null +++ b/test/microwaveprop/weather/weather_layers_test.exs @@ -0,0 +1,143 @@ +defmodule Microwaveprop.Weather.WeatherLayersTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Weather.SoundingParams + alias Microwaveprop.Weather.WeatherLayers + + @sample_profile [ + %{"pres" => 1000.0, "tmpc" => 28.0, "dwpc" => 20.0, "hght" => 100.0}, + %{"pres" => 975.0, "tmpc" => 26.0, "dwpc" => 18.0, "hght" => 340.0}, + %{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 16.0, "hght" => 580.0}, + %{"pres" => 925.0, "tmpc" => 22.0, "dwpc" => 14.0, "hght" => 830.0}, + %{"pres" => 900.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 1090.0}, + %{"pres" => 875.0, "tmpc" => 18.0, "dwpc" => 10.0, "hght" => 1360.0}, + %{"pres" => 850.0, "tmpc" => 16.0, "dwpc" => 8.0, "hght" => 1640.0}, + %{"pres" => 825.0, "tmpc" => 14.0, "dwpc" => 6.0, "hght" => 1930.0}, + %{"pres" => 800.0, "tmpc" => 12.0, "dwpc" => 4.0, "hght" => 2230.0}, + %{"pres" => 775.0, "tmpc" => 10.0, "dwpc" => 2.0, "hght" => 2540.0}, + %{"pres" => 750.0, "tmpc" => 8.0, "dwpc" => 0.0, "hght" => 2860.0}, + %{"pres" => 725.0, "tmpc" => 6.0, "dwpc" => -2.0, "hght" => 3200.0}, + %{"pres" => 700.0, "tmpc" => 4.0, "dwpc" => -4.0, "hght" => 3550.0} + ] + + @inversion_profile [ + %{"pres" => 1000.0, "tmpc" => 28.0, "dwpc" => 20.0, "hght" => 100.0}, + %{"pres" => 975.0, "tmpc" => 26.0, "dwpc" => 18.0, "hght" => 340.0}, + %{"pres" => 950.0, "tmpc" => 22.0, "dwpc" => 14.0, "hght" => 580.0}, + %{"pres" => 925.0, "tmpc" => 25.0, "dwpc" => 10.0, "hght" => 830.0}, + %{"pres" => 900.0, "tmpc" => 20.0, "dwpc" => 8.0, "hght" => 1090.0} + ] + + defp sample_row(overrides \\ %{}) do + Map.merge( + %{ + surface_temp_c: 28.0, + surface_dewpoint_c: 20.0, + surface_pressure_mb: 1000.0, + surface_refractivity: 350.0, + profile: @sample_profile, + duct_characteristics: nil + }, + overrides + ) + end + + describe "derive/1 surface relative humidity" do + test "computes surface RH from temp and dewpoint using Buck equation" do + result = WeatherLayers.derive(sample_row()) + + expected_rh = 100.0 * SoundingParams.sat_vap_pres(20.0) / SoundingParams.sat_vap_pres(28.0) + assert_in_delta result.surface_rh, expected_rh, 0.01 + end + end + + describe "derive/1 850mb extraction" do + test "extracts temperature at 850mb from profile" do + result = WeatherLayers.derive(sample_row()) + assert result.temp_850mb == 16.0 + end + + test "extracts dewpoint at 850mb from profile" do + result = WeatherLayers.derive(sample_row()) + assert result.dewpoint_850mb == 8.0 + end + end + + describe "derive/1 lapse rate" do + test "computes lapse rate in C/km from surface to top of profile" do + result = WeatherLayers.derive(sample_row()) + + # (28.0 - 4.0) / ((3550.0 - 100.0) / 1000.0) = 24.0 / 3.45 = ~6.957 + expected = (28.0 - 4.0) / ((3550.0 - 100.0) / 1000.0) + assert_in_delta result.lapse_rate, expected, 0.01 + end + end + + describe "derive/1 inversion detection" do + test "detects no inversion in standard lapse profile" do + result = WeatherLayers.derive(sample_row()) + assert result.inversion_strength == 0.0 + end + + test "detects inversion strength and base height" do + result = WeatherLayers.derive(sample_row(%{profile: @inversion_profile})) + + # Inversion: 950mb (22C, 580m) -> 925mb (25C, 830m), strength = 3.0 + assert_in_delta result.inversion_strength, 3.0, 0.01 + # Base height AGL: 580m - 100m (surface) = 480m + assert_in_delta result.inversion_base_m, 480.0, 0.01 + end + end + + describe "derive/1 duct characteristics" do + test "extracts duct base and strength from duct_characteristics" do + ducts = [ + %{"base" => 500.0, "top" => 800.0, "strength" => 12.5}, + %{"base" => 200.0, "top" => 400.0, "strength" => 8.0} + ] + + result = WeatherLayers.derive(sample_row(%{duct_characteristics: ducts})) + + # min base + assert result.duct_base_m == 200.0 + # max strength + assert result.duct_strength == 12.5 + end + + test "handles nil duct_characteristics" do + result = WeatherLayers.derive(sample_row(%{duct_characteristics: nil})) + + assert result.duct_base_m == nil + assert result.duct_strength == nil + end + end + + describe "derive/1 nil/empty profile" do + test "handles nil profile gracefully" do + result = WeatherLayers.derive(sample_row(%{profile: nil})) + + assert result.temp_850mb == nil + assert result.dewpoint_850mb == nil + assert result.lapse_rate == nil + assert result.inversion_strength == 0.0 + assert result.inversion_base_m == nil + end + + test "handles empty profile gracefully" do + result = WeatherLayers.derive(sample_row(%{profile: []})) + + assert result.temp_850mb == nil + assert result.dewpoint_850mb == nil + assert result.lapse_rate == nil + assert result.inversion_strength == 0.0 + assert result.inversion_base_m == nil + end + end + + describe "derive/1 surface refractivity passthrough" do + test "passes through surface_refractivity unchanged" do + result = WeatherLayers.derive(sample_row(%{surface_refractivity: 342.7})) + assert result.surface_refractivity == 342.7 + end + end +end