defmodule Microwaveprop.Weather.SoundingParamsTest do use ExUnit.Case, async: true alias Microwaveprop.Weather.SoundingParams # Realistic 3-level profile (surface, 925, 850 hPa) @simple_profile [ %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10}, %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20} ] # Profile with a temperature inversion (temp increases with height in one layer) @inversion_profile [ %{"pres" => 1013.0, "hght" => 100, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 5}, %{"pres" => 980.0, "hght" => 400, "tmpc" => 22.0, "dwpc" => 14.0, "drct" => 190, "sknt" => 8}, %{"pres" => 950.0, "hght" => 700, "tmpc" => 26.0, "dwpc" => 10.0, "drct" => 200, "sknt" => 12}, %{"pres" => 925.0, "hght" => 900, "tmpc" => 20.0, "dwpc" => 8.0, "drct" => 210, "sknt" => 15}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 5.0, "drct" => 230, "sknt" => 20}, %{"pres" => 700.0, "hght" => 3100, "tmpc" => 2.0, "dwpc" => -5.0, "drct" => 250, "sknt" => 30}, %{"pres" => 500.0, "hght" => 5600, "tmpc" => -15.0, "dwpc" => -25.0, "drct" => 270, "sknt" => 40} ] # Profile designed to create a duct (M decreases with height) # Strong surface-based inversion with very dry air aloft → M decreases @ducting_profile [ %{"pres" => 1013.0, "hght" => 0, "tmpc" => 30.0, "dwpc" => 25.0, "drct" => 180, "sknt" => 5}, %{"pres" => 1000.0, "hght" => 100, "tmpc" => 35.0, "dwpc" => 5.0, "drct" => 180, "sknt" => 5}, %{"pres" => 980.0, "hght" => 300, "tmpc" => 28.0, "dwpc" => 10.0, "drct" => 190, "sknt" => 8}, %{"pres" => 950.0, "hght" => 550, "tmpc" => 24.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 10}, %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 10.0, "drct" => 210, "sknt" => 12}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 14.0, "dwpc" => 5.0, "drct" => 230, "sknt" => 18}, %{"pres" => 700.0, "hght" => 3100, "tmpc" => 2.0, "dwpc" => -10.0, "drct" => 250, "sknt" => 25}, %{"pres" => 500.0, "hght" => 5600, "tmpc" => -18.0, "dwpc" => -30.0, "drct" => 270, "sknt" => 35} ] describe "sat_vap_pres/1" do test "returns known value at 20°C (~23.4 hPa)" do result = SoundingParams.sat_vap_pres(20.0) assert_in_delta result, 23.4, 0.5 end test "returns known value at 0°C (~6.1 hPa)" do result = SoundingParams.sat_vap_pres(0.0) assert_in_delta result, 6.1, 0.2 end end describe "mixing_ratio/2" do test "returns reasonable value for typical surface conditions" do # At 15°C, 1013 hPa, mixing ratio ~10.7 g/kg result = SoundingParams.mixing_ratio(15.0, 1013.0) assert_in_delta result, 10.7, 1.0 end end describe "derive/1" do test "returns nil for profiles with fewer than 3 valid levels" do short = [ %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0}, %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0} ] assert SoundingParams.derive(short) == nil end test "returns nil for empty profile" do assert SoundingParams.derive([]) == nil end test "computes surface parameters" do result = SoundingParams.derive(@simple_profile) assert result assert_in_delta result.surface_temp_c, 25.0, 0.01 assert_in_delta result.surface_dewpoint_c, 15.0, 0.01 assert_in_delta result.surface_pressure_mb, 1013.0, 0.01 end test "computes surface refractivity (N-units)" do result = SoundingParams.derive(@simple_profile) # At 25°C, 15°C dewpoint, 1013 hPa — N should be ~330-350 assert result.surface_refractivity > 300 assert result.surface_refractivity < 400 end test "computes dN/dh gradients" do result = SoundingParams.derive(@simple_profile) # Standard atmosphere dN/dh is about -40/km assert result.min_refractivity_gradient < 0 end test "captures sharp sub-10m inversions instead of skipping the layer" do # HRRR native-level profiles routinely report 0/10/30/50 m thin layers; # an inversion confined to the lowest 5m is exactly the kind of # surface-based duct the propagation map cares about, so dropping any # gradient with `dh < 10 m` quietly hides the strongest signals. thin_layer_profile = [ %{"pres" => 1013.0, "hght" => 0.0, "tmpc" => 25.0, "dwpc" => 22.0}, %{"pres" => 1012.5, "hght" => 5.0, "tmpc" => 24.5, "dwpc" => 5.0}, %{"pres" => 1010.0, "hght" => 100.0, "tmpc" => 23.0, "dwpc" => 4.0}, %{"pres" => 950.0, "hght" => 600.0, "tmpc" => 18.0, "dwpc" => 0.0} ] result = SoundingParams.derive(thin_layer_profile) # The 0→5 m drop in dewpoint creates a strong negative dM/dh and # therefore a sharp negative refractivity gradient. Without it we # only see the broad 5→100 m baseline, which is much shallower. assert result.min_refractivity_gradient < -200 end test "detects temperature inversions" do result = SoundingParams.derive(@inversion_profile) assert result.inversions != [] inv = hd(result.inversions) assert inv.strength > 0 assert inv.base >= 0 end test "no inversions in standard lapse profile" do # Simple profile has monotonic temperature decrease result = SoundingParams.derive(@simple_profile) assert result.inversions == [] end test "computes precipitable water" do result = SoundingParams.derive(@inversion_profile) # PW should be positive and reasonable (5-80mm) assert result.precipitable_water_mm > 0 assert result.precipitable_water_mm < 100 end test "computes K-Index when standard levels available" do result = SoundingParams.derive(@inversion_profile) # K-Index should be computed (profile has 850, 700, 500 levels) assert result.k_index end test "computes Lifted Index when 500 hPa available" do result = SoundingParams.derive(@inversion_profile) assert result.lifted_index end test "detects ducting in strong inversion profile" do result = SoundingParams.derive(@ducting_profile) assert result.ducting_detected == true assert result.ducts != [] end test "no ducting in standard profile" do result = SoundingParams.derive(@simple_profile) assert result.ducting_detected == false assert result.ducts == [] end test "keeps a surface duct that has not yet closed by the top of the profile" do # Three sampled levels with monotonically decreasing modified # refractivity (M). The profile starts inside a trapping layer # and the duct never "tops out" before the data runs out. The # previous code threw the duct away because it never observed # the layer close — hiding exactly the surface ducts the # propagation map needs to surface. open_top_duct = [ %{"pres" => 1013.0, "hght" => 0.0, "tmpc" => 30.0, "dwpc" => 27.0}, %{"pres" => 1010.0, "hght" => 20.0, "tmpc" => 33.0, "dwpc" => 5.0}, %{"pres" => 1005.0, "hght" => 50.0, "tmpc" => 35.0, "dwpc" => -10.0} ] result = SoundingParams.derive(open_top_duct) assert result.ducting_detected assert result.ducts != [] end test "computes boundary layer depth" do result = SoundingParams.derive(@inversion_profile) # BL depth should be positive and reasonable assert result.boundary_layer_depth_m > 0 assert result.boundary_layer_depth_m < 5000 end test "returns level_count matching filtered profile" do result = SoundingParams.derive(@simple_profile) assert result.level_count == 3 end test "filters out levels with nil pressure, temp, or height" do profile_with_nils = [ %{"pres" => 1013.0, "hght" => 171, "tmpc" => 25.0, "dwpc" => 15.0, "drct" => 180, "sknt" => 10}, %{"pres" => nil, "hght" => 500, "tmpc" => 22.0, "dwpc" => 13.0, "drct" => 190, "sknt" => 12}, %{"pres" => 925.0, "hght" => 800, "tmpc" => 20.0, "dwpc" => 12.0, "drct" => 200, "sknt" => 15}, %{"pres" => 850.0, "hght" => 1500, "tmpc" => 15.0, "dwpc" => 8.0, "drct" => 220, "sknt" => 20} ] result = SoundingParams.derive(profile_with_nils) assert result.level_count == 3 end end end