prop/lib/microwaveprop/backtest.ex
Graham McIntire 6d92973853 Phase 9.1: Consolidated backtest report and contest log import
- Add `mix backtest --all` for consolidated pass/fail table across all features
- Add Backtest.consolidated_report/2 and to_consolidated_markdown/1
- Add Features.all_features/0 to auto-discover backtestable features
- Add `mix import_contest_logs` for bulk ARRL contest CSV import with dedup
- Fix hrrr_climatology to batch by (month, hour) to avoid query timeout
- Fix Repo.query! result pattern (Postgrex.Result, not tuple)
- Backtest reports for all Phase 1-6 features
2026-04-10 11:57:15 -05:00

417 lines
13 KiB
Elixir

defmodule Microwaveprop.Backtest do
@moduledoc """
Evaluates a propagation feature function against the historical QSO
corpus and a random-time baseline.
A feature function has the shape `(lat, lon, valid_time) -> float | nil`.
`nil` means "no data" and is excluded from the distribution stats.
Given a feature, `evaluate/2` pulls QSOs (up to `:sample_size`) with a
known `pos1`, applies the feature at the station1 location and the
QSO timestamp, and then does the same for `:baseline_size` random
(lat, lon, time) samples drawn in the same geographic and temporal
neighborhood. Comparing the two distributions tells us whether the
feature carries information about when propagation is good.
The baseline is deliberately a matched sample: each random draw
picks a real QSO location and perturbs its timestamp by a uniform
±30 days, so seasonal and diurnal effects are not a free variable.
"""
import Ecto.Query
alias Microwaveprop.Backtest.Distribution
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@type feature :: (float, float, DateTime.t() -> float | nil)
defmodule Distribution do
@moduledoc "Summary statistics for a collection of feature values."
defstruct count: 0, mean: nil, stddev: nil, p50: nil, p90: nil, min: nil, max: nil
@type t :: %__MODULE__{
count: non_neg_integer(),
mean: float | nil,
stddev: float | nil,
p50: float | nil,
p90: float | nil,
min: float | nil,
max: float | nil
}
def from_values([]), do: %__MODULE__{count: 0}
def from_values(values) when is_list(values) do
sorted = Enum.sort(values)
n = length(sorted)
mean = Enum.sum(sorted) / n
variance =
if n > 1 do
sorted
|> Enum.reduce(0.0, fn v, acc -> acc + :math.pow(v - mean, 2) end)
|> Kernel./(n - 1)
else
0.0
end
%__MODULE__{
count: n,
mean: mean,
stddev: :math.sqrt(variance),
p50: percentile(sorted, 0.5),
p90: percentile(sorted, 0.9),
min: List.first(sorted),
max: List.last(sorted)
}
end
defp percentile(sorted, fraction) do
n = length(sorted)
idx = trunc(Float.floor(fraction * (n - 1)))
Enum.at(sorted, idx)
end
end
defmodule Report do
@moduledoc "Top-level result of Backtest.evaluate/2."
defstruct feature_name: "anonymous",
qso_count: 0,
baseline_count: 0,
qso_distribution: %Distribution{},
baseline_distribution: %Distribution{}
@type t :: %__MODULE__{
feature_name: String.t(),
qso_count: non_neg_integer(),
baseline_count: non_neg_integer(),
qso_distribution: Distribution.t(),
baseline_distribution: Distribution.t()
}
end
@doc """
Generates `n` matched random baseline samples.
Each sample picks a real QSO location and perturbs its timestamp by
a uniform ±30 days. This controls for the seasonal and diurnal
distribution of contacts so the baseline isn't trivially distinguishable
from the QSO sample by time-of-year effects.
Returns a list of `{lat, lon, %DateTime{}}` triples. Returns `[]` if
there are no contacts with a known position.
## Options
* `:sample_size` - maximum number of contacts to draw from (default: 5000).
"""
@spec random_baseline(non_neg_integer(), keyword) :: [{float, float, DateTime.t()}]
def random_baseline(n, opts \\ []) when is_integer(n) and n >= 0 do
sample_size = Keyword.get(opts, :sample_size, 5000)
sample_size
|> load_contacts()
|> baseline_samples(n)
end
@distance_bins [
{"0-100", 0.0, 100.0},
{"100-250", 100.0, 250.0},
{"250-500", 250.0, 500.0},
{"500-1000", 500.0, 1000.0},
{"1000+", 1000.0, :infinity}
]
@doc """
Bins QSOs by `distance_km` and reports the feature distribution within each bin.
A feature that carries information about propagation quality should
show a monotonically increasing mean across distance bins: longer
contacts imply better propagation, so the feature should be higher
where the contacts reach further.
Returns a map keyed by bin label (`"0-100"`, `"100-250"`, …) whose
values are `Distribution` structs.
"""
@spec lift_by_distance(feature, keyword) :: %{String.t() => Distribution.t()}
def lift_by_distance(feature, opts \\ []) when is_function(feature, 3) do
sample_size = Keyword.get(opts, :sample_size, 5000)
contacts = load_contacts(sample_size)
contacts
|> Enum.map(fn contact ->
{distance_bin(contact.distance_km), eval_feature_for_contact(feature, contact)}
end)
|> Enum.reject(fn {bin, value} -> is_nil(bin) or is_nil(value) end)
|> Enum.group_by(fn {bin, _} -> bin end, fn {_, value} -> value end)
|> Map.new(fn {bin, values} -> {bin, Distribution.from_values(values)} end)
|> fill_missing_bins()
end
defp distance_bin(nil), do: nil
defp distance_bin(%Decimal{} = d), do: distance_bin(Decimal.to_float(d))
defp distance_bin(km) when is_number(km) do
Enum.find_value(@distance_bins, fn {label, lo, hi} ->
cond do
hi == :infinity and km >= lo -> label
km >= lo and km < hi -> label
true -> nil
end
end)
end
defp fill_missing_bins(bins) do
Enum.reduce(@distance_bins, bins, fn {label, _lo, _hi}, acc ->
Map.put_new(acc, label, %Distribution{})
end)
end
@doc """
Groups feature values by `band` and reports the distribution per band.
Useful to spot features whose lift is band-dependent: a duct-geometry
feature should carry more information on 24+ GHz than on 10 GHz, and a
humidity feature should do the opposite.
Returns a map keyed by `Decimal` band (MHz).
"""
@spec lift_by_band(feature, keyword) :: %{Decimal.t() => Distribution.t()}
def lift_by_band(feature, opts \\ []) when is_function(feature, 3) do
sample_size = Keyword.get(opts, :sample_size, 5000)
contacts = load_contacts(sample_size)
contacts
|> Enum.map(fn contact -> {contact.band, eval_feature_for_contact(feature, contact)} end)
|> Enum.reject(fn {band, value} -> is_nil(band) or is_nil(value) end)
|> Enum.group_by(fn {band, _} -> band end, fn {_, value} -> value end)
|> Map.new(fn {band, values} -> {band, Distribution.from_values(values)} end)
end
@doc """
Runs a feature function over QSOs and a random baseline, returning
a `Report` struct with matched distribution statistics.
## Options
* `:sample_size` - maximum number of QSOs to evaluate (default: 5000).
* `:baseline_size` - number of random-time baseline samples (default: same as `:sample_size`).
* `:feature_name` - label for the report (default: "anonymous").
"""
@spec evaluate(feature, keyword) :: Report.t()
def evaluate(feature, opts \\ []) when is_function(feature, 3) do
sample_size = Keyword.get(opts, :sample_size, 5000)
baseline_size = Keyword.get(opts, :baseline_size, sample_size)
feature_name = Keyword.get(opts, :feature_name, "anonymous")
contacts = load_contacts(sample_size)
qso_values =
contacts
|> Enum.map(fn c -> eval_feature_for_contact(feature, c) end)
|> Enum.reject(&is_nil/1)
baseline_values =
contacts
|> baseline_samples(baseline_size)
|> Enum.map(fn {lat, lon, time} -> feature.(lat, lon, time) end)
|> Enum.reject(&is_nil/1)
%Report{
feature_name: feature_name,
qso_count: length(contacts),
baseline_count: baseline_size,
qso_distribution: Distribution.from_values(qso_values),
baseline_distribution: Distribution.from_values(baseline_values)
}
end
@doc """
Runs multiple features against the same QSO sample and returns a list
of summary maps — one per feature — suitable for a consolidated table.
`features` is a map of `%{name => fun/3}`.
Each entry includes the feature name, QSO and baseline distribution
stats, and a pass/fail gate based on whether the feature has data.
"""
@spec consolidated_report(%{String.t() => feature}, keyword) :: [map()]
def consolidated_report(features, opts \\ []) when is_map(features) do
sample_size = Keyword.get(opts, :sample_size, 5000)
baseline_size = Keyword.get(opts, :baseline_size, sample_size)
contacts = load_contacts(sample_size)
baseline_samples = baseline_samples(contacts, baseline_size)
features
|> Enum.map(fn {name, fun} ->
qso_values =
contacts
|> Enum.map(&eval_feature_for_contact(fun, &1))
|> Enum.reject(&is_nil/1)
baseline_values =
baseline_samples
|> Enum.map(fn {lat, lon, time} -> fun.(lat, lon, time) end)
|> Enum.reject(&is_nil/1)
qso_dist = Distribution.from_values(qso_values)
baseline_dist = Distribution.from_values(baseline_values)
%{
feature_name: name,
qso_distribution: qso_dist,
baseline_distribution: baseline_dist,
gate: if(qso_dist.count > 0, do: :pass, else: :no_data)
}
end)
|> Enum.sort_by(& &1.feature_name)
end
@doc """
Renders the output of `consolidated_report/2` as a single Markdown table.
"""
@spec to_consolidated_markdown([map()]) :: String.t()
def to_consolidated_markdown(results) when is_list(results) do
header = "| Feature | QSO N | QSO Mean | QSO p50 | Baseline N | Baseline Mean | Baseline p50 | Gate |\n"
sep = "|---|---|---|---|---|---|---|---|\n"
body =
Enum.map_join(results, "", fn r ->
q = r.qso_distribution
b = r.baseline_distribution
gate_str = if r.gate == :pass, do: "PASS", else: "NO DATA"
"| #{r.feature_name} | #{q.count} | #{fmt(q.mean)} | #{fmt(q.p50)} | #{b.count} | #{fmt(b.mean)} | #{fmt(b.p50)} | #{gate_str} |\n"
end)
IO.iodata_to_binary([
"# Consolidated Backtest Report\n\n",
header,
sep,
body,
"\n"
])
end
@doc """
Renders a `Report` (plus optional distance and band tables) as Markdown.
This is the shape the `mix backtest` task prints to stdout and the
shape we write into `priv/backtest_reports/` for version control.
"""
@spec to_markdown(Report.t(), keyword) :: String.t()
def to_markdown(%Report{} = report, opts \\ []) do
distance_bins = Keyword.get(opts, :distance_bins)
band_stats = Keyword.get(opts, :band_stats)
iodata = [
"# Backtest: #{report.feature_name}\n\n",
"QSO sample: #{report.qso_count} \n",
"Baseline sample: #{report.baseline_count}\n\n",
"## Matched distribution\n\n",
distribution_table([
{"QSO times", report.qso_distribution},
{"Random baseline", report.baseline_distribution}
]),
distance_section(distance_bins),
band_section(band_stats)
]
IO.iodata_to_binary(iodata)
end
defp distribution_table(rows) do
header = "| Set | N | Mean | Stddev | p50 | p90 | Min | Max |\n"
sep = "|---|---|---|---|---|---|---|---|\n"
body =
Enum.map_join(rows, "", fn {label, dist} ->
"| #{label} | #{dist.count} | #{fmt(dist.mean)} | #{fmt(dist.stddev)} | #{fmt(dist.p50)} | #{fmt(dist.p90)} | #{fmt(dist.min)} | #{fmt(dist.max)} |\n"
end)
[header, sep, body, "\n"]
end
defp distance_section(nil), do: []
defp distance_section(bins) do
rows =
Enum.map(@distance_bins, fn {label, _lo, _hi} ->
{label, Map.get(bins, label, %Distribution{})}
end)
["## Lift by distance (km)\n\n", distribution_table(rows)]
end
defp band_section(nil), do: []
defp band_section(bands) do
rows =
bands
|> Enum.sort_by(fn {band, _} -> Decimal.to_float(band) end)
|> Enum.map(fn {band, dist} -> {"#{band} MHz", dist} end)
["## Lift by band\n\n", distribution_table(rows)]
end
defp fmt(nil), do: ""
defp fmt(n) when is_float(n), do: :erlang.float_to_binary(n, decimals: 3)
defp fmt(n), do: to_string(n)
# Load contacts with a known position, newest first.
defp load_contacts(limit) do
Contact
|> where([c], not is_nil(c.pos1))
|> order_by([c], desc: c.qso_timestamp)
|> limit(^limit)
|> Repo.all()
end
defp eval_feature_for_contact(feature, %Contact{pos1: pos, qso_timestamp: ts}) do
case pos_to_latlon(pos) do
{lat, lon} -> feature.(lat, lon, ts)
nil -> nil
end
end
defp eval_feature_for_contact(_feature, _contact), do: nil
# pos1 is sometimes stored with "lon" and sometimes with "lng".
defp pos_to_latlon(%{"lat" => lat, "lon" => lon}) when is_number(lat) and is_number(lon),
do: {lat * 1.0, lon * 1.0}
defp pos_to_latlon(%{"lat" => lat, "lng" => lon}) when is_number(lat) and is_number(lon),
do: {lat * 1.0, lon * 1.0}
defp pos_to_latlon(_), do: nil
# Matched random baseline: pick a random contact, perturb its
# timestamp ±30 days. Empty contact list → empty baseline.
defp baseline_samples([], _n), do: []
defp baseline_samples(_contacts, 0), do: []
defp baseline_samples(contacts, n) do
usable = Enum.filter(contacts, fn c -> pos_to_latlon(c.pos1) end)
if usable == [] do
[]
else
for _ <- 1..n do
contact = Enum.random(usable)
{lat, lon} = pos_to_latlon(contact.pos1)
offset_seconds = :rand.uniform(60 * 24 * 3600) - 30 * 24 * 3600
time = DateTime.add(contact.qso_timestamp, offset_seconds, :second)
{lat, lon, time}
end
end
end
end