Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
+ `if/else` with a classifier that pattern-matches the error reason
directly, returning `:transient | :permanent`, and dispatch via
`case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
step on the row/col comprehensions so an image box whose centre
lands outside the raster collapses to an empty iteration instead
of firing Elixir 1.19's ambiguous-range warning. Adds a regression
test for the right-edge box.
Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
duct_thickness, best_duct_freq, duct_usable_for_band,
distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
handling; parse_snmpget_output OID normalisation and junk-line
tolerance.
- HrrrNativeClient native_level_count, native_variables,
native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
monotonic in RH, finite across the operating envelope) + idempotent
nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
output; latlon_to_pixel round-trip for every grid cell.
Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.
Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
211 lines
7.3 KiB
Elixir
211 lines
7.3 KiB
Elixir
defmodule Microwaveprop.Backtest.FeaturesTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Backtest.Features
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
|
|
|
@point_lat 32.9
|
|
@point_lon -97.0
|
|
@valid_time ~U[2026-03-28 18:00:00Z]
|
|
|
|
defp create_profile(attrs) do
|
|
base = %{
|
|
valid_time: @valid_time,
|
|
lat: @point_lat,
|
|
lon: @point_lon
|
|
}
|
|
|
|
{:ok, _} = Weather.upsert_hrrr_profile(Map.merge(base, attrs))
|
|
end
|
|
|
|
describe "naive_gradient/3" do
|
|
test "returns the nearest profile's min_refractivity_gradient" do
|
|
create_profile(%{min_refractivity_gradient: -250.0})
|
|
assert Features.naive_gradient(@point_lat, @point_lon, @valid_time) == -250.0
|
|
end
|
|
|
|
test "returns nil when no profile is within match window" do
|
|
assert Features.naive_gradient(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "td_depression/3" do
|
|
test "returns surface_temp_c minus surface_dewpoint_c" do
|
|
create_profile(%{surface_temp_c: 20.0, surface_dewpoint_c: 14.0})
|
|
assert Features.td_depression(@point_lat, @point_lon, @valid_time) == 6.0
|
|
end
|
|
|
|
test "returns nil when profile has no surface data" do
|
|
create_profile(%{surface_temp_c: nil, surface_dewpoint_c: nil})
|
|
assert Features.td_depression(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "time_of_day/3" do
|
|
test "returns UTC hour + minute fraction and never calls the database" do
|
|
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 06:30:00Z]) == 6.5
|
|
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 00:00:00Z]) == 0.0
|
|
assert Features.time_of_day(0.0, 0.0, ~U[2026-01-01 23:45:00Z]) == 23.75
|
|
end
|
|
end
|
|
|
|
describe "pressure/3" do
|
|
test "returns surface_pressure_mb from the nearest profile" do
|
|
create_profile(%{surface_pressure_mb: 1013.2})
|
|
assert Features.pressure(@point_lat, @point_lon, @valid_time) == 1013.2
|
|
end
|
|
|
|
test "returns nil when no profile exists" do
|
|
assert Features.pressure(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "native_surface_refractivity/3" do
|
|
test "computes N-units from native profile surface scalars" do
|
|
insert_native_profile(%{
|
|
surface_temp_k: 295.0,
|
|
surface_spfh: 0.010,
|
|
surface_pressure_pa: 101_325.0
|
|
})
|
|
|
|
result = Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time)
|
|
assert is_float(result)
|
|
# Typical surface N is 250-400 in temperate latitudes
|
|
assert result > 200.0 and result < 500.0
|
|
end
|
|
|
|
test "returns nil when no native profile exists" do
|
|
assert Features.native_surface_refractivity(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "theta_e_jump/3" do
|
|
test "returns the native profile's theta_e_jump_k" do
|
|
insert_native_profile(%{theta_e_jump_k: 4.75})
|
|
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == 4.75
|
|
end
|
|
|
|
test "returns nil when no native profile or no jump value" do
|
|
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil
|
|
insert_native_profile(%{theta_e_jump_k: nil})
|
|
assert Features.theta_e_jump(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "shear_at_top/3" do
|
|
test "returns the native profile's shear_at_top_ms" do
|
|
insert_native_profile(%{shear_at_top_ms: 3.2})
|
|
assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == 3.2
|
|
end
|
|
|
|
test "returns nil when the native profile lacks a shear value" do
|
|
insert_native_profile(%{shear_at_top_ms: nil})
|
|
assert Features.shear_at_top(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "duct_thickness/3" do
|
|
test "returns the thickest duct's thickness_m" do
|
|
insert_native_profile(%{
|
|
ducts: [
|
|
%{"thickness_m" => 120.0, "min_freq_ghz" => 18.0},
|
|
%{"thickness_m" => 340.0, "min_freq_ghz" => 9.5},
|
|
%{"thickness_m" => 210.0, "min_freq_ghz" => 12.0}
|
|
]
|
|
})
|
|
|
|
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == 340.0
|
|
end
|
|
|
|
test "returns nil when there are no ducts" do
|
|
insert_native_profile(%{ducts: []})
|
|
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
|
|
test "returns nil when no native profile exists" do
|
|
assert Features.duct_thickness(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "best_duct_freq/3" do
|
|
test "returns the native profile's best_duct_band_ghz" do
|
|
insert_native_profile(%{best_duct_band_ghz: 12.5})
|
|
assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == 12.5
|
|
end
|
|
|
|
test "returns nil when the native profile lacks best_duct_band_ghz" do
|
|
insert_native_profile(%{best_duct_band_ghz: nil})
|
|
assert Features.best_duct_freq(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "duct_usable_for_band/4" do
|
|
test "returns 1.0 when the best duct traps the requested band" do
|
|
insert_native_profile(%{best_duct_band_ghz: 10.0})
|
|
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 24.0) == 1.0
|
|
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 1.0
|
|
end
|
|
|
|
test "returns 0.0 when the best duct can't trap the requested band" do
|
|
insert_native_profile(%{best_duct_band_ghz: 20.0})
|
|
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == 0.0
|
|
end
|
|
|
|
test "returns nil when there's no native profile" do
|
|
assert Features.duct_usable_for_band(@point_lat, @point_lon, @valid_time, 10.0) == nil
|
|
end
|
|
end
|
|
|
|
describe "distance_to_front/3 and parallel_to_front/3 placeholders" do
|
|
test "both return nil and never touch the database" do
|
|
assert Features.distance_to_front(@point_lat, @point_lon, @valid_time) == nil
|
|
assert Features.parallel_to_front(@point_lat, @point_lon, @valid_time) == nil
|
|
end
|
|
end
|
|
|
|
describe "all_features/0" do
|
|
test "returns a name-keyed map of 3-arity feature closures" do
|
|
features = Features.all_features()
|
|
assert is_map(features)
|
|
assert Map.has_key?(features, "naive_gradient")
|
|
assert Map.has_key?(features, "td_depression")
|
|
assert Map.has_key?(features, "time_of_day")
|
|
assert Map.has_key?(features, "duct_thickness")
|
|
|
|
# Excluded per the @doc contract
|
|
refute Map.has_key?(features, "duct_usable_for_band")
|
|
refute Map.has_key?(features, "distance_to_front")
|
|
refute Map.has_key?(features, "bulk_richardson")
|
|
|
|
# Every value is a 3-arity closure that returns a float or nil
|
|
# without raising.
|
|
for {_name, fun} <- features do
|
|
assert is_function(fun, 3)
|
|
result = fun.(@point_lat, @point_lon, @valid_time)
|
|
assert is_nil(result) or is_float(result)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp insert_native_profile(attrs) do
|
|
base = %{
|
|
valid_time: @valid_time,
|
|
lat: @point_lat,
|
|
lon: @point_lon,
|
|
level_count: 1,
|
|
heights_m: [10.0],
|
|
temp_k: [295.0],
|
|
spfh: [0.010],
|
|
pressure_pa: [101_325.0],
|
|
u_wind_ms: [2.0],
|
|
v_wind_ms: [1.0],
|
|
tke_m2s2: [0.5]
|
|
}
|
|
|
|
{:ok, _} =
|
|
%HrrrNativeProfile{}
|
|
|> HrrrNativeProfile.changeset(Map.merge(base, attrs))
|
|
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:valid_time, :lat, :lon])
|
|
end
|
|
end
|