From 56e6a3513c5920ffe07a0bdb1db8776c79fad693 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 23 Apr 2026 14:13:41 -0500 Subject: [PATCH] test: cover METAR field extractors and Scorer.path_integrated_conditions - NceiMetarClient: wind-group parsing (VVVKKKT), missing-wind-group fallback to nil, multi-layer sky condition concat, missing-sky token returns nil, missing altimeter returns nil, T-group-less fallback to the coarse "TT/DD" field. - Scorer.path_integrated_conditions/2: averages surface temp/dew, takes MIN of pressure + refractivity gradient, takes AVG of HPBL + PWAT, threads contact time+longitude, and propagates -97.0 longitude when pos1 lacks a lon key. Returns nil when every profile is missing surface temp OR dewpoint; returns nil on empty-list input; tolerates nil pressure / gradient / HPBL / PWAT when temp + dew are present. Suite: 2,409 tests + 159 properties (was 2,397 + 159); credo strict clean. --- .../microwaveprop/propagation/scorer_test.exs | 133 ++++++++++++++++++ .../weather/ncei_metar_client_test.exs | 61 ++++++++ 2 files changed, 194 insertions(+) diff --git a/test/microwaveprop/propagation/scorer_test.exs b/test/microwaveprop/propagation/scorer_test.exs index dfdf97f2..d1129a50 100644 --- a/test/microwaveprop/propagation/scorer_test.exs +++ b/test/microwaveprop/propagation/scorer_test.exs @@ -755,4 +755,137 @@ defmodule Microwaveprop.Propagation.ScorerTest do end end end + + describe "path_integrated_conditions/2" do + @contact %{ + qso_timestamp: ~U[2026-06-15 18:30:00Z], + pos1: %{"lat" => 32.9, "lon" => -97.0} + } + + test "averages surface temp/dew, mins pressure+gradient, and averages HPBL/PWAT" do + profiles = [ + %{ + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1020.0, + min_refractivity_gradient: -200.0, + hpbl_m: 500.0, + pwat_mm: 20.0 + }, + %{ + surface_temp_c: 30.0, + surface_dewpoint_c: 20.0, + surface_pressure_mb: 1010.0, + min_refractivity_gradient: -400.0, + hpbl_m: 1500.0, + pwat_mm: 30.0 + } + ] + + cond_map = Scorer.path_integrated_conditions(profiles, @contact) + assert cond_map + + # Averages carry through to Fahrenheit equivalents. + assert_in_delta cond_map.temp_f, 77.0, 0.1 + assert_in_delta cond_map.dewpoint_f, 59.0, 0.1 + + # min() picks the worst pressure and the strongest (most-negative) + # refractivity gradient. + assert cond_map.pressure_mb == 1010.0 + assert cond_map.min_refractivity_gradient == -400.0 + + # avg() smooths HPBL + PWAT. + assert cond_map.bl_depth_m == 1000.0 + assert cond_map.pwat_mm == 25.0 + + # Time/position fields come from the contact. + assert cond_map.utc_hour == 18 + assert cond_map.utc_minute == 30 + assert cond_map.month == 6 + assert cond_map.longitude == -97.0 + + # Fields that the path integration can't infer from HRRR are nil. + assert cond_map.wind_speed_kts == nil + assert cond_map.sky_cover_pct == nil + assert cond_map.prev_pressure_mb == nil + assert cond_map.rain_rate_mmhr == 0.0 + end + + test "returns nil when every profile is missing surface temperature" do + profiles = [ + %{ + surface_temp_c: nil, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1020.0, + min_refractivity_gradient: -200.0, + hpbl_m: 500.0, + pwat_mm: 20.0 + } + ] + + assert Scorer.path_integrated_conditions(profiles, @contact) == nil + end + + test "returns nil when every profile is missing surface dewpoint" do + profiles = [ + %{ + surface_temp_c: 20.0, + surface_dewpoint_c: nil, + surface_pressure_mb: 1020.0, + min_refractivity_gradient: -200.0, + hpbl_m: 500.0, + pwat_mm: 20.0 + } + ] + + assert Scorer.path_integrated_conditions(profiles, @contact) == nil + end + + test "nil profile list yields nil" do + assert Scorer.path_integrated_conditions([], @contact) == nil + end + + test "tolerates profiles with missing non-critical scalars (pressure, gradient, HPBL, PWAT)" do + # Only temp + dewpoint are required; everything else can be nil + # across all profiles and the conditions map still comes back + # with nil / avg-over-empty for those fields. + profiles = [ + %{ + surface_temp_c: 22.0, + surface_dewpoint_c: 15.0, + surface_pressure_mb: nil, + min_refractivity_gradient: nil, + hpbl_m: nil, + pwat_mm: nil + } + ] + + cond_map = Scorer.path_integrated_conditions(profiles, @contact) + assert cond_map + assert cond_map.pressure_mb == nil + assert cond_map.min_refractivity_gradient == nil + assert cond_map.bl_depth_m == nil + assert cond_map.pwat_mm == nil + end + + test "uses -97.0 as longitude when contact.pos1 is missing the lon key" do + # Stale contacts written before pos1 was normalised can lack a + # lon field. The function falls back to CONUS-centre (-97.0). + contact = %{qso_timestamp: ~U[2026-06-15 18:00:00Z], pos1: %{}} + + profiles = [ + %{ + surface_temp_c: 20.0, + surface_dewpoint_c: 10.0, + surface_pressure_mb: 1020.0, + min_refractivity_gradient: -200.0, + hpbl_m: 500.0, + pwat_mm: 20.0 + } + ] + + cond_map = Scorer.path_integrated_conditions(profiles, contact) + assert cond_map.longitude == -97.0 + end + end end diff --git a/test/microwaveprop/weather/ncei_metar_client_test.exs b/test/microwaveprop/weather/ncei_metar_client_test.exs index 645f4239..75d39e3d 100644 --- a/test/microwaveprop/weather/ncei_metar_client_test.exs +++ b/test/microwaveprop/weather/ncei_metar_client_test.exs @@ -47,5 +47,66 @@ defmodule Microwaveprop.Weather.NceiMetarClientTest do assert_in_delta obs.temp_f, 28.4, 0.2 assert_in_delta obs.dewpoint_f, 17.6, 0.2 end + + test "extracts wind speed/direction from the VVVKKT group" do + # 17015KT = 170° at 15 kt. + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17015KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094" + + [obs] = NceiMetarClient.parse(line) + assert obs.wind_direction_deg == 170 + assert obs.wind_speed_kts == 15.0 + end + + test "returns nil wind values when the wind group is missing" do + # Valid METAR without a wind group (CALM-ish). extract_wind_* should + # fall through to nil rather than crashing. + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094" + + [obs] = NceiMetarClient.parse(line) + assert obs.wind_speed_kts == nil + assert obs.wind_direction_deg == nil + end + + test "concatenates multiple sky tokens" do + # Layered ceiling: SCT030 + BKN080. + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM SCT030 BKN080 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094" + + [obs] = NceiMetarClient.parse(line) + assert obs.sky_condition =~ "SCT030" + assert obs.sky_condition =~ "BKN080" + end + + test "sky_condition is nil when no cloud-layer token is present" do + # No CLR/FEW/SCT/BKN/OVC/VV token at all. + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094" + + [obs] = NceiMetarClient.parse(line) + assert obs.sky_condition == nil + end + + test "altimeter_setting is nil when the A-group is absent" do + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 560 47 1400 160/12 RMK AO2 T02110094" + + [obs] = NceiMetarClient.parse(line) + assert obs.altimeter_setting == nil + end + + test "falls back to the coarse temp/dew pair when no T-group is in the remark" do + # No T-group → extract_temp_f falls through to the "21/09" field, + # which gives integer °C temperatures. + line = + "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2" + + [obs] = NceiMetarClient.parse(line) + # 21°C = 69.8°F, 9°C = 48.2°F — coarser than the T-group but + # within ±0.3°F. + assert_in_delta obs.temp_f, 69.8, 0.3 + assert_in_delta obs.dewpoint_f, 48.2, 0.3 + end end end