57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'. Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback — which just caused a SQL widget to silently miscount 98% of contacts (count_narr_done used `pos1->>'lon'` directly, no fallback, so every lng-keyed row returned NULL and failed the coverage check). - Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to 'lon' and dropping 'lng'. - Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers, scorer, weather, radio.ex, contact show view, and recalibrator. - lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly (was reading 'lng' only, which would have broken after migration). - priv/repo/import_contacts.exs one-time seed script now emits 'lon' with string keys, matching production shape. - Test fixtures in 4 test files normalized to 'lon'. - Two lng-characterization tests deleted — nonsensical post-normalize. - Updated notebook + old import_weather script to match. - JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
412 lines
13 KiB
Elixir
412 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
|
|
|
|
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
|