prop/test/microwaveprop/backtest_test.exs
Graham McIntire ab04cb9168 Phase 0: backtest harness
Add Microwaveprop.Backtest: a feature-evaluation framework that runs
a (lat, lon, valid_time) -> float function over the historical QSO
corpus and a matched random-time baseline, reporting distribution
statistics, distance-binned lift, and band-stratified lift.

Adds four baseline feature wrappers around the current scorer inputs
(naive_gradient, td_depression, time_of_day, pressure), a mix backtest
CLI, and the first set of baseline reports under priv/backtest_reports
so downstream phases have a frozen reference point to compare against.
2026-04-09 16:10:54 -05:00

170 lines
5.2 KiB
Elixir

defmodule Microwaveprop.BacktestTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Backtest
alias Microwaveprop.Radio.Contact
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
{:ok, contact} =
%Contact{}
|> Contact.changeset(Map.merge(default, attrs))
|> Repo.insert()
contact
end
describe "evaluate/2" do
test "returns counts and a qso distribution for a trivial feature" do
create_contact()
feature = fn _lat, _lon, _time -> 1.0 end
report = Backtest.evaluate(feature, sample_size: 10, baseline_size: 10)
assert report.qso_count == 1
assert report.baseline_count == 10
assert %Backtest.Distribution{} = report.qso_distribution
assert %Backtest.Distribution{} = report.baseline_distribution
assert report.qso_distribution.count == 1
assert report.qso_distribution.mean == 1.0
end
test "skips contacts with nil pos1" do
create_contact(%{pos1: nil, pos2: nil})
feature = fn _lat, _lon, _time -> 1.0 end
report = Backtest.evaluate(feature, sample_size: 10, baseline_size: 0)
assert report.qso_count == 0
end
test "excludes nil feature values from the distribution" do
create_contact(%{station1: "A"})
create_contact(%{station1: "B"})
feature = fn _lat, _lon, _time -> nil end
report = Backtest.evaluate(feature, sample_size: 10, baseline_size: 0)
assert report.qso_count == 2
assert report.qso_distribution.count == 0
end
end
describe "lift_by_distance/2" do
test "bins QSOs by distance_km and summarizes the feature per bin" do
create_contact(%{station1: "A", distance_km: Decimal.new("50"), pos1: %{"lat" => 1.0, "lon" => 0.0}})
create_contact(%{station1: "B", distance_km: Decimal.new("300"), pos1: %{"lat" => 5.0, "lon" => 0.0}})
create_contact(%{station1: "C", distance_km: Decimal.new("800"), pos1: %{"lat" => 9.0, "lon" => 0.0}})
f = fn lat, _lon, _time -> lat end
bins = Backtest.lift_by_distance(f, sample_size: 10)
assert bins["0-100"].count == 1
assert bins["0-100"].mean == 1.0
assert bins["100-250"].count == 0
assert bins["250-500"].count == 1
assert bins["250-500"].mean == 5.0
assert bins["500-1000"].count == 1
assert bins["500-1000"].mean == 9.0
assert bins["1000+"].count == 0
end
test "drops nil feature values from the bin stats" do
create_contact(%{station1: "A", distance_km: Decimal.new("50"), pos1: %{"lat" => 1.0, "lon" => 0.0}})
create_contact(%{station1: "B", distance_km: Decimal.new("60"), pos1: %{"lat" => 2.0, "lon" => 0.0}})
f = fn lat, _lon, _time -> if lat == 1.0, do: nil, else: 2.0 end
bins = Backtest.lift_by_distance(f, sample_size: 10)
assert bins["0-100"].count == 1
assert bins["0-100"].mean == 2.0
end
end
describe "random_baseline/2" do
test "generates N samples drawn from the contact locations" do
create_contact(%{
station1: "A",
qso_timestamp: ~U[2026-03-01 00:00:00Z],
pos1: %{"lat" => 1.0, "lon" => 10.0}
})
create_contact(%{
station1: "B",
qso_timestamp: ~U[2026-06-15 12:00:00Z],
pos1: %{"lat" => 2.0, "lon" => 20.0}
})
samples = Backtest.random_baseline(5, sample_size: 10)
assert length(samples) == 5
Enum.each(samples, fn {lat, lon, time} ->
assert lat in [1.0, 2.0]
assert lon in [10.0, 20.0]
assert %DateTime{} = time
end)
end
test "keeps timestamps within ±30 days of a source QSO" do
create_contact(%{
station1: "A",
qso_timestamp: ~U[2026-03-01 00:00:00Z],
pos1: %{"lat" => 1.0, "lon" => 10.0}
})
samples = Backtest.random_baseline(200, sample_size: 10)
source = ~U[2026-03-01 00:00:00Z]
Enum.each(samples, fn {_, _, time} ->
delta_seconds = abs(DateTime.diff(time, source))
assert delta_seconds <= 30 * 24 * 3600
end)
end
test "returns an empty list when there are no contacts with a position" do
assert Backtest.random_baseline(10) == []
end
end
describe "lift_by_band/2" do
test "groups feature values by band and summarizes per band" do
create_contact(%{
station1: "A",
band: Decimal.new("10000"),
pos1: %{"lat" => 1.0, "lon" => 0.0}
})
create_contact(%{
station1: "B",
band: Decimal.new("24000"),
pos1: %{"lat" => 7.0, "lon" => 0.0}
})
create_contact(%{
station1: "C",
band: Decimal.new("24000"),
pos1: %{"lat" => 9.0, "lon" => 0.0}
})
f = fn lat, _lon, _time -> lat end
bands = Backtest.lift_by_band(f, sample_size: 10)
assert bands[Decimal.new("10000")].count == 1
assert bands[Decimal.new("10000")].mean == 1.0
assert bands[Decimal.new("24000")].count == 2
assert bands[Decimal.new("24000")].mean == 8.0
end
end
end