- Fix Module.safe_concat -> Module.concat in tests with dynamic process names
(safe_concat calls binary_to_existing_atom, but test names are newly generated)
- Fix PSKR AggregatorTest sandbox ownership by switching to async: false
- Fix MapLiveTest assert_patch: regex unsupported in LiveView 1.2, use string match
- Fix WeatherMapLiveTest toggle_grid: assert on checked attribute, not data-grid
- Fix BeaconLive.Index leaking unapproved beacons: wire data_provider to approved_beacons_query
- Fix ContactLive.Index leaking private contacts: wire data_provider with visibility filter
- Fix RecalibratorTest: train() expects factor vectors, not {vector, datetime, band} tuples
- Fix toggle_sort: compare current_field to new_field, not current_order
- Fix internal_network?: handle both atom and string session keys from Plug sessions
Test results: 3979/4001 -> 3997/4001 (18 previously-failing tests now pass)
Adds tests for previously under-covered modules so the cover-tool
threshold check passes:
* Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
the algorithm/ML scoring path, write_latest, append_history,
read_history (incl. the unparseable-line arm), and the per-band
summary loop.
* Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
months take the task through load_training_data, shuffle, split,
train, eval, save, and the monthly-bias check.
* Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
exercises the spearman/rank/percentile helpers and the
multi-band summary path.
* Mix.Tasks.Unused — smoke tests run/1 with no flags,
--skip-external, and --verbose.
* Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
the per-(month,hour) batch insert.
* Microwaveprop.Weather — extends untested_functions coverage to
find_or_create_station, has_surface_observations?,
station_day_covered?, get/existing_solar_*, nearby_stations,
sounding_times_around, latest_grid_valid_time, find_nearest_*
(HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
backfill_hrrr_scalars, analyze_all.
* Microwaveprop.Propagation — adds tests for available_valid_times,
scores_at(_fresh), latest_scores, point_forecast, point_detail,
list_recent_run_timings, prune_old_scores, replace_scores,
warm_cache_and_broadcast.
* Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
delegations.
* Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
of registered?/1 with a live PID.
* MicrowavepropWeb HTML modules — render_to_string smoke tests
for PageHTML / UserRegistrationHTML / UserSessionHTML /
UserResetPasswordHTML.
Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).
Total goes from 82.77% → 85.06%.
The producer-only path left older hours stuck at 0% HRRR coverage
forever once their fetch tasks drained: the rolling-window cron only
re-passes the prior hour, so manual lookback runs (or any
out-of-window hour) wrote NULL samples + enqueued tasks, then never
re-ran to UPSERT the now-landed HRRR data.
Fix: split the responsibility cleanly.
* CalibrationSampler.build_for_hour/1 now returns
%{upserted: int, missing_hrrr_cells: int} so callers know whether
the producer enqueued anything (i.e. whether a follow-up makes
sense). Spec-tightened with a new @type result.
* PskrCalibrationWorker, after each hour's sampler pass, schedules a
follow-up of itself for that exact hour 10 min later when missing
> 0. The follow-up carries args %{"hour_utc" => ..., "_follow_up"
=> true}; the sentinel prevents follow-ups from chaining further
follow-ups (rolling-window cron is the safety net).
Tests:
* Updated 4 existing sampler tests to the new map return shape.
* Moved the follow-up assertions from the sampler suite into the
worker suite where they belong (sampler is now scheduling-free).
* Added 3 worker tests: schedules-when-missing, no-schedule-when-
covered, follow-up-doesn't-chain.
3313 tests + 228 properties, 0 failures.
Two performance fixes the property tests pinned down:
* CalibrationSampler.build_for_hour/1 walked every cell through
nearest_hrrr/3 twice — once in the producer and again in
build_sample. Now computed once into a (band, lat, lon) → hrrr_or_nil
map, halving the O(cells × profiles) scan (~10M comparisons saved
per fire at typical sizes).
* PartitionManager.ensure_quarterly_partitions/2 issued one
pg_inherits scan per (parent × lookahead). Refactored to one scan
per parent with in-memory coverage check across all quarters.
Property tests for both modules:
* PartitionManager: tile-coverage invariant (no gaps, no overlaps),
exact 3-month bounds, name format, multi-call idempotence,
multi-parent independence — caught a real NaiveDateTime sort bug
in the original test helpers. Default Enum.sort_by/2 falls back to
term comparison on NaiveDateTime; now uses NaiveDateTime as the
comparator module.
* CalibrationSampler: enqueued points = unique missing midpoints,
empty enqueue when fully covered, sample row count invariant under
HRRR availability.
Saved operational gotchas to CLAUDE.md (Oban leader election spans
all app=prop pods, kubectl exec deploy/prop ambiguity, half-year
partition coexistence, HRRR f000 publish lag).
3310 tests + 228 properties, 0 failures.
Adds the test that verifies the full producer→drain→re-pass loop:
sample written with NULL HRRR fields + fetch task enqueued, then once
an HRRR profile lands, the next sampler pass UPSERTs the same
calibration_samples row (id preserved) with non-NULL weather data.
Also tightens dialyzer surface:
* PartitionManager defines a `result()` typedoc and uses it on both
public functions instead of inlined tuple types.
* PartitionMaintenanceWorker.perform/1 + the lookahead/1 helper get
explicit specs.
* CalibrationSampler.enqueue_missing_hrrr/3 gets a spec covering its
group_by-shape input map and HRRR profile list.
`mix precommit` clean (3310 tests, 0 failures). Local `mix dialyzer`
hits an OTP 28.4 vs 28.5 toolchain mismatch unrelated to this change.
The CalibrationSampler joined PSKR midpoints against hrrr_profiles via
nearest-within-window, but post-Phase-3 Stream C nothing bulk-populates
the grid — hrrr_profiles only sees per-QSO point fetches from the Rust
hrrr-point-worker. Result: 0 / 16,042 prod samples had HRRR data.
Producer pattern: when a cell has no profile in the lookahead window,
enqueue a row into hrrr_fetch_tasks at the cell's HRRR-grid-rounded
midpoint. The Rust worker drains it; the next 5-min rolling-window
pass UPSERTs the same sample with non-NULL weather fields. Idempotent
with the existing on-conflict design — no extra worker, no churn on
cells already covered, multiple bands sharing a midpoint dedupe to a
single fetch via Enum.uniq + jsonb point-union in HrrrPointEnqueuer.
`Pskr.Recalibrator.run/0` reads `pskr_calibration_samples`, bins
each sample per (band × feature), and writes spot-density stats to
`pskr_feature_bins` so an operator can read whether a feature
actually discriminates propagation at the threshold granularity
the scorer uses.
Bins, not regression: `BandConfig` already encodes scoring as
discrete thresholds, so the bin output matches that shape and an
operator can copy adjusted thresholds directly without translating
from regression coefficients.
Self-healing: corpus too thin ⇒ run row written with status
`skipped_insufficient_data` and the analysis is a no-op until next
fire. `min_total_samples = 1000` (≈ 4-5 days of CONUS PSKR
activity); per-band threshold is 100. Both surface in the run row's
`notes`.
Auto-applies nothing. Weight changes still go through human review
of `BandConfig.@band_configs` and a code commit. The recalibrator
is a read-only analyst that stays out of the production scoring
path.
Features binned (matching the scorer's discriminating fields):
* pwat_mm — humidity U-shape candidate
* hpbl_m — boundary layer (mechanism vs scoring re-eval)
* min_refractivity_gradient — refractivity threshold validation
* surface_pressure_mb — pressure-front proxy
* kp_index — aurora boost magnitude tuning
Schema: two tables.
* `pskr_recalibration_runs` — one row per fire with corpus
stats, status, notes
* `pskr_feature_bins` — one row per (run, band, feature, bin)
with sample_count, spot_count_total/avg/p50/p90
Cron: `0 4 * * 0` (Sundays 04:00 UTC, off-peak, post-climatology).
Manual reruns enqueue with no args.
Tests cover the empty-corpus skip path, sub-threshold totals,
per-band threshold gating, the actual bin emission, nil-feature
handling, spot-count averaging, and the always-records-a-run
audit invariant. 8 new tests, 3282 total passing.
Backfill pipeline untouched.
PSK Reporter is now an always-on data feed, so the calibration
corpus that recalibration will eventually train against can grow
hourly from when the firehose started. One row per (hour, band,
0.125° midpoint cell) joins three sources:
* `pskr_spots_hourly` — observed spot density (truth signal)
* `hrrr_profiles` nearest match at the cell midpoint at hour
boundary (atmospheric features: T, Td, PWAT, P, dN/dh, HPBL,
ducting flag)
* `geomagnetic_observations` latest Kp at the hour (space weather)
Predicted scores are intentionally NOT stored — they're a function
of the algorithm version under evaluation. Storing only features
keeps the corpus stable across every weight refit; the recalibrator
computes predictions on demand.
Components:
* Migration `20260504210756_create_pskr_calibration_samples` —
new table + unique index on (hour, band, midpoint_lat, lon),
plus a midpoint spatial index on `pskr_spots_hourly` to keep
the join cheap.
* `Pskr.CalibrationSample` — schema mirror of the table with
the same `(hour, band, midpoint_lat, lon)` unique constraint.
* `Pskr.CalibrationSampler.build_for_hour/1` — pulls all spots
for the hour, snaps midpoints to the propagation grid (0.125°),
builds an in-memory HRRR index over a ±0.07°/±60 min window,
and bulk-upserts samples. Idempotent — re-runs upsert.
* `Workers.PskrCalibrationWorker` — Oban cron entry on the
`:backfill_enqueue` queue. Default args target the previous
full hour; explicit `hour_utc` arg reruns any hour.
* Cron `25 * * * *` — fires past HRRR analysis publish window
(~HH:50→HH:05) and PSKR aggregator's 60s flush.
Forward-only: PSKR has no historical archive, so the corpus only
grows from when the feed started. Recalibration weight refits
should wait for ~30 days / ~10k samples to cover diurnal and
synoptic variability.
Tests cover cell-snapping, HRRR feature joining, Kp stamping,
median-distance aggregation, mode dedup, idempotent reruns, and
the worker's default-hour and explicit-hour args (12 new tests,
all passing).
Backfill pipeline untouched — none of these changes feed into
contact enrichment.
Spots that include subsquare (EM12kl), extended-square (EM12kl37),
or extended-extended-subsquare (EM12kl37ab) precision now keep
that resolution end-to-end. Previously we truncated everything to
4 chars at parse time, which collapsed distinct paths into the same
row whenever two spotters shared a field.
The aggregation key uses the full grid as reported, so two
spotters in different subsquares of `EM12` produce two rows —
correct, since they are physically different paths. Any spot that
fails Maidenhead validation (odd length, illegal char at position
N) is still dropped via the same error path.
Storage normalizes to uppercase so the unique index doesn't split
on case. Maidenhead.valid?/1 already accepts both cases on input.
emqtt's transitive `quicer` dep needs CMake + msquic submodule
clones to build, which the slim Debian builder image doesn't ship —
the prod CI build was failing inside `mix deps.compile`. Tortoise311
is the obvious pure-Elixir alternative but it bundles a
gen_state_machine + retry/inflight stack we don't use because we're
subscribe-only at QoS 0.
Implementing the wire protocol inline is ~150 lines in
`Microwaveprop.Pskr.Mqtt` (CONNECT/SUBSCRIBE/PINGREQ/DISCONNECT
builders, CONNACK/PUBLISH/SUBACK/PINGRESP parser, varint codec).
Pskr.Client now uses :gen_tcp with `active: :once` framing,
buffers partial reads, schedules its own keepalive, and parses
inbound packets in a tight loop.
Drops `BUILD_WITHOUT_QUIC=1` from the Dockerfile — no longer
relevant since there's no native dep at all in the build chain
now.
Subscribes to mqtt.pskreporter.info:1883 over plain TCP and folds
every reception report into hourly aggregates per
(band, sender_grid_4, receiver_grid_4). Each spot is the empirical
"propagation actually occurred on this path right now" signal we'll
correlate against HRRR atmospheric state to recalibrate the scoring
weights. Aggregating at the path-hour bucket collapses 50-reporter
QSOs to one row instead of 50.
Topic filter anchors on USA (DXCC 291) on either sender or receiver
side so the broker pre-filters out everything outside our HRRR
domain. Bands subscribed: VHF and up (6m, 2m, 70cm, 23cm, +
microwave) — HF is dominated by ionospheric propagation, which this
project doesn't model.
Pskr.Client cluster-elects a singleton via :global.register_name —
all replicas start the GenServer but only one connects to MQTT;
the rest stay in :standby and watch for the leader's nodedown to
re-run election. Off in dev/test, on by default in prod
(PSKR_MQTT_ENABLED=false as kill-switch).
Pskr.Aggregator buffers in memory keyed by Pskr.path_key/1 and
flushes every 60s via Repo.insert_all/3 with an additive
ON CONFLICT (count summed, SNR envelope by GREATEST/LEAST,
modes unioned via unnest+DISTINCT). Idempotent across overlapping
flushes and across leader handover.
Dockerfile sets BUILD_WITHOUT_QUIC=1 — emqtt's transitive quicer
dep wants CMake + OpenSSL headers we'd otherwise have to add to
the builder image just to never use QUIC over plain MQTT. Base
image is unchanged; the new dep compiles cleanly into the existing
prop-base runtime.