prop/test/microwaveprop/propagation/scorer_test.exs
2026-06-16 12:38:08 -05:00

187 lines
5.8 KiB
Elixir

defmodule Microwaveprop.Propagation.ScorerTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Propagation.Scorer
@contact %{
utc_hour: 18,
utc_minute: 30,
month: 6,
longitude: -97.0,
latitude: 32.9,
sky_cover_pct: 25.0,
wind_speed_kts: 5.0,
pressure_mb: 1013.0,
prev_pressure_mb: 1015.0,
abs_humidity: 10.0,
temp_f: 80.0,
dewpoint_f: 60.0,
min_refractivity_gradient: -150.0,
bl_depth_m: 500.0,
rain_rate_mmhr: 0.0
}
describe "score/2" do
test "returns a map with score, contributions, breakdown" do
result = Scorer.score(@contact, 10_000)
assert is_integer(result.score)
assert result.score >= 0 and result.score <= 100
assert result.contributions != []
assert Map.has_key?(result, :breakdown)
end
test "score increases with favorable conditions" do
# Same month, same location — dawn inversion peak scores higher than afternoon mixing.
# At longitude -97.0 the solar offset is ~-6.5 h, so 13 UTC ≈ 6:30 local (peak inversion)
# and 21 UTC ≈ 14:30 local (deep convective mixing).
good_contact = Map.merge(@contact, %{utc_hour: 13, utc_minute: 0})
bad_contact = Map.merge(@contact, %{utc_hour: 21, utc_minute: 0})
good_score = Scorer.score(good_contact, 10_000)
bad_score = Scorer.score(bad_contact, 10_000)
assert good_score.score > bad_score.score
end
test "band config selection picks correct humidity effect" do
result_10g = Scorer.score(@contact, 10_000)
result_24g = Scorer.score(@contact, 24_000)
assert result_10g.band_mhz == 10_000
assert result_24g.band_mhz == 24_000
end
test "score returns nil for invalid band" do
assert Scorer.score(@contact, 999) == nil
end
end
describe "path_integrated_conditions/2" do
test "averages scalar fields and picks worst pressure / strongest gradient" do
profiles = [
%{
surface_temp_c: 25.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1010.0,
min_refractivity_gradient: -300.0,
hpbl_m: 500.0,
pwat_mm: 20.0
},
%{
surface_temp_c: 25.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1020.0,
min_refractivity_gradient: -400.0,
hpbl_m: 1500.0,
pwat_mm: 30.0
}
]
cond_map = Scorer.path_integrated_conditions(profiles, @contact)
assert %{temp_f: _} = 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 %{temp_f: _} = 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).
profiles = [
%{
surface_temp_c: 22.0,
surface_dewpoint_c: 15.0,
surface_pressure_mb: 1015.0,
min_refractivity_gradient: -200.0,
hpbl_m: 1000.0,
pwat_mm: 25.0
}
]
contact_no_lon = %{utc_hour: 12, utc_minute: 0, month: 3, latitude: 32.9}
cond_map = Scorer.path_integrated_conditions(profiles, contact_no_lon)
assert cond_map.longitude == -97.0
end
end
end