diff --git a/lib_ml/propagation_analyze.ex b/lib_ml/propagation_analyze.ex index 8a784bf7..d8e9c961 100644 --- a/lib_ml/propagation_analyze.ex +++ b/lib_ml/propagation_analyze.ex @@ -94,7 +94,7 @@ defmodule Mix.Tasks.PropagationAnalyze do -- Longitude for solar time ((q.pos1->>'lon')::float + (q.pos2->>'lon')::float) / 2.0 AS avg_longitude - FROM qsos q + FROM contacts q INNER JOIN hrrr_profiles h1 ON h1.lat = ROUND((q.pos1->>'lat')::numeric * 8) / 8 @@ -106,7 +106,7 @@ defmodule Mix.Tasks.PropagationAnalyze do AND h2.lon = ROUND((q.pos2->>'lon')::numeric * 8) / 8 AND h2.valid_time = date_trunc('hour', q.qso_timestamp) - LEFT JOIN terrain_profiles tp ON tp.qso_id = q.id + LEFT JOIN terrain_profiles tp ON tp.contact_id = q.id WHERE q.distance_km > 0 AND q.distance_km < 3000 diff --git a/test/mix/tasks/propagation_ml_tasks_test.exs b/test/mix/tasks/propagation_ml_tasks_test.exs new file mode 100644 index 00000000..ac59a492 --- /dev/null +++ b/test/mix/tasks/propagation_ml_tasks_test.exs @@ -0,0 +1,145 @@ +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 "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