prop/test/microwaveprop/weather/sounding_params_test.exs
Graham McIntire 6aa91e7656
fix: April 2026 codebase review — address 13 bugs across propagation chain
Each fix is covered by a regression test that fails on `main` and
passes on this commit.

Round 1 (initial review):

* propagation: thread `latitude` into the conditions map so
  `score_season/4` actually picks up regional multipliers
* hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at
  a future cycle that NOAA hasn't published yet
* radio: spherical-vector great-circle midpoint replaces the
  arithmetic mean — anti-meridian paths no longer fold to Greenwich
* weather: `reconcile_weather_statuses` scales the longitude band by
  `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude
* radio/maidenhead: clamp 90°/180° below the field-bucket overflow so
  `from_latlon` never emits invalid characters like 'S'
* prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and
  read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native
  Duct Boost actually fires
* propagation/region (Elixir + Rust): inclusive upper bounds so points
  exactly at lat_max get the regional multiplier
* weather/sounding_params (Elixir + Rust): drop the 10 m gradient
  floor so HRRR's thin near-surface layers stop hiding sharp ducts
* weather/sounding_params: when the profile ends inside a duct,
  finalize it with the highest sample as the top instead of throwing
  it away (Rust port already correct)

Round 2 (post-fix sweep):

* radio + commercial: single canonical haversine in Radio (atan2
  form); Commercial delegates instead of carrying a second copy that
  could disagree at threshold distances
* prop_grid_rs/profiles_file: `snap_coords` matches Elixir's
  step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so
  Rust-keyed and Elixir-keyed profile maps land on the same cell
* weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse`
  uniformly — wgrib2 dropping the trailing `.0` from a longitude no
  longer crashes the whole chain step
2026-04-25 10:52:51 -05:00

198 lines
8.3 KiB
Elixir

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