57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'. Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback — which just caused a SQL widget to silently miscount 98% of contacts (count_narr_done used `pos1->>'lon'` directly, no fallback, so every lng-keyed row returned NULL and failed the coverage check). - Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to 'lon' and dropping 'lng'. - Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers, scorer, weather, radio.ex, contact show view, and recalibrator. - lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly (was reading 'lng' only, which would have broken after migration). - priv/repo/import_contacts.exs one-time seed script now emits 'lon' with string keys, matching production shape. - Test fixtures in 4 test files normalized to 'lon'. - Two lng-characterization tests deleted — nonsensical post-normalize. - Updated notebook + old import_weather script to match. - JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
639 lines
19 KiB
Elixir
639 lines
19 KiB
Elixir
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 qsos 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.qso_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
|