defmodule Mix.Tasks.PropagationMlTasksTest do @moduledoc """ Coverage smoke tests for the two lib_ml/ mix tasks. Both tasks boot the full application via `Mix.Task.run("app.start")` and mutate the `:microwaveprop, Oban` env to disable queues. Those side effects are fine under `async: false` because the already- started test Oban stays on its testing: :inline wiring. """ use Microwaveprop.DataCase, async: false alias Microwaveprop.Radio.Contact alias Microwaveprop.Weather.HrrrProfile alias Mix.Tasks.PropagationAnalyze alias Mix.Tasks.PropagationTrain setup do original_shell = Mix.shell() Mix.shell(Mix.Shell.Process) on_exit(fn -> Mix.shell(original_shell) end) :ok end describe "Mix.Tasks.PropagationAnalyze.run/1" do test "runs end-to-end on an empty DB and prints the main section headers" do output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end) # Walks the whole run/1 path: header + dataset summary + per-band # correlation loop + factor binning + interaction analysis + closer. assert output =~ "PROPAGATION ANALYSIS" assert output =~ "Dataset: 0 matched" assert output =~ "SPEARMAN RANK CORRELATION" assert output =~ "BINNED FACTOR ANALYSIS" assert output =~ "Analysis complete" end test "seeded contact + matching HRRR at both endpoints yields a non-empty dataset" do # Both hrrr_profiles rows must land on the exact 1/8° grid cell # that the SQL rounds pos1 / pos2 to — otherwise the INNER JOINs # leave the dataset empty and the test devolves into the empty-DB # path. Grid step is `ROUND((pos->>'lat')::numeric * 8) / 8`. valid_time = ~U[2020-06-15 18:00:00Z] {:ok, _c} = %Contact{} |> Contact.changeset(%{ station1: "W5XD", station2: "K5TR", qso_timestamp: valid_time, mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 33.0, "lon" => -97.0}, pos2: %{"lat" => 30.5, "lon" => -97.5}, distance_km: Decimal.new("295") }) |> Repo.insert() {:ok, _h1} = %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: valid_time, lat: 33.0, lon: -97.0, surface_temp_c: 22.0, surface_dewpoint_c: 14.0, surface_pressure_mb: 1012.0, surface_refractivity: 320.0, min_refractivity_gradient: -100.0, hpbl_m: 1200.0, pwat_mm: 25.0, ducting_detected: false }) |> Repo.insert() {:ok, _h2} = %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: valid_time, lat: 30.5, lon: -97.5, surface_temp_c: 21.0, surface_dewpoint_c: 13.0, surface_pressure_mb: 1011.0, surface_refractivity: 318.0, min_refractivity_gradient: -90.0, hpbl_m: 1100.0, pwat_mm: 24.0, ducting_detected: false }) |> Repo.insert() output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end) # Non-empty dataset row count + the matching per-band summary # line exercising format_band + median + percentile helpers. assert output =~ "Dataset: 1 matched" assert output =~ "10 GHz:" end test "many contacts at varied bands exercises spearman + rank + percentile" do # Seed N contacts each with a matching HRRR profile at both # endpoints. PropagationAnalyze joins h1 AND h2 separately, so # both pos1 and pos2 need a row at the snapped 1/8° grid. base_time = ~U[2020-06-15 18:00:00Z] for i <- 1..6 do valid_time = base_time |> DateTime.add(i * 3600, :second) |> DateTime.truncate(:second) # Distinct snapped grid points for each i. lat1 = 32.0 + i * 0.125 lon1 = -97.0 - i * 0.125 lat2 = 31.5 + i * 0.125 lon2 = -98.0 - i * 0.125 {:ok, _} = %Contact{} |> Contact.changeset(%{ station1: "W#{i}A", station2: "K#{i}B", qso_timestamp: valid_time, mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => lat1, "lon" => lon1}, pos2: %{"lat" => lat2, "lon" => lon2}, distance_km: Decimal.new("#{100 + i * 20}") }) |> Repo.insert() for {plat, plon} <- [{lat1, lon1}, {lat2, lon2}] do {:ok, _} = %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: valid_time, lat: plat, lon: plon, surface_temp_c: 20.0 + i, surface_dewpoint_c: 12.0 + i, surface_pressure_mb: 1010.0 + i, surface_refractivity: 320.0 - i, min_refractivity_gradient: -100.0 + i * 5, hpbl_m: 1000.0 + i * 100, pwat_mm: 25.0 + i, ducting_detected: rem(i, 2) == 0 }) |> Repo.insert() end end output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end) assert output =~ "Dataset: 6 matched" assert output =~ "10 GHz" assert output =~ "SPEARMAN" end test "pre-cutoff contact is excluded by the 2016-06-30 WHERE clause" do # The SQL's `q.qso_timestamp >= '2016-06-30'` gate drops any # earlier contact outright. Using 2018-01 keeps HRRR partitioning # happy while still testing the filter logic (contact + HRRR rows # both exist and JOIN, so the absence of a match is in the # filter, not the data). valid_time = ~U[2016-06-29 12:00:00Z] {:ok, _c} = %Contact{} |> Contact.changeset(%{ station1: "W5OLD", station2: "K5TR", qso_timestamp: valid_time, mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 33.0, "lon" => -97.0}, pos2: %{"lat" => 30.5, "lon" => -97.5}, distance_km: Decimal.new("295") }) |> Repo.insert() output = ExUnit.CaptureIO.capture_io(fn -> PropagationAnalyze.run([]) end) assert output =~ "Dataset: 0 matched" end end describe "Mix.Tasks.PropagationTrain.run/1" do test "empty hrrr_profiles raises a clear Nx error, exercising load + header" do # With zero rows Nx.tensor([]) raises `cannot build empty tensor`. # The run/1 path up through load_training_data walks the header # printer, sampling query, and encode_profile_rows wiring before # the raise. That covers the printable header helpers + band # iteration even though the task ultimately fails. assert_raise RuntimeError, ~r/empty tensor/, fn -> ExUnit.CaptureIO.capture_io(fn -> PropagationTrain.run(["--samples-per-month", "1", "--epochs", "1"]) end) end end end end