prop/lib/mix/tasks/backtest.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

139 lines
4.4 KiB
Elixir

defmodule Mix.Tasks.Backtest do
@shortdoc "Evaluate a propagation feature against the QSO corpus"
@moduledoc """
Runs `Microwaveprop.Backtest.evaluate/2` (plus the distance and band
breakdowns) for a named feature function and prints a Markdown report
to stdout.
## Usage
mix backtest --feature naive_gradient
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` — 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.
"""
use Mix.Task
alias Microwaveprop.Backtest
alias Microwaveprop.Backtest.Features
@impl Mix.Task
def run(argv) do
Mix.Task.run("app.start")
_ = Oban.pause_all_queues(Oban)
{opts, _, _} =
OptionParser.parse(argv,
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 = 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)
out_path = Keyword.get(opts, :out)
{feature_fun, feature_name} = resolve_feature(feature_spec)
report =
Backtest.evaluate(feature_fun,
sample_size: sample_size,
baseline_size: baseline_size,
feature_name: feature_name
)
distance_bins = Backtest.lift_by_distance(feature_fun, sample_size: sample_size)
band_stats = Backtest.lift_by_band(feature_fun, sample_size: sample_size)
markdown =
Backtest.to_markdown(report, distance_bins: distance_bins, band_stats: band_stats)
IO.puts(markdown)
if out_path do
File.mkdir_p!(Path.dirname(out_path))
File.write!(out_path, markdown)
Mix.shell().info("Wrote report to #{out_path}")
end
end
defp resolve_feature(spec) do
case String.split(spec, ".") do
[name] ->
fun = resolve_function_atom!(Features, name)
feature_fun = &apply(Features, fun, [&1, &2, &3])
{feature_fun, "Microwaveprop.Backtest.Features.#{fun}"}
parts ->
{fun_name, mod_parts} = List.pop_at(parts, -1)
module = Module.concat(mod_parts)
fun = resolve_function_atom!(module, fun_name)
feature_fun = &apply(module, fun, [&1, &2, &3])
{feature_fun, "#{inspect(module)}.#{fun}"}
end
end
# Accept both `naive_gradient` and `NaiveGradient` and anything in between.
defp resolve_function_atom!(module, name) do
Code.ensure_loaded!(module)
normalized = Macro.underscore(name)
fun = String.to_atom(normalized)
if function_exported?(module, fun, 3) do
fun
else
exported =
:functions
|> module.__info__()
|> Enum.filter(fn {_f, arity} -> arity == 3 end)
|> Enum.map_join(", ", fn {f, _} -> to_string(f) end)
Mix.raise("Feature #{inspect(module)}.#{normalized}/3 is not defined.\nAvailable 3-arity functions: #{exported}")
end
end
end