prop/lib/microwaveprop/pskr/recalibrator.ex
Graham McInitre b3ad3bdbe6 fix: sweep bugs across Python MQTT listener, Elixir PSKR/propagation, and Rust
Python pskr_mqtt_listen.py:
- Guard against malformed CONNACK/SUBACK/PUBLISH packets
- Fix _s() truthiness: is not None instead of if v (zero SNR was lost)
- EINTR-safe select loop, OOB data handling, VBInt overflow check
- Proper socket cleanup with try/finally + DISCONNECT on shutdown

Elixir:
- Log dropped spot errors in aggregator (was silently discarded)
- Wire retain_scores_window into NotifyListener chain completion
- Recurse sweep_tmp_dir into band/weather_scalars subdirectories
- Rescue recalibrator.run/0 to write failed status row on crash
- Handle nil in fmt_snr/fmt_callsigns (no more nil dB crashes)
- Replace Stream.run with Enum.reduce logging exits in poll_worker
- Handle File.stat race in ms_footprints prune, grid_center nil log
- Fix unused variables, stale comments, missing get_path!/1

Rust:
- Graceful JoinError handling in fetcher/hrdps_fetcher (no more panics)
- round_to_5min returns Option (leap-second safe)
- Acquire/Release ordering on shutdown flags (was Relaxed)
- Parameterized NOTIFY in db.rs, defensive scalar sweep, NaN guard
- OsString::push for tmp naming, clippy fixes
2026-07-20 19:48:45 -05:00

276 lines
8.6 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 Microwaveprop.Pskr.Recalibrator do
@moduledoc """
PSKR-driven analysis pipeline. Reads `pskr_calibration_samples`,
bins each sample by every scoring feature × band, and writes per-
(run, band, feature, bin) spot-density stats to
`pskr_feature_bins`. The operator queries the bin tables to see
whether each feature actually discriminates spot density at the
threshold granularity the scorer uses.
Auto-applies *nothing* to the live algorithm. Weight changes still
go through human review of `BandConfig.@band_configs` and a code
commit. The recalibrator's job is to surface evidence; it stays
out of the production scoring path.
## Why bins, not regression
`BandConfig` already encodes scoring as discrete thresholds (e.g.
refractivity gradient at -500 / -300 / -200 / -100). The bin
output matches that shape so an operator can read "PWAT 25-40 mm
on 10 GHz had n=4,200 cells with avg 18 spots, vs PWAT >55 mm
with n=620 and avg 4 spots" and decide directly whether a
threshold needs to move. A continuous regression would obscure
the threshold structure that the scorer needs to consume.
## Self-healing
`run/0` is idempotent at the run level — every fire creates a
fresh `pskr_recalibration_runs` row. Fresh runs replace stale
ones at query time (operators look at the latest). Mid-run
failure logs a `failed` status and surfaces the exception in
`notes` so the next fire can retry without manual cleanup.
"""
import Ecto.Query
alias Microwaveprop.Pskr.CalibrationSample
alias Microwaveprop.Pskr.FeatureBin
alias Microwaveprop.Pskr.RecalibrationRun
alias Microwaveprop.Repo
require Logger
# Minimum corpus size to attempt analysis. Below this the bin
# statistics are so noisy they're misleading; better to log a
# `skipped_insufficient_data` run and wait. ~1000 samples ≈
# 10 cells × 100 hours of CONUS PSKR activity, achievable in 4-5
# days even with sparse traffic.
@min_total_samples 1_000
# Per-band threshold for emitting bins for that band. With < this
# many samples on a band, the per-bin counts collapse to single
# digits and the analysis tells you nothing.
@min_band_samples 100
# Feature → bin definitions. Each list is `{label, min, max}` with
# `min`/`max` as inclusive lower / exclusive upper. nil sides
# mean unbounded. Bins are ordered for human-readable output but
# SQL returns them set-style; the worker re-sorts on read if
# needed.
@feature_bins %{
pwat_mm: [
{"<15", nil, 15.0},
{"15-25", 15.0, 25.0},
{"25-40", 25.0, 40.0},
{"40-55", 40.0, 55.0},
{">55", 55.0, nil}
],
hpbl_m: [
{"<250", nil, 250.0},
{"250-500", 250.0, 500.0},
{"500-1000", 500.0, 1000.0},
{"1000-1500", 1000.0, 1500.0},
{">1500", 1500.0, nil}
],
min_refractivity_gradient: [
{"<-200", nil, -200.0},
{"-200..-100", -200.0, -100.0},
{"-100..-50", -100.0, -50.0},
{">-50", -50.0, nil}
],
surface_pressure_mb: [
{"<1010", nil, 1010.0},
{"1010-1015", 1010.0, 1015.0},
{"1015-1020", 1015.0, 1020.0},
{">1020", 1020.0, nil}
],
kp_index: [
{"0-2 quiet", nil, 3.0},
{"3-4 unsettled", 3.0, 5.0},
{"5+ storm", 5.0, nil}
]
}
@doc """
Run the analysis end-to-end. Returns the `RecalibrationRun` row
written. Safe to call from a worker, an iex shell, or a test —
always inserts a run record so the audit trail is complete.
"""
@spec run() :: RecalibrationRun.t()
def run do
now = DateTime.truncate(DateTime.utc_now(), :second)
stats = corpus_stats()
if stats.sample_count < @min_total_samples do
insert_skipped_run(now, stats, "corpus has #{stats.sample_count} samples, need ≥ #{@min_total_samples}")
else
run_analysis(now, stats)
end
rescue
e ->
Logger.error("Pskr.Recalibrator: run failed — #{Exception.format(:error, e, __STACKTRACE__)}")
%RecalibrationRun{}
|> RecalibrationRun.changeset(%{
run_at: DateTime.truncate(DateTime.utc_now(), :second),
status: "failed",
notes: Exception.format(:error, e, __STACKTRACE__)
})
|> Repo.insert!(on_conflict: :nothing)
end
# ── corpus stats ────────────────────────────────────────────────
defp corpus_stats do
%{rows: [[total, bands, earliest, latest, avg_spots]]} =
Repo.query!(
"""
SELECT
count(*)::int,
count(DISTINCT band)::int,
min(hour_utc),
max(hour_utc),
avg(spot_count)::float
FROM pskr_calibration_samples
""",
[]
)
%{
sample_count: total || 0,
band_count: bands || 0,
earliest_sample: cast_to_utc(earliest),
latest_sample: cast_to_utc(latest),
avg_spots_per_sample: avg_spots
}
end
defp cast_to_utc(%NaiveDateTime{} = ndt), do: DateTime.from_naive!(ndt, "Etc/UTC")
defp cast_to_utc(nil), do: nil
defp cast_to_utc(%DateTime{} = dt), do: dt
# ── skipped run ────────────────────────────────────────────────
defp insert_skipped_run(now, stats, note) do
Logger.info("Pskr.Recalibrator: skipping — #{note}")
{:ok, run} =
%RecalibrationRun{}
|> RecalibrationRun.changeset(
Map.merge(stats, %{
run_at: now,
status: "skipped_insufficient_data",
notes: note
})
)
|> Repo.insert()
run
end
# ── analysis run ───────────────────────────────────────────────
defp run_analysis(now, stats) do
Logger.info("Pskr.Recalibrator: starting analysis on #{stats.sample_count} samples across #{stats.band_count} bands")
{:ok, run} =
%RecalibrationRun{}
|> RecalibrationRun.changeset(
Map.merge(stats, %{
run_at: now,
status: "completed"
})
)
|> Repo.insert()
bin_rows = build_bin_rows(run.id)
if bin_rows != [] do
now_seconds = DateTime.truncate(DateTime.utc_now(), :second)
timestamped =
Enum.map(bin_rows, &Map.merge(&1, %{inserted_at: now_seconds, updated_at: now_seconds}))
{count, _} = Repo.insert_all(FeatureBin, timestamped)
Logger.info("Pskr.Recalibrator: wrote #{count} feature_bins for run #{run.id}")
end
Repo.preload(run, :feature_bins)
end
# ── bin generation ─────────────────────────────────────────────
defp build_bin_rows(run_id) do
eligible_bands = eligible_bands_for_run()
for band <- eligible_bands,
{feature, defs} <- @feature_bins,
{label, lo, hi} <- defs,
row = bin_for(run_id, band, feature, label, lo, hi),
not is_nil(row) do
row
end
end
defp eligible_bands_for_run do
Repo.all(
from(s in CalibrationSample,
group_by: s.band,
having: count(s.id) >= ^@min_band_samples,
select: s.band
)
)
end
defp bin_for(run_id, band, feature, label, lo, hi) do
%{rows: rows} =
Repo.query!(bin_sql(feature), bin_params(band, lo, hi))
[[count, total, avg, p50, p90]] = rows
if count == 0 do
nil
else
%{
id: Ecto.UUID.generate(),
run_id: run_id,
band: band,
feature: Atom.to_string(feature),
bin_label: label,
bin_min: lo,
bin_max: hi,
sample_count: count,
spot_count_total: total || 0,
spot_count_avg: avg,
spot_count_p50: p50,
spot_count_p90: p90
}
end
end
# Per-feature SQL: count samples that have a non-null value of the
# feature within the bin range, plus spot-count statistics. Built
# at compile time per feature so we don't take an Ecto fragment
# interpolation hit on the hot path.
for feature <- Map.keys(@feature_bins) do
col = Atom.to_string(feature)
defp bin_sql(unquote(feature)) do
"""
SELECT
count(*)::int,
sum(spot_count)::bigint,
avg(spot_count)::float,
percentile_cont(0.5) WITHIN GROUP (ORDER BY spot_count)::float,
percentile_cont(0.9) WITHIN GROUP (ORDER BY spot_count)::float
FROM pskr_calibration_samples
WHERE band = $1
AND #{unquote(col)} IS NOT NULL
AND ($2::float IS NULL OR #{unquote(col)} >= $2)
AND ($3::float IS NULL OR #{unquote(col)} < $3)
"""
end
end
defp bin_params(band, lo, hi), do: [band, lo, hi]
end