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 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(_), 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