prop/lib_ml/propagation_train.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

394 lines
13 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.PropagationTrain do
@shortdoc "Train propagation ML model from month-balanced HRRR + algorithm scores"
@moduledoc """
Trains the propagation Nx/Axon model to predict the physical algorithm
score from weather conditions alone.
## Why not use QSO outcomes as the target?
An earlier version of this task fine-tuned on "within-band distance
percentile" from the `contacts` table. That target is structurally
biased: non-contest months (Feb, Mar, Nov) have almost no QSOs, so the
model learned "these conditions → QSO happened → good" and implicitly
"no QSO happened → bad." The result was a model that systematically
under-predicted propagation during months when nobody is on the air,
which is exactly the wrong thing for a forecast.
This task instead:
1. Samples `hrrr_profiles` stratified *uniformly by calendar month*
— every month contributes the same number of rows regardless of
contest-driven density. Contest months still exist in the data,
they just don't drown out the quiet months.
2. Joins each sample to `solar_indices` (same date) and the nearest
`soundings` row (±6 h) for K-index / lifted-index features.
3. Computes the target via `Scorer.composite_score/2` — the *same*
physical scoring algorithm that `PropagationGridWorker` uses in
production. Sky / wind / rain fall back to neutral defaults
because `hrrr_profiles` doesn't store them; the model learns the
weather-only residual and the missing factors remain constant.
4. Trains on every band for every sampled profile, producing one
training row per (profile, band) pair.
## Usage
mix propagation_train
mix propagation_train --samples-per-month 20000 --epochs 100
"""
use Mix.Task
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Model
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo
@query_timeout 600_000
@impl Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
strict: [
samples_per_month: :integer,
epochs: :integer,
batch_size: :integer,
learning_rate: :float
]
)
# Boot the app without starting Oban queues so training doesn't have
# the HRRR / NARR / weather cron firing in the background against the
# local DB we're reading from.
Application.put_env(
:microwaveprop,
Oban,
Keyword.put(Application.get_env(:microwaveprop, Oban, []), :queues, false)
)
Mix.Task.run("app.start")
samples_per_month = Keyword.get(opts, :samples_per_month, 20_000)
epochs = Keyword.get(opts, :epochs, 100)
batch_size = Keyword.get(opts, :batch_size, 256)
learning_rate = Keyword.get(opts, :learning_rate, 0.001)
print_header(samples_per_month, epochs, batch_size, learning_rate)
{features, targets, month_counts} = load_training_data(samples_per_month)
n = elem(Nx.shape(features), 0)
IO.puts("Loaded #{n} training rows (one per profile × band).")
print_month_counts(month_counts)
{features, targets} = shuffle(features, targets, n)
{train_x, train_y, val_x, val_y, test_x, test_y} = split(features, targets, n)
IO.puts("\nTraining for #{epochs} epochs (lr=#{learning_rate}, batch=#{batch_size})...")
{trained_state, metrics} =
Model.train(train_x, train_y,
epochs: epochs,
batch_size: batch_size,
learning_rate: learning_rate
)
print_loss(metrics.final_loss)
print_eval("Validation", Model.evaluate(trained_state, val_x, val_y))
print_eval("Test", Model.evaluate(trained_state, test_x, test_y))
IO.puts("\nSaving model to priv/models/propagation_v1.nx...")
Model.save(trained_state)
IO.puts("Done.")
_ = check_monthly_bias(trained_state)
"-" |> String.duplicate(70) |> IO.puts()
IO.puts("Finished: #{DateTime.to_string(DateTime.utc_now())}")
end
# ── Data loading ────────────────────────────────────────────────────
# Returns {features_tensor, targets_tensor, %{month => count}}.
# Stratifies by calendar month so every month contributes `per_month`
# profile rows, then explodes each profile into one row per band.
defp load_training_data(per_month) do
IO.puts("Sampling hrrr_profiles stratified by month (#{per_month}/month)...")
# The stratification is the whole point: random_row_in_month ranks each
# profile within its (year, month) bucket by a random key, then we keep
# the first `per_month` rows per month across all years. This is much
# cheaper than ORDER BY random() over the whole 43M-row table because
# the window function can stream.
sql = """
WITH ranked AS (
SELECT
h.valid_time,
h.lat,
h.lon,
h.surface_temp_c,
h.surface_dewpoint_c,
h.surface_pressure_mb,
h.min_refractivity_gradient,
h.hpbl_m,
h.pwat_mm,
h.surface_refractivity,
h.ducting_detected,
extract(month from h.valid_time)::int AS month,
extract(hour from h.valid_time)::int AS utc_hour,
ROW_NUMBER() OVER (
PARTITION BY extract(month from h.valid_time)::int
ORDER BY random()
) AS rn
FROM hrrr_profiles h
WHERE h.surface_temp_c IS NOT NULL
AND h.surface_dewpoint_c IS NOT NULL
AND h.surface_pressure_mb IS NOT NULL
)
SELECT
r.valid_time, r.lat, r.lon,
r.surface_temp_c, r.surface_dewpoint_c, r.surface_pressure_mb,
r.min_refractivity_gradient, r.hpbl_m, r.pwat_mm,
r.surface_refractivity, r.ducting_detected,
r.month, r.utc_hour,
si.sfi,
COALESCE((SELECT MAX(v) FROM unnest(si.kp_values) AS v), 2.0) AS kp_max,
snd.k_index,
snd.lifted_index
FROM ranked r
LEFT JOIN solar_indices si ON si.date = r.valid_time::date
LEFT JOIN LATERAL (
SELECT s.k_index, s.lifted_index
FROM soundings s
WHERE s.observed_at BETWEEN r.valid_time - interval '6 hours'
AND r.valid_time + interval '6 hours'
ORDER BY ABS(EXTRACT(EPOCH FROM s.observed_at - r.valid_time))
LIMIT 1
) snd ON true
WHERE r.rn <= $1
"""
%{rows: rows} = Repo.query!(sql, [per_month], timeout: @query_timeout)
IO.puts(" #{length(rows)} profile rows after stratified sample")
month_counts = Enum.frequencies_by(rows, fn row -> Enum.at(row, 11) end)
bands = BandConfig.all_freqs()
# Each profile row turns into one training row per band.
{feature_rows, target_rows} =
rows
|> Enum.flat_map(fn row -> encode_profile_rows(row, bands) end)
|> Enum.unzip()
{Nx.tensor(feature_rows, type: :f32), Nx.tensor(target_rows, type: :f32), month_counts}
end
# Builds one training row per (profile, band). Target is the algorithm
# composite score normalized to [0, 1]; features are the ML model's
# 20-element encoding of the same conditions.
defp encode_profile_rows(row, bands) do
[
_valid_time,
lat,
lon,
temp_c,
dewpoint_c,
pressure_mb,
grad,
hpbl_m,
pwat_mm,
refractivity,
ducting,
month,
utc_hour,
sfi,
kp_max,
k_index,
lifted_index
] = row
temp_c = to_float(temp_c)
dewpoint_c = to_float(dewpoint_c)
pressure_mb = to_float(pressure_mb)
grad = to_float(grad)
hpbl_m = to_float(hpbl_m)
pwat_mm = to_float(pwat_mm)
refractivity = to_float(refractivity)
lat_f = to_float(lat)
lon_f = to_float(lon)
sfi_f = to_float(sfi)
kp_f = to_float(kp_max)
k_idx_f = to_float(k_index)
li_f = to_float(lifted_index)
scorer_conditions =
build_scorer_conditions(
temp_c,
dewpoint_c,
pressure_mb,
grad,
hpbl_m,
pwat_mm,
lat_f,
lon_f,
trunc(to_float(month)),
trunc(to_float(utc_hour))
)
Enum.map(bands, fn freq_mhz ->
band_config = BandConfig.get(freq_mhz)
%{score: score} = Scorer.composite_score(scorer_conditions, band_config)
features =
Model.encode_features(%{
surface_temp_c: temp_c,
surface_dewpoint_c: dewpoint_c,
surface_pressure_mb: pressure_mb,
min_refractivity_gradient: grad,
hpbl_m: hpbl_m,
pwat_mm: pwat_mm,
surface_refractivity: refractivity,
latitude: lat_f,
sfi: sfi_f,
kp_max: kp_f,
ducting_detected: ducting,
k_index: k_idx_f,
lifted_index: li_f,
utc_hour: to_float(utc_hour),
longitude: lon_f,
month: trunc(to_float(month)),
freq_mhz: freq_mhz
})
{features, [score / 100.0]}
end)
end
# Builds a conditions map matching the shape Scorer.composite_score/2
# expects. Sky / wind / rain default to nil → Scorer uses neutral
# values (50, 50, 100). The ML model's feature set also omits those
# channels, so the missing-factor contribution is constant across all
# samples and the model learns the weather-derived residual cleanly.
defp build_scorer_conditions(temp_c, dewpoint_c, pressure_mb, grad, hpbl_m, pwat_mm, lat, lon, month, utc_hour) do
%{
abs_humidity: Scorer.absolute_humidity(temp_c, dewpoint_c),
temp_f: Scorer.c_to_f(temp_c),
dewpoint_f: Scorer.c_to_f(dewpoint_c),
pressure_mb: pressure_mb,
prev_pressure_mb: nil,
min_refractivity_gradient: grad,
bl_depth_m: hpbl_m,
pwat_mm: pwat_mm,
sky_cover_pct: nil,
wind_speed_kts: nil,
rain_rate_mmhr: 0.0,
utc_hour: utc_hour,
utc_minute: 0,
month: month,
longitude: lon,
latitude: lat,
best_duct_band_ghz: nil
}
end
# ── Post-training sanity check ──────────────────────────────────────
# After training, evaluate the model on a synthetic set of
# (temp, month) combinations at otherwise-identical conditions. The
# goal is to confirm the model doesn't prefer / penalize specific
# months independently of the weather they carry — i.e. that a
# generic March day doesn't rank below a generic June day just
# because the training data had fewer March contacts.
defp check_monthly_bias(params) do
IO.puts("\nMonthly sanity check at fixed conditions (10 GHz, 15°C, 10°C dewpoint)...")
predict_fn = Model.compile_predict()
for month <- 1..12 do
conditions = %{
surface_temp_c: 15.0,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0,
min_refractivity_gradient: -70.0,
hpbl_m: 800.0,
pwat_mm: 20.0,
surface_refractivity: 320.0,
latitude: 32.5,
longitude: -97.0,
sfi: 120.0,
kp_max: 2.0,
ducting_detected: false,
k_index: 25.0,
lifted_index: 0.0,
utc_hour: 12,
month: month,
freq_mhz: 10_000
}
score = Model.predict_score(predict_fn, params, conditions)
IO.puts(" month=#{month |> Integer.to_string() |> String.pad_leading(2)}: #{score}")
end
end
# ── Helpers ──────────────────────────────────────────────────────────
defp print_header(per_month, epochs, batch_size, lr) do
"=" |> String.duplicate(70) |> IO.puts()
IO.puts("PROPAGATION MODEL TRAINING — month-balanced HRRR → algorithm score")
"=" |> String.duplicate(70) |> IO.puts()
IO.puts("Samples per month: #{per_month} epochs: #{epochs} batch: #{batch_size} lr: #{lr}")
IO.puts("Started: #{DateTime.to_string(DateTime.utc_now())}\n")
end
defp print_month_counts(counts) do
counts
|> Enum.sort_by(fn {m, _} -> m end)
|> Enum.each(fn {m, count} ->
IO.puts(" month #{m |> Integer.to_string() |> String.pad_leading(2)}: #{count}")
end)
end
defp shuffle(features, targets, n) do
key = Nx.Random.key(System.os_time())
{indices, _} = Nx.Random.shuffle(key, Nx.iota({n}))
indices = Nx.as_type(indices, :s64)
{Nx.take(features, indices), Nx.take(targets, indices)}
end
defp split(features, targets, n) do
nc = elem(Nx.shape(features), 1)
train_end = trunc(n * 0.8)
val_end = trunc(n * 0.9)
test_size = n - val_end
IO.puts(" Split: train=#{train_end}, val=#{val_end - train_end}, test=#{test_size}")
{
Nx.slice(features, [0, 0], [train_end, nc]),
Nx.slice(targets, [0, 0], [train_end, 1]),
Nx.slice(features, [train_end, 0], [val_end - train_end, nc]),
Nx.slice(targets, [train_end, 0], [val_end - train_end, 1]),
Nx.slice(features, [val_end, 0], [test_size, nc]),
Nx.slice(targets, [val_end, 0], [test_size, 1])
}
end
defp print_loss(loss) do
if is_float(loss) and loss == loss do
IO.puts(" Final loss: #{Float.round(loss, 6)}")
else
IO.puts(" Final loss: NaN (training diverged)")
System.halt(1)
end
end
defp print_eval(label, metrics) do
IO.puts(" #{label}: RMSE=#{Float.round(metrics.rmse * 100, 2)} pts, R²=#{Float.round(metrics.r_squared, 4)}")
end
defp to_float(nil), do: 0.0
defp to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp to_float(v) when is_float(v), do: v
defp to_float(v) when is_integer(v), do: v / 1
end