prop/lib_ml/propagation_analyze.ex
Graham McIntire 080257f2c5
test: push coverage over 85% via PropagationAnalyze/Train smoke tests
Final coverage round: 84.44% → 86.18% (target was 85%).

Two lib_ml/ tasks were stranded at 0% (1035 combined lines) because
the qsos → contacts table rename left stale refs in
PropagationAnalyze's SQL. Fixed:

- `FROM qsos q` → `FROM contacts q`
- `terrain_profiles tp ON tp.qso_id = q.id` → `tp.contact_id = q.id`

Adds test/mix/tasks/propagation_ml_tasks_test.exs with 4 tests:
- PropagationAnalyze end-to-end against empty DB (walks every section
  header: correlation, binned factor, interaction, close).
- PropagationAnalyze with seeded contact + matching HRRR at both
  endpoints yields a 1-row dataset (exercises derive_averages +
  format_band + median + percentile helpers).
- PropagationAnalyze with a pre-2016-06-30 contact is excluded by the
  WHERE clause.
- PropagationTrain on empty hrrr_profiles raises the expected Nx
  "cannot build empty tensor" error after walking header + load path.

215 → 221 properties, 2812 → 2846 tests, 0 failures.
2026-04-24 10:32:05 -05:00

641 lines
19 KiB
Elixir
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Mix.Tasks.PropagationAnalyze do
@shortdoc "Analyze QSO-HRRR correlations and factor effects on propagation distance"
@moduledoc """
Builds a dataset matching QSOs to HRRR atmospheric conditions at both endpoints,
then runs statistical analysis to identify which factors drive propagation distance.
Analyses performed:
1. Spearman rank correlation per band (10G, 24G, 47G, 75G)
2. Binned factor analysis with median/p25/p75 distances (10G, 24G)
3. Interaction effects for 10 GHz (gradient×time, HPBL×season, ducting)
mix propagation.analyze
"""
use Mix.Task
alias Microwaveprop.Repo
@bands [10_000, 24_000, 47_000, 75_000]
@timeout 300_000
@impl Mix.Task
def run(_args) do
Application.put_env(
:microwaveprop,
Oban,
Keyword.put(
Application.get_env(:microwaveprop, Oban, []),
:queues,
false
)
)
Mix.Task.run("app.start")
"=" |> String.duplicate(80) |> IO.puts()
IO.puts("PROPAGATION ANALYSIS: QSO-HRRR Correlation Study")
"=" |> String.duplicate(80) |> IO.puts()
IO.puts("Started: #{DateTime.to_string(DateTime.utc_now())}")
IO.puts("")
dataset = build_dataset()
IO.puts("Dataset: #{length(dataset)} matched QSO-HRRR rows\n")
print_dataset_summary(dataset)
run_correlation_analysis(dataset)
run_factor_analysis(dataset)
run_interaction_analysis(dataset)
IO.puts("\n" <> String.duplicate("=", 80))
IO.puts("Analysis complete: #{DateTime.to_string(DateTime.utc_now())}")
end
# ---------------------------------------------------------------------------
# Dataset builder
# ---------------------------------------------------------------------------
defp build_dataset do
IO.puts("Building dataset (joining QSOs × HRRR at both endpoints)...")
sql = """
SELECT
q.id AS qso_id,
q.band::float AS band,
q.distance_km::float AS distance_km,
q.qso_timestamp,
EXTRACT(MONTH FROM q.qso_timestamp)::int AS month,
EXTRACT(HOUR FROM q.qso_timestamp)::int AS utc_hour,
-- Endpoint 1 atmospheric
h1.surface_temp_c AS h1_temp,
h1.surface_dewpoint_c AS h1_dewpoint,
h1.surface_pressure_mb AS h1_pressure,
h1.surface_refractivity AS h1_refractivity,
h1.min_refractivity_gradient AS h1_gradient,
h1.hpbl_m AS h1_hpbl,
h1.pwat_mm AS h1_pwat,
h1.ducting_detected AS h1_ducting,
-- Endpoint 2 atmospheric
h2.surface_temp_c AS h2_temp,
h2.surface_dewpoint_c AS h2_dewpoint,
h2.surface_pressure_mb AS h2_pressure,
h2.surface_refractivity AS h2_refractivity,
h2.min_refractivity_gradient AS h2_gradient,
h2.hpbl_m AS h2_hpbl,
h2.pwat_mm AS h2_pwat,
h2.ducting_detected AS h2_ducting,
-- Terrain
tp.verdict AS terrain_verdict,
-- Longitude for solar time
((q.pos1->>'lon')::float + (q.pos2->>'lon')::float) / 2.0 AS avg_longitude
FROM contacts q
INNER JOIN hrrr_profiles h1
ON h1.lat = ROUND((q.pos1->>'lat')::numeric * 8) / 8
AND h1.lon = ROUND((q.pos1->>'lon')::numeric * 8) / 8
AND h1.valid_time = date_trunc('hour', q.qso_timestamp)
INNER JOIN hrrr_profiles h2
ON h2.lat = ROUND((q.pos2->>'lat')::numeric * 8) / 8
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.contact_id = q.id
WHERE q.distance_km > 0
AND q.distance_km < 3000
AND q.qso_timestamp >= '2016-06-30'
AND q.pos1 IS NOT NULL
AND q.pos2 IS NOT NULL
"""
%{rows: rows, columns: columns} = Repo.query!(sql, [], timeout: @timeout)
Enum.map(rows, fn row ->
columns
|> Enum.zip(row)
|> Map.new(fn {col, val} -> {String.to_atom(col), val} end)
|> derive_averages()
end)
end
defp derive_averages(row) do
row
|> Map.put(:avg_temp, safe_avg(row[:h1_temp], row[:h2_temp]))
|> Map.put(:avg_dewpoint, safe_avg(row[:h1_dewpoint], row[:h2_dewpoint]))
|> Map.put(:avg_pressure, safe_avg(row[:h1_pressure], row[:h2_pressure]))
|> Map.put(:avg_refractivity, safe_avg(row[:h1_refractivity], row[:h2_refractivity]))
|> Map.put(:avg_gradient, safe_avg(row[:h1_gradient], row[:h2_gradient]))
|> Map.put(:avg_hpbl, safe_avg(row[:h1_hpbl], row[:h2_hpbl]))
|> Map.put(:avg_pwat, safe_avg(row[:h1_pwat], row[:h2_pwat]))
|> Map.put(:either_ducting, row[:h1_ducting] == true or row[:h2_ducting] == true)
|> derive_solar_hour()
end
defp derive_solar_hour(row) do
case {row[:utc_hour], row[:avg_longitude]} do
{h, lng} when is_number(h) and is_number(lng) ->
solar = :math.fmod(h + lng / 15 + 24, 24)
Map.put(row, :solar_hour, solar)
_ ->
Map.put(row, :solar_hour, nil)
end
end
defp safe_avg(nil, nil), do: nil
defp safe_avg(nil, b), do: b
defp safe_avg(a, nil), do: a
defp safe_avg(a, b), do: (a + b) / 2.0
# ---------------------------------------------------------------------------
# Dataset summary
# ---------------------------------------------------------------------------
defp print_dataset_summary(dataset) do
IO.puts(String.duplicate("-", 80))
IO.puts("DATASET SUMMARY")
IO.puts(String.duplicate("-", 80))
by_band =
dataset
|> Enum.group_by(& &1.band)
|> Enum.sort_by(fn {band, _} -> band end)
IO.puts("\nQSOs per band:")
for {band, rows} <- by_band do
distances = Enum.map(rows, & &1.distance_km)
IO.puts(
" #{format_band(band)}: n=#{length(rows)}, median=#{distances |> median() |> format_num(1)} km, " <>
"p25=#{distances |> percentile(25) |> format_num(1)} km, p75=#{distances |> percentile(75) |> format_num(1)} km"
)
end
terrain_counts =
dataset
|> Enum.frequencies_by(& &1.terrain_verdict)
|> Enum.sort_by(fn {_, count} -> -count end)
IO.puts("\nTerrain verdicts:")
for {verdict, count} <- terrain_counts do
IO.puts(" #{verdict || "NULL"}: #{count}")
end
IO.puts("")
end
# ---------------------------------------------------------------------------
# Spearman rank correlation
# ---------------------------------------------------------------------------
defp run_correlation_analysis(dataset) do
IO.puts(String.duplicate("-", 80))
IO.puts("SPEARMAN RANK CORRELATION WITH DISTANCE (per band)")
IO.puts(String.duplicate("-", 80))
variables = [
{:avg_temp, "Temperature (°C)"},
{:avg_dewpoint, "Dewpoint (°C)"},
{:avg_pressure, "Pressure (mb)"},
{:avg_refractivity, "Surface Refractivity"},
{:avg_gradient, "Refractivity Gradient"},
{:avg_hpbl, "HPBL (m)"},
{:avg_pwat, "PWAT (mm)"},
{:month, "Month"},
{:utc_hour, "UTC Hour"},
{:solar_hour, "Solar Hour"}
]
for band <- @bands do
band_data = Enum.filter(dataset, &(trunc(&1.band) == band))
IO.puts("\n#{format_band(band)} (n=#{length(band_data)}):")
IO.puts(
" #{String.pad_trailing("Variable", 28)} #{String.pad_leading("rho", 8)} #{String.pad_leading("n_valid", 8)}"
)
IO.puts(" " <> String.duplicate("-", 46))
correlations =
variables
|> Enum.map(fn {key, label} ->
pairs =
band_data
|> Enum.filter(&(&1[key] != nil))
|> Enum.map(&{to_float(&1[key]), to_float(&1.distance_km)})
rho = spearman(pairs)
{label, rho, length(pairs)}
end)
|> Enum.sort_by(fn {_, rho, _} -> -abs(rho || 0) end)
_ =
for {label, rho, n} <- correlations do
rho_str = if rho, do: format_num(rho, 4), else: "N/A"
IO.puts(
" #{String.pad_trailing(label, 28)} #{String.pad_leading(rho_str, 8)} #{String.pad_leading(Integer.to_string(n), 8)}"
)
end
end
IO.puts("")
end
# ---------------------------------------------------------------------------
# Binned factor analysis
# ---------------------------------------------------------------------------
defp run_factor_analysis(dataset) do
IO.puts(String.duplicate("-", 80))
IO.puts("BINNED FACTOR ANALYSIS (median distance per bin)")
IO.puts(String.duplicate("-", 80))
factor_configs = [
{:avg_gradient, "Refractivity Gradient",
[
{nil, -500, "< -500 (strong)"},
{-500, -300, "-500 to -300 (moderate)"},
{-300, -200, "-300 to -200 (enhanced)"},
{-200, -100, "-200 to -100 (mild)"},
{-100, 0, "-100 to 0 (normal)"},
{0, nil, "> 0"}
]},
{:avg_temp, "Temperature (°C)",
[
{nil, 0, "< 0"},
{0, 10, "0-10"},
{10, 20, "10-20"},
{20, 30, "20-30"},
{30, nil, "> 30"}
]},
{:avg_hpbl, "HPBL (m)",
[
{nil, 200, "< 200"},
{200, 500, "200-500"},
{500, 1000, "500-1000"},
{1000, 2000, "1000-2000"},
{2000, nil, "> 2000"}
]},
{:avg_pressure, "Pressure (mb)",
[
{nil, 1005, "< 1005"},
{1005, 1013, "1005-1013"},
{1013, 1020, "1013-1020"},
{1020, nil, "> 1020"}
]},
{:avg_pwat, "PWAT (mm)",
[
{nil, 10, "< 10"},
{10, 20, "10-20"},
{20, 30, "20-30"},
{30, 40, "30-40"},
{40, nil, "> 40"}
]},
{:utc_hour, "UTC Hour",
[
{0, 3, "00-02 UTC"},
{3, 6, "03-05 UTC"},
{6, 9, "06-08 UTC"},
{9, 12, "09-11 UTC"},
{12, 15, "12-14 UTC"},
{15, 18, "15-17 UTC"},
{18, 21, "18-20 UTC"},
{21, 24, "21-23 UTC"}
]},
{:solar_hour, "Solar Hour",
[
{0, 3, "00-02 Solar (night)"},
{3, 6, "03-05 Solar (pre-dawn)"},
{6, 9, "06-08 Solar (dawn)"},
{9, 12, "09-11 Solar (morning)"},
{12, 15, "12-14 Solar (midday)"},
{15, 18, "15-17 Solar (afternoon)"},
{18, 21, "18-20 Solar (evening)"},
{21, 24, "21-23 Solar (night)"}
]},
{:month, "Month",
[
{1, 2, "Jan"},
{2, 3, "Feb"},
{3, 4, "Mar"},
{4, 5, "Apr"},
{5, 6, "May"},
{6, 7, "Jun"},
{7, 8, "Jul"},
{8, 9, "Aug"},
{9, 10, "Sep"},
{10, 11, "Oct"},
{11, 12, "Nov"},
{12, 13, "Dec"}
]}
]
terrain_config =
{:terrain_verdict, "Terrain Verdict",
[
{:eq, "CLEAR", "CLEAR"},
{:eq, "FRESNEL_PARTIAL", "FRESNEL_PARTIAL"},
{:eq, "BLOCKED", "BLOCKED"}
]}
for band <- [10_000, 24_000] do
band_data = Enum.filter(dataset, &(trunc(&1.band) == band))
IO.puts("\n#{format_band(band)} (n=#{length(band_data)}):")
_ =
for {key, label, bins} <- factor_configs do
_ = print_binned_factor(band_data, key, label, bins)
end
_ = print_categorical_factor(band_data, terrain_config)
end
IO.puts("")
end
defp print_binned_factor(data, key, label, bins) do
IO.puts("\n #{label}:")
IO.puts(
" #{String.pad_trailing("Bin", 28)} #{String.pad_leading("n", 6)} #{String.pad_leading("median", 8)} #{String.pad_leading("p25", 8)} #{String.pad_leading("p75", 8)}"
)
IO.puts(" " <> String.duplicate("-", 60))
for {low, high, bin_label} <- bins do
rows =
Enum.filter(data, fn row ->
val = row[key]
val != nil and in_bin?(val, low, high)
end)
print_bin_row(bin_label, rows)
end
end
defp print_categorical_factor(data, {key, label, bins}) do
IO.puts("\n #{label}:")
IO.puts(
" #{String.pad_trailing("Bin", 28)} #{String.pad_leading("n", 6)} #{String.pad_leading("median", 8)} #{String.pad_leading("p25", 8)} #{String.pad_leading("p75", 8)}"
)
IO.puts(" " <> String.duplicate("-", 60))
for {:eq, match_val, bin_label} <- bins do
rows = Enum.filter(data, &(&1[key] == match_val))
print_bin_row(bin_label, rows)
end
end
defp print_bin_row(bin_label, rows) do
if rows == [] do
IO.puts(
" #{String.pad_trailing(bin_label, 28)} #{String.pad_leading("0", 6)} #{String.pad_leading("-", 8)} #{String.pad_leading("-", 8)} #{String.pad_leading("-", 8)}"
)
else
distances = Enum.map(rows, & &1.distance_km)
IO.puts(
" #{String.pad_trailing(bin_label, 28)} " <>
"#{String.pad_leading(Integer.to_string(length(rows)), 6)} " <>
"#{String.pad_leading(format_num(median(distances), 1), 8)} " <>
"#{String.pad_leading(format_num(percentile(distances, 25), 1), 8)} " <>
"#{String.pad_leading(format_num(percentile(distances, 75), 1), 8)}"
)
end
end
defp in_bin?(val, nil, high), do: val < high
defp in_bin?(val, low, nil), do: val >= low
defp in_bin?(val, low, high), do: val >= low and val < high
# ---------------------------------------------------------------------------
# Interaction effects (10 GHz only)
# ---------------------------------------------------------------------------
defp run_interaction_analysis(dataset) do
IO.puts(String.duplicate("-", 80))
IO.puts("INTERACTION EFFECTS (10 GHz)")
IO.puts(String.duplicate("-", 80))
data_10g = Enum.filter(dataset, &(trunc(&1.band) == 10_000))
IO.puts("\nDataset: #{length(data_10g)} QSOs at 10 GHz\n")
# Gradient × Time of day
IO.puts("1. Refractivity Gradient × Time of Day")
IO.puts(" (strong gradient = avg < -100, weak = avg >= -100)")
IO.puts("")
time_blocks = [
{0, 6, "00-05 UTC (night)"},
{6, 12, "06-11 UTC (morning)"},
{12, 18, "12-17 UTC (afternoon)"},
{18, 24, "18-23 UTC (evening)"}
]
IO.puts(
" #{String.pad_trailing("Time Block", 24)} #{String.pad_trailing("Gradient", 10)} #{String.pad_leading("n", 6)} #{String.pad_leading("median km", 10)}"
)
IO.puts(" " <> String.duplicate("-", 54))
for {t_low, t_high, t_label} <- time_blocks, gradient_class <- ["strong", "weak"] do
rows =
Enum.filter(data_10g, fn row ->
row.utc_hour >= t_low and row.utc_hour < t_high and
row.avg_gradient != nil and
if gradient_class == "strong",
do: row.avg_gradient < -100,
else: row.avg_gradient >= -100
end)
n = length(rows)
med = if n > 0, do: format_num(median(Enum.map(rows, & &1.distance_km)), 1), else: "-"
IO.puts(
" #{String.pad_trailing(t_label, 24)} #{String.pad_trailing(gradient_class, 10)} #{String.pad_leading(Integer.to_string(n), 6)} #{String.pad_leading(med, 10)}"
)
end
# HPBL × Season
IO.puts("\n2. HPBL × Season")
IO.puts(" (shallow = avg < 500m, mid = 500-999m, deep = avg >= 1000m)")
IO.puts("")
seasons = [
{[12, 1, 2], "Winter (Dec-Feb)"},
{[3, 4, 5], "Spring (Mar-May)"},
{[6, 7, 8], "Summer (Jun-Aug)"},
{[9, 10, 11], "Fall (Sep-Nov)"}
]
hpbl_classes = [
{"shallow", fn h -> h < 500 end},
{"mid", fn h -> h >= 500 and h < 1000 end},
{"deep", fn h -> h >= 1000 end}
]
IO.puts(
" #{String.pad_trailing("Season", 22)} #{String.pad_trailing("HPBL", 10)} #{String.pad_leading("n", 6)} #{String.pad_leading("median km", 10)}"
)
IO.puts(" " <> String.duplicate("-", 52))
for {months, s_label} <- seasons, {h_label, h_filter} <- hpbl_classes do
rows =
Enum.filter(data_10g, fn row ->
row.month in months and row.avg_hpbl != nil and h_filter.(row.avg_hpbl)
end)
n = length(rows)
med = if n > 0, do: format_num(median(Enum.map(rows, & &1.distance_km)), 1), else: "-"
IO.puts(
" #{String.pad_trailing(s_label, 22)} #{String.pad_trailing(h_label, 10)} #{String.pad_leading(Integer.to_string(n), 6)} #{String.pad_leading(med, 10)}"
)
end
# Ducting detection vs distance
IO.puts("\n3. Ducting Detection vs Distance")
IO.puts("")
for ducting_val <- [true, false] do
rows = Enum.filter(data_10g, &(&1.either_ducting == ducting_val))
n = length(rows)
if n > 0 do
distances = Enum.map(rows, & &1.distance_km)
IO.puts(
" Ducting #{if ducting_val, do: "YES", else: "NO "}: n=#{n}, " <>
"median=#{format_num(median(distances), 1)} km, " <>
"p25=#{format_num(percentile(distances, 25), 1)} km, " <>
"p75=#{format_num(percentile(distances, 75), 1)} km, " <>
"max=#{format_num(Enum.max(distances), 1)} km"
)
else
IO.puts(" Ducting #{if ducting_val, do: "YES", else: "NO "}: n=0")
end
end
IO.puts("")
end
# ---------------------------------------------------------------------------
# Statistics helpers
# ---------------------------------------------------------------------------
defp spearman(pairs) when length(pairs) < 3, do: nil
defp spearman(pairs) do
n = length(pairs)
x_vals = Enum.map(pairs, &elem(&1, 0))
y_vals = Enum.map(pairs, &elem(&1, 1))
x_ranks = rank(x_vals)
y_ranks = rank(y_vals)
d_squared =
x_ranks
|> Enum.zip(y_ranks)
|> Enum.map(fn {rx, ry} ->
d = rx - ry
d * d
end)
|> Enum.sum()
# Standard Spearman formula: 1 - (6 * sum(d^2)) / (n * (n^2 - 1))
1.0 - 6.0 * d_squared / (n * (n * n - 1))
end
defp rank(values) do
indexed =
values
|> Enum.with_index()
|> Enum.sort_by(&elem(&1, 0))
# Assign average ranks for ties
ranked =
indexed
|> Enum.chunk_by(&elem(&1, 0))
|> Enum.flat_map(fn group ->
start_rank = length(ranked_before(indexed, group))
avg_rank = start_rank + (length(group) - 1) / 2.0 + 1
Enum.map(group, fn {_val, orig_idx} -> {orig_idx, avg_rank} end)
end)
ranked
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(&elem(&1, 1))
end
defp ranked_before(all_sorted, group) do
first_val = group |> hd() |> elem(0)
Enum.take_while(all_sorted, fn {v, _} -> v < first_val end)
end
defp median([]), do: 0.0
defp median(values) do
sorted = Enum.sort(values)
n = length(sorted)
mid = div(n, 2)
if rem(n, 2) == 0 do
(Enum.at(sorted, mid - 1) + Enum.at(sorted, mid)) / 2.0
else
Enum.at(sorted, mid) / 1.0
end
end
defp percentile([], _p), do: 0.0
defp percentile(values, p) do
sorted = Enum.sort(values)
n = length(sorted)
k = p / 100.0 * (n - 1)
f = trunc(k)
c = k - f
if f + 1 < n do
Enum.at(sorted, f) * (1 - c) + Enum.at(sorted, f + 1) * c
else
Enum.at(sorted, f) / 1.0
end
end
defp to_float(val) when is_float(val), do: val
defp to_float(val) when is_integer(val), do: val / 1.0
defp to_float(%Decimal{} = val), do: Decimal.to_float(val)
defp to_float(val), do: val
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
defp format_band(band) when is_float(band), do: format_band(trunc(band))
defp format_band(10_000), do: "10 GHz"
defp format_band(24_000), do: "24 GHz"
defp format_band(47_000), do: "47 GHz"
defp format_band(75_000), do: "75 GHz"
defp format_band(band), do: "#{band} MHz"
defp format_num(nil, _decimals), do: "N/A"
defp format_num(val, decimals), do: :erlang.float_to_binary(val / 1.0, decimals: decimals)
end