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
This commit is contained in:
parent
234519bc66
commit
6d92973853
15 changed files with 544 additions and 37 deletions
|
|
@ -232,6 +232,75 @@ defmodule Microwaveprop.Backtest do
|
||||||
}
|
}
|
||||||
end
|
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 """
|
@doc """
|
||||||
Renders a `Report` (plus optional distance and band tables) as Markdown.
|
Renders a `Report` (plus optional distance and band tables) as Markdown.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,23 @@ defmodule Microwaveprop.Backtest.Features do
|
||||||
alias Microwaveprop.Weather.HrrrClimatology
|
alias Microwaveprop.Weather.HrrrClimatology
|
||||||
alias Microwaveprop.Weather.HrrrNativeProfile
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a map of all backtestable features: `%{name => fun/3}`.
|
||||||
|
|
||||||
|
Excludes `duct_usable_for_band/4` (4-arity) and the placeholder stubs
|
||||||
|
that always return nil (`distance_to_front`, `parallel_to_front`).
|
||||||
|
"""
|
||||||
|
def all_features do
|
||||||
|
excluded = MapSet.new([:duct_usable_for_band, :distance_to_front, :parallel_to_front])
|
||||||
|
|
||||||
|
__MODULE__.__info__(:functions)
|
||||||
|
|> Enum.filter(fn {_name, arity} -> arity == 3 end)
|
||||||
|
|> Enum.reject(fn {name, _} -> MapSet.member?(excluded, name) end)
|
||||||
|
|> Map.new(fn {name, _} ->
|
||||||
|
{to_string(name), &apply(__MODULE__, name, [&1, &2, &3])}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Minimum refractivity gradient from the nearest HRRR profile.
|
Minimum refractivity gradient from the nearest HRRR profile.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,19 +11,20 @@ defmodule Mix.Tasks.Backtest do
|
||||||
mix backtest --feature NaiveGradient # CamelCase also works
|
mix backtest --feature NaiveGradient # CamelCase also works
|
||||||
mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient
|
mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient
|
||||||
mix backtest --feature naive_gradient --sample 1000 --out priv/backtest_reports/naive.md
|
mix backtest --feature naive_gradient --sample 1000 --out priv/backtest_reports/naive.md
|
||||||
|
mix backtest --all --out priv/backtest_reports/consolidated.md
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
* `--feature` (required) — fully-qualified `Module.function` or a
|
* `--feature` — fully-qualified `Module.function` or a short name
|
||||||
short name that lives on `Microwaveprop.Backtest.Features`. Names
|
that lives on `Microwaveprop.Backtest.Features`. Names are normalized
|
||||||
are normalized via `Macro.underscore/1`, so `NaiveGradient`,
|
via `Macro.underscore/1`, so `NaiveGradient`, `naive_gradient`, and
|
||||||
`naive_gradient`, and `naiveGradient` all resolve to the same
|
`naiveGradient` all resolve to the same function.
|
||||||
function.
|
* `--all` — run all registered features and produce a consolidated
|
||||||
|
pass/fail table.
|
||||||
* `--sample` — max number of QSOs to evaluate (default: 5000).
|
* `--sample` — max number of QSOs to evaluate (default: 5000).
|
||||||
* `--baseline` — random-baseline sample size (default: same as `--sample`).
|
* `--baseline` — random-baseline sample size (default: same as `--sample`).
|
||||||
* `--out` — optional file path to write the report to in addition
|
* `--out` — optional file path to write the report to in addition
|
||||||
to printing it. Useful for saving baseline reports into
|
to printing it.
|
||||||
`priv/backtest_reports/`.
|
|
||||||
"""
|
"""
|
||||||
use Mix.Task
|
use Mix.Task
|
||||||
|
|
||||||
|
|
@ -36,9 +37,41 @@ defmodule Mix.Tasks.Backtest do
|
||||||
|
|
||||||
{opts, _, _} =
|
{opts, _, _} =
|
||||||
OptionParser.parse(argv,
|
OptionParser.parse(argv,
|
||||||
switches: [feature: :string, sample: :integer, baseline: :integer, out: :string]
|
switches: [feature: :string, all: :boolean, sample: :integer, baseline: :integer, out: :string]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if Keyword.get(opts, :all) do
|
||||||
|
run_all(opts)
|
||||||
|
else
|
||||||
|
run_single(opts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_all(opts) do
|
||||||
|
sample_size = Keyword.get(opts, :sample, 5000)
|
||||||
|
baseline_size = Keyword.get(opts, :baseline, sample_size)
|
||||||
|
out_path = Keyword.get(opts, :out)
|
||||||
|
|
||||||
|
features = Microwaveprop.Backtest.Features.all_features()
|
||||||
|
Mix.shell().info("Running consolidated backtest for #{map_size(features)} features...")
|
||||||
|
|
||||||
|
results =
|
||||||
|
Backtest.consolidated_report(features,
|
||||||
|
sample_size: sample_size,
|
||||||
|
baseline_size: baseline_size
|
||||||
|
)
|
||||||
|
|
||||||
|
markdown = Backtest.to_consolidated_markdown(results)
|
||||||
|
IO.puts(markdown)
|
||||||
|
|
||||||
|
if out_path do
|
||||||
|
File.mkdir_p!(Path.dirname(out_path))
|
||||||
|
File.write!(out_path, markdown)
|
||||||
|
Mix.shell().info("Wrote consolidated report to #{out_path}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_single(opts) do
|
||||||
feature_spec = Keyword.fetch!(opts, :feature)
|
feature_spec = Keyword.fetch!(opts, :feature)
|
||||||
sample_size = Keyword.get(opts, :sample, 5000)
|
sample_size = Keyword.get(opts, :sample, 5000)
|
||||||
baseline_size = Keyword.get(opts, :baseline, sample_size)
|
baseline_size = Keyword.get(opts, :baseline, sample_size)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ defmodule Mix.Tasks.HrrrClimatology do
|
||||||
Aggregates `hrrr_profiles.surface_temp_c` by (lat, lon, month, hour)
|
Aggregates `hrrr_profiles.surface_temp_c` by (lat, lon, month, hour)
|
||||||
into `hrrr_climatology` for use by the temperature-anomaly feature.
|
into `hrrr_climatology` for use by the temperature-anomaly feature.
|
||||||
|
|
||||||
With 42M+ grid-point profiles, this runs a single SQL GROUP BY and
|
Discovers which (month, hour) combos have data first, then processes
|
||||||
bulk-inserts the results. Idempotent via ON CONFLICT.
|
only those batches. Idempotent via ON CONFLICT.
|
||||||
|
|
||||||
mix hrrr_climatology # build from all grid-point profiles
|
mix hrrr_climatology # build from all grid-point profiles
|
||||||
mix hrrr_climatology --min-samples 5 # require at least 5 observations per cell
|
mix hrrr_climatology --min-samples 5 # require at least 5 observations per cell
|
||||||
|
|
@ -22,37 +22,61 @@ defmodule Mix.Tasks.HrrrClimatology do
|
||||||
{opts, _, _} = OptionParser.parse(argv, switches: [min_samples: :integer])
|
{opts, _, _} = OptionParser.parse(argv, switches: [min_samples: :integer])
|
||||||
min_samples = Keyword.get(opts, :min_samples, 3)
|
min_samples = Keyword.get(opts, :min_samples, 3)
|
||||||
|
|
||||||
Mix.shell().info("Building climatology (min_samples=#{min_samples})...")
|
# Discover which (month, hour) combos actually have data
|
||||||
|
%{rows: combos} =
|
||||||
# Single SQL: aggregate and upsert in one shot
|
|
||||||
{count, _} =
|
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"""
|
"""
|
||||||
INSERT INTO hrrr_climatology (id, lat, lon, month, hour,
|
SELECT EXTRACT(MONTH FROM valid_time)::int AS month,
|
||||||
mean_surface_temp_c, stddev_surface_temp_c, sample_count)
|
EXTRACT(HOUR FROM valid_time)::int AS hour
|
||||||
SELECT gen_random_uuid(), lat, lon,
|
|
||||||
EXTRACT(MONTH FROM valid_time)::int AS month,
|
|
||||||
EXTRACT(HOUR FROM valid_time)::int AS hour,
|
|
||||||
AVG(surface_temp_c),
|
|
||||||
STDDEV_SAMP(surface_temp_c),
|
|
||||||
COUNT(*)
|
|
||||||
FROM hrrr_profiles
|
FROM hrrr_profiles
|
||||||
WHERE surface_temp_c IS NOT NULL
|
WHERE surface_temp_c IS NOT NULL AND is_grid_point = true
|
||||||
AND is_grid_point = true
|
GROUP BY 1, 2
|
||||||
GROUP BY lat, lon,
|
ORDER BY 1, 2
|
||||||
EXTRACT(MONTH FROM valid_time)::int,
|
|
||||||
EXTRACT(HOUR FROM valid_time)::int
|
|
||||||
HAVING COUNT(*) >= $1
|
|
||||||
ON CONFLICT (lat, lon, month, hour)
|
|
||||||
DO UPDATE SET
|
|
||||||
mean_surface_temp_c = EXCLUDED.mean_surface_temp_c,
|
|
||||||
stddev_surface_temp_c = EXCLUDED.stddev_surface_temp_c,
|
|
||||||
sample_count = EXCLUDED.sample_count
|
|
||||||
""",
|
""",
|
||||||
[min_samples],
|
[],
|
||||||
timeout: 600_000
|
timeout: 120_000
|
||||||
)
|
)
|
||||||
|
|
||||||
Mix.shell().info("Upserted #{count} climatology records.")
|
Mix.shell().info(
|
||||||
|
"Building climatology (min_samples=#{min_samples}, #{length(combos)} batches)..."
|
||||||
|
)
|
||||||
|
|
||||||
|
total =
|
||||||
|
combos
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.reduce(0, fn {[month, hour], idx}, acc ->
|
||||||
|
%{num_rows: count} =
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO hrrr_climatology (id, lat, lon, month, hour,
|
||||||
|
mean_surface_temp_c, stddev_surface_temp_c, sample_count)
|
||||||
|
SELECT gen_random_uuid(), lat, lon,
|
||||||
|
$2 AS month,
|
||||||
|
$3 AS hour,
|
||||||
|
AVG(surface_temp_c),
|
||||||
|
STDDEV_SAMP(surface_temp_c),
|
||||||
|
COUNT(*)
|
||||||
|
FROM hrrr_profiles
|
||||||
|
WHERE surface_temp_c IS NOT NULL
|
||||||
|
AND is_grid_point = true
|
||||||
|
AND EXTRACT(MONTH FROM valid_time)::int = $2
|
||||||
|
AND EXTRACT(HOUR FROM valid_time)::int = $3
|
||||||
|
GROUP BY lat, lon
|
||||||
|
HAVING COUNT(*) >= $1
|
||||||
|
ON CONFLICT (lat, lon, month, hour)
|
||||||
|
DO UPDATE SET
|
||||||
|
mean_surface_temp_c = EXCLUDED.mean_surface_temp_c,
|
||||||
|
stddev_surface_temp_c = EXCLUDED.stddev_surface_temp_c,
|
||||||
|
sample_count = EXCLUDED.sample_count
|
||||||
|
""",
|
||||||
|
[min_samples, month, hour],
|
||||||
|
timeout: 300_000
|
||||||
|
)
|
||||||
|
|
||||||
|
Mix.shell().info(" [#{idx}/#{length(combos)}] month=#{month} hour=#{hour}: #{count} rows")
|
||||||
|
acc + count
|
||||||
|
end)
|
||||||
|
|
||||||
|
Mix.shell().info("Upserted #{total} climatology records total.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
99
lib/mix/tasks/import_contest_logs.ex
Normal file
99
lib/mix/tasks/import_contest_logs.ex
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
defmodule Mix.Tasks.ImportContestLogs do
|
||||||
|
@shortdoc "Import ARRL contest CSV files with deduplication"
|
||||||
|
@moduledoc """
|
||||||
|
Imports one or more contest CSV files, deduplicating both across files
|
||||||
|
and against existing contacts in the database.
|
||||||
|
|
||||||
|
Uses the same direction-agnostic, 60-minute-window dedup logic as the
|
||||||
|
web CSV uploader.
|
||||||
|
|
||||||
|
mix import_contest_logs ~/Downloads/arrl_10ghz_2024.csv ~/Downloads/arrl_10ghz_2023.csv ...
|
||||||
|
mix import_contest_logs ~/Downloads/arrl_10ghz_*.csv
|
||||||
|
"""
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.CsvImport
|
||||||
|
|
||||||
|
@impl Mix.Task
|
||||||
|
def run(argv) do
|
||||||
|
if argv == [] do
|
||||||
|
Mix.raise("Usage: mix import_contest_logs FILE [FILE ...]")
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.Task.run("app.start")
|
||||||
|
Oban.pause_all_queues(Oban)
|
||||||
|
|
||||||
|
# Concatenate all CSVs into one string (skip headers after the first file)
|
||||||
|
combined =
|
||||||
|
argv
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {path, idx} ->
|
||||||
|
expanded = Path.expand(path)
|
||||||
|
content = File.read!(expanded)
|
||||||
|
lines = String.split(content, ~r/\r?\n/)
|
||||||
|
|
||||||
|
# Skip header for all files after the first
|
||||||
|
data_lines = if idx == 0, do: lines, else: tl(lines)
|
||||||
|
|
||||||
|
# Drop blank trailing lines
|
||||||
|
Enum.reject(data_lines, &(String.trim(&1) == ""))
|
||||||
|
end)
|
||||||
|
|> List.flatten()
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
total_lines = combined |> String.split("\n") |> length() |> then(&(&1 - 1))
|
||||||
|
Mix.shell().info("Combined #{length(argv)} files: #{total_lines} data rows")
|
||||||
|
|
||||||
|
Mix.shell().info("Running preview (validate + deduplicate)...")
|
||||||
|
|
||||||
|
case CsvImport.preview(combined, "contest-import@ntms.org") do
|
||||||
|
{:ok, preview} ->
|
||||||
|
Mix.shell().info("""
|
||||||
|
Preview:
|
||||||
|
Valid: #{length(preview.valid)}
|
||||||
|
Duplicates: #{length(preview.duplicates)}
|
||||||
|
Invalid: #{length(preview.invalid)}
|
||||||
|
""")
|
||||||
|
|
||||||
|
if preview.invalid != [] do
|
||||||
|
preview.invalid
|
||||||
|
|> Enum.take(10)
|
||||||
|
|> Enum.each(fn row ->
|
||||||
|
Mix.shell().info(" Row #{row.row_num}: #{Enum.join(row.messages, ", ")}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
if length(preview.invalid) > 10 do
|
||||||
|
Mix.shell().info(" ... and #{length(preview.invalid) - 10} more")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if preview.valid == [] do
|
||||||
|
Mix.shell().info("Nothing to import.")
|
||||||
|
else
|
||||||
|
Mix.shell().info("Importing #{length(preview.valid)} contacts...")
|
||||||
|
|
||||||
|
# Import in batches to show progress
|
||||||
|
preview.valid
|
||||||
|
|> Enum.chunk_every(500)
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.reduce({0, 0}, fn {batch, batch_num}, {imported, errors} ->
|
||||||
|
{:ok, result} = CsvImport.commit(batch)
|
||||||
|
new_imported = imported + length(result.imported)
|
||||||
|
new_errors = errors + length(result.errors)
|
||||||
|
|
||||||
|
Mix.shell().info(
|
||||||
|
" Batch #{batch_num}: +#{length(result.imported)} imported, #{length(result.errors)} errors (total: #{new_imported})"
|
||||||
|
)
|
||||||
|
|
||||||
|
{new_imported, new_errors}
|
||||||
|
end)
|
||||||
|
|> then(fn {imported, errors} ->
|
||||||
|
Mix.shell().info("Done! Imported #{imported} contacts, #{errors} errors.")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Mix.raise("Failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -8,7 +8,7 @@ Baseline sample: 5000
|
||||||
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
| QSO times | 5000 | -113.971 | 42.207 | -105.154 | -69.043 | -331.397 | -35.521 |
|
| QSO times | 5000 | -113.971 | 42.207 | -105.154 | -69.043 | -331.397 | -35.521 |
|
||||||
| Random baseline | 151 | -118.002 | 43.405 | -107.528 | -72.860 | -268.079 | -41.047 |
|
| Random baseline | 140 | -112.877 | 51.502 | -100.413 | -62.855 | -331.397 | -48.393 |
|
||||||
|
|
||||||
## Lift by distance (km)
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
|
|
||||||
27
priv/backtest_reports/best_duct_freq.md
Normal file
27
priv/backtest_reports/best_duct_freq.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.best_duct_freq
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
27
priv/backtest_reports/bulk_richardson.md
Normal file
27
priv/backtest_reports/bulk_richardson.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.bulk_richardson
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
27
priv/backtest_reports/duct_thickness.md
Normal file
27
priv/backtest_reports/duct_thickness.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.duct_thickness
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
27
priv/backtest_reports/duct_usable_10ghz.md
Normal file
27
priv/backtest_reports/duct_usable_10ghz.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.duct_usable_10ghz
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
27
priv/backtest_reports/native_surface_refractivity.md
Normal file
27
priv/backtest_reports/native_surface_refractivity.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.native_surface_refractivity
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
27
priv/backtest_reports/shear_at_top.md
Normal file
27
priv/backtest_reports/shear_at_top.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.shear_at_top
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
31
priv/backtest_reports/temperature_anomaly.md
Normal file
31
priv/backtest_reports/temperature_anomaly.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.temperature_anomaly
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 4974 | 1.118 | 2.624 | 1.402 | 4.175 | -8.127 | 9.335 |
|
||||||
|
| Random baseline | 128 | 0.332 | 2.840 | 0.581 | 3.714 | -6.600 | 7.206 |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 1233 | 0.570 | 2.423 | 0.828 | 3.469 | -8.127 | 9.264 |
|
||||||
|
| 100-250 | 2285 | 1.088 | 2.519 | 1.467 | 3.941 | -7.423 | 9.264 |
|
||||||
|
| 250-500 | 1288 | 1.596 | 2.913 | 1.701 | 5.037 | -7.706 | 9.335 |
|
||||||
|
| 500-1000 | 167 | 1.872 | 2.260 | 2.047 | 4.398 | -7.706 | 6.211 |
|
||||||
|
| 1000+ | 1 | 2.311 | 0.000 | 2.311 | 2.311 | 2.311 | 2.311 |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 10000 MHz | 4444 | 1.145 | 2.648 | 1.454 | 4.206 | -8.127 | 9.335 |
|
||||||
|
| 24000 MHz | 465 | 0.840 | 2.426 | 1.247 | 3.678 | -6.932 | 6.951 |
|
||||||
|
| 47000 MHz | 51 | 0.873 | 2.315 | 1.396 | 4.056 | -6.932 | 4.248 |
|
||||||
|
| 75000 MHz | 14 | 2.374 | 1.534 | 1.317 | 4.056 | 0.943 | 4.056 |
|
||||||
|
|
||||||
27
priv/backtest_reports/theta_e_jump.md
Normal file
27
priv/backtest_reports/theta_e_jump.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Backtest: Microwaveprop.Backtest.Features.theta_e_jump
|
||||||
|
|
||||||
|
QSO sample: 5000
|
||||||
|
Baseline sample: 5000
|
||||||
|
|
||||||
|
## Matched distribution
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| QSO times | 0 | — | — | — | — | — | — |
|
||||||
|
| Random baseline | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by distance (km)
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| 0-100 | 0 | — | — | — | — | — | — |
|
||||||
|
| 100-250 | 0 | — | — | — | — | — | — |
|
||||||
|
| 250-500 | 0 | — | — | — | — | — | — |
|
||||||
|
| 500-1000 | 0 | — | — | — | — | — | — |
|
||||||
|
| 1000+ | 0 | — | — | — | — | — | — |
|
||||||
|
|
||||||
|
## Lift by band
|
||||||
|
|
||||||
|
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
|
||||||
|
|
@ -138,6 +138,51 @@ defmodule Microwaveprop.BacktestTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "consolidated_report/1" do
|
||||||
|
test "runs all registered features and returns a summary per feature" do
|
||||||
|
create_contact(%{station1: "A", pos1: %{"lat" => 32.0, "lon" => -97.0}})
|
||||||
|
create_contact(%{station1: "B", pos1: %{"lat" => 33.0, "lon" => -96.0}})
|
||||||
|
|
||||||
|
features = %{
|
||||||
|
"always_one" => fn _lat, _lon, _time -> 1.0 end,
|
||||||
|
"always_nil" => fn _lat, _lon, _time -> nil end,
|
||||||
|
"uses_lat" => fn lat, _lon, _time -> lat end
|
||||||
|
}
|
||||||
|
|
||||||
|
results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5)
|
||||||
|
|
||||||
|
assert is_list(results)
|
||||||
|
assert length(results) == 3
|
||||||
|
|
||||||
|
one = Enum.find(results, &(&1.feature_name == "always_one"))
|
||||||
|
assert one.qso_distribution.count == 2
|
||||||
|
assert one.qso_distribution.mean == 1.0
|
||||||
|
|
||||||
|
nil_feat = Enum.find(results, &(&1.feature_name == "always_nil"))
|
||||||
|
assert nil_feat.qso_distribution.count == 0
|
||||||
|
|
||||||
|
lat_feat = Enum.find(results, &(&1.feature_name == "uses_lat"))
|
||||||
|
assert lat_feat.qso_distribution.count == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_consolidated_markdown renders a summary table" do
|
||||||
|
create_contact(%{station1: "A", pos1: %{"lat" => 32.0, "lon" => -97.0}})
|
||||||
|
|
||||||
|
features = %{
|
||||||
|
"feat_a" => fn _lat, _lon, _time -> 5.0 end,
|
||||||
|
"feat_b" => fn _lat, _lon, _time -> nil end
|
||||||
|
}
|
||||||
|
|
||||||
|
results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5)
|
||||||
|
markdown = Backtest.to_consolidated_markdown(results)
|
||||||
|
|
||||||
|
assert markdown =~ "feat_a"
|
||||||
|
assert markdown =~ "feat_b"
|
||||||
|
assert markdown =~ "Consolidated"
|
||||||
|
assert markdown =~ "QSO N"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "lift_by_band/2" do
|
describe "lift_by_band/2" do
|
||||||
test "groups feature values by band and summarizes per band" do
|
test "groups feature values by band and summarizes per band" do
|
||||||
create_contact(%{
|
create_contact(%{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue