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.
This commit is contained in:
Graham McIntire 2026-04-23 14:13:41 -05:00
parent eb0eaa2416
commit 56e6a3513c
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 194 additions and 0 deletions

View file

@ -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

View file

@ -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