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