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:
Graham McIntire 2026-04-10 11:56:54 -05:00
parent 234519bc66
commit 6d92973853
15 changed files with 544 additions and 37 deletions

View file

@ -232,6 +232,75 @@ defmodule Microwaveprop.Backtest do
}
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.

View file

@ -25,6 +25,23 @@ defmodule Microwaveprop.Backtest.Features do
alias Microwaveprop.Weather.HrrrClimatology
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 """
Minimum refractivity gradient from the nearest HRRR profile.

View file

@ -11,19 +11,20 @@ defmodule Mix.Tasks.Backtest do
mix backtest --feature NaiveGradient # CamelCase also works
mix backtest --feature Microwaveprop.Backtest.Features.naive_gradient
mix backtest --feature naive_gradient --sample 1000 --out priv/backtest_reports/naive.md
mix backtest --all --out priv/backtest_reports/consolidated.md
## Options
* `--feature` (required) fully-qualified `Module.function` or a
short name that lives on `Microwaveprop.Backtest.Features`. Names
are normalized via `Macro.underscore/1`, so `NaiveGradient`,
`naive_gradient`, and `naiveGradient` all resolve to the same
function.
* `--feature` fully-qualified `Module.function` or a short name
that lives on `Microwaveprop.Backtest.Features`. Names are normalized
via `Macro.underscore/1`, so `NaiveGradient`, `naive_gradient`, and
`naiveGradient` all resolve to the same function.
* `--all` run all registered features and produce a consolidated
pass/fail table.
* `--sample` max number of QSOs to evaluate (default: 5000).
* `--baseline` random-baseline sample size (default: same as `--sample`).
* `--out` optional file path to write the report to in addition
to printing it. Useful for saving baseline reports into
`priv/backtest_reports/`.
to printing it.
"""
use Mix.Task
@ -36,9 +37,41 @@ defmodule Mix.Tasks.Backtest do
{opts, _, _} =
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)
sample_size = Keyword.get(opts, :sample, 5000)
baseline_size = Keyword.get(opts, :baseline, sample_size)

View file

@ -4,8 +4,8 @@ defmodule Mix.Tasks.HrrrClimatology do
Aggregates `hrrr_profiles.surface_temp_c` by (lat, lon, month, hour)
into `hrrr_climatology` for use by the temperature-anomaly feature.
With 42M+ grid-point profiles, this runs a single SQL GROUP BY and
bulk-inserts the results. Idempotent via ON CONFLICT.
Discovers which (month, hour) combos have data first, then processes
only those batches. Idempotent via ON CONFLICT.
mix hrrr_climatology # build from all grid-point profiles
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])
min_samples = Keyword.get(opts, :min_samples, 3)
Mix.shell().info("Building climatology (min_samples=#{min_samples})...")
# Single SQL: aggregate and upsert in one shot
{count, _} =
# Discover which (month, hour) combos actually have data
%{rows: combos} =
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,
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(*)
SELECT EXTRACT(MONTH FROM valid_time)::int AS month,
EXTRACT(HOUR FROM valid_time)::int AS hour
FROM hrrr_profiles
WHERE surface_temp_c IS NOT NULL
AND is_grid_point = true
GROUP BY lat, lon,
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
WHERE surface_temp_c IS NOT NULL AND is_grid_point = true
GROUP BY 1, 2
ORDER BY 1, 2
""",
[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

View 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

View file

@ -8,7 +8,7 @@ Baseline sample: 5000
| Set | N | Mean | Stddev | p50 | p90 | Min | Max |
|---|---|---|---|---|---|---|---|
| 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)

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |
|---|---|---|---|---|---|---|---|

View 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 |

View 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 |
|---|---|---|---|---|---|---|---|

View file

@ -138,6 +138,51 @@ defmodule Microwaveprop.BacktestTest do
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
test "groups feature values by band and summarizes per band" do
create_contact(%{