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_*ghz aliases" do test "duct_usable_10ghz delegates to band 10.0" do # No native profile present → delegate returns nil. assert Features.duct_usable_10ghz(@point_lat, @point_lon, @valid_time) == nil end test "duct_usable_24ghz delegates to band 24.0" do assert Features.duct_usable_24ghz(@point_lat, @point_lon, @valid_time) == nil end test "duct_usable_47ghz delegates to band 47.0" do assert Features.duct_usable_47ghz(@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 map_size(features) > 0 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. assert is_function(features["time_of_day"], 3) assert is_float(features["time_of_day"].(@point_lat, @point_lon, @valid_time)) for {name, fun} <- features, name != "time_of_day" do assert is_function(fun, 3) result = fun.(@point_lat, @point_lon, @valid_time) assert result == nil 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