prop/lib_ml/propagation_train.ex
Graham McIntire 3604896726
Rename ERA5 → NARR across the codebase
- Schema: Era5Profile → NarrProfile; table renamed via migration
  rename_era5_profiles_to_narr_profiles (ALTER TABLE + rename indexes,
  atomic and instant, no row rewrite)
- Weather helpers: find_nearest_era5/era5_for_contact/era5_profiles_for_path
  → find_nearest_narr/narr_for_contact/narr_profiles_for_path
- BackfillEnqueueWorker: :era5 → :narr type (virtual, no narr_status column);
  reconcile_stale_queued_to_unavailable and status_priority_order now skip
  virtual types via @virtual_types. Fixes the prod crash where the cron tried
  to update a non-existent era5_status column.
- ContactWeatherEnqueueWorker: build_era5_jobs → build_narr_jobs,
  era5_jobs_for_contact → narr_jobs_for_contact
- ContactLive: @era5 → @narr, maybe_enqueue_era5 → maybe_enqueue_narr;
  UI label "ERA5 (0.25°)" → "NARR (32 km)"
- Cron (runtime.exs): args types list now "narr" instead of "era5"
- /status: progress row, status-by-type card, totals.narr_profiles, and
  table-count lookup all target narr_profiles
- Drop obsolete Era5CdsJob schema + era5_cds_jobs table (inspection
  artifact from the retired CDS pipeline; 34 orphan rows in prod)
- Misc docstring/comment cleanups (skew_t, about, wgrib2, propagation_train)

Includes a regression test for the virtual-type crash.
2026-04-16 09:22:23 -05:00

394 lines
13 KiB
Elixir
Raw 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