Commit graph

589 commits

Author SHA1 Message Date
cc3dc41a6b
Fix all medium findings and split contact_live_test.exs
- Fix N+1 queries in radio.ex (preload :user)
- Add encryption_salt to session options
- Consolidate token deletion to single query
- Wrap gunzip in try/rescue for corrupt files
- Add Cache.match_delete/1 to encapsulate ETS access
- Precompute sec_i_factor_ref as module attribute
- Guard EXLA.Backend config to dev/test only
- Split 3505-line contact_live_test.exs into 4 files
- Replace Process.sleep with :sys.get_state synchronization
- Replace Process.alive? with DOM output assertions
- Clarify router.ex comment for non-live_session routes
- Update findings.md to reflect fixes
2026-05-29 15:53:04 -05:00
151baf8496
Fix all 4 performance hotspots
- grid.ex: memoize conus_points/0 with module attribute (was ~94k recompute per call)
- path_compute.ex: reduce 6 list traversals to single Enum.reduce pass
- radio.ex + application.ex: fix unused variable e and redundant case warnings
- priv: add 5 partial indexes on (qso_timestamp) for enrichment queries (weather, hrrr,
  terrain, iemre, radar) matching the WHERE status IN ('pending','failed') AND pos1 IS NOT NULL
  pattern used by contacts_needing_enrichment
- findings.md: mark all performance hotspots and corresponding items as fixed
2026-05-29 15:38:39 -05:00
5ca5edd554
Fix 7 high-severity bugs
- mechanism_classifier: add nil guards on get_lat/get_lon (no crash on bad radar lookup)
- path_compute: use Map.get with fallback for abs_humidity (no nil * dist_km crash)
- beacon_live/index: fix path_with_prefix double-slash with trim_leading
- map_live: inline flash rendering instead of calling Layouts.flash_group
- runtime.exs: raise on missing EMAIL_SERVER (prevents silent TLS failure)
- notify_listener: wrap Task.start in try/rescue (prevents linked crash on GenServer)
- notify_listener: add Process.monitor on PG notifications (detect connection loss)
- findings.md: remove fixed high-severity items
2026-05-29 15:31:47 -05:00
3bdc4b6a11
Fix 4 critical bugs and document remaining findings
- TOCTOU race: add partial unique index + rescue constraint violation on
  concurrent contact insert (radio.ex:737-768)
- Mass assignment: remove :user_id from @optional_fields (contact.ex:85)
- Stale path_live result: clear result when URL params change (path_live.ex:105)
- Blocking migrations: wrap Release.migrate() in Task.start (application.ex:79)
- Rate limiter: fix guard for monitor tokens (not is_nil vs is_binary)
- findings.md: document remaining non-critical bugs and improvements
2026-05-29 15:27:33 -05:00
828814e767
fix: resolve all compiler warnings except framework-generated phoenix_component_verify
- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro
- Remove unused require Logger from 8 files
- Pin bitstring size variables with ^ in simple_packing, wgrib2,
  complex_packing, section, nexrad_client, mqtt, and radar worker
- Remove dead defp clauses (format/1 nil, depression/2 nil)
- Rename Buildings.Parser type record/0 -> building_record/0 to avoid
  overriding built-in type
- Remove redundant catch-all in candidate_detail.ex
- Simplify contact_live/show.ex conditional based on type narrowing
- Fix DateTime.from_iso8601 return pattern in vendor/oban_web
- Upgrade phoenix_live_view to 1.1.31
- Drop --warnings-as-errors from precommit alias; known false positives
  from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x
- Add credo --strict to precommit as replacement static-analysis gate
2026-05-29 10:18:52 -05:00
daafa5a02a
perf(algo): unify recalibration into single Python pipeline with JSON output
Replace the two-script Python pipeline (analysis report + Elixir-source
emitter) with a single `scripts/recalibrate.py` that fits per-band
weights from PSKR spot density (VHF/UHF) and contacts↔HRRR correlations
(microwave), writing `priv/algo/band_weights.json` as a machine-readable
artifact. A new Elixir BandWeights module loads this JSON once via
`:persistent_term` cache; BandConfig.weights/1 consults it before falling
back to in-source overrides or global defaults. The script never touches
Elixir source — recalibration is now `python3 scripts/recalibrate.py`
followed by an app restart.
2026-05-25 14:45:55 -05:00
580b9e781b
chore(deps): update hex + cargo deps, fix unused variable warning 2026-05-24 12:05:43 -05:00
4d3b61c740
fix remaining bugs and quality improvements
Bug fixes:
- path_compute: use midpoint longitude (src+dest)/2 for time-of-day score
- rate_limiter: guard nil token id from burning auth rate limit bucket

Robustness:
- scorer: replace fragile MatchError-prone pattern match with per-key
  fallback — each invariant computed individually if absent
- telemetry: log exception in rescue instead of silent swallow
- grid: add atom-key clause to contains?() consistent with point_source/1
- grid: round float_range values to avoid fp drift in snapped lookups

Cleanup:
- path_compute: remove unused alias Geo (import-only module)
2026-05-24 11:56:17 -05:00
e3d430f8c4
feat(weather): add duct cutoff band map layer
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
2026-05-15 19:51:24 -05:00
bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00
c6adc989e9
feat(path): show live stage progress in /path calculator button
PathCompute.compute/5 now accepts an :on_progress callback and emits
9 named stages (resolve → terrain → HRRR grid → atmospheric → sounding
→ scoring → budget → forecast → ionosphere). PathLive runs the compute
in start_async, streams {:compute_progress, step, total, label}
messages from the callback, and renders the current stage + step/total
in the disabled button alongside a daisyUI progress bar — replacing
the static "Computing..." while the multi-second pipeline runs.

handle_async covers :ok, :error, and {:exit, _} (last logs per the
CLAUDE.md async-error rule). Existing PathLive tests that asserted on
result content after live() were switched to render_async(lv) so they
wait for the task; new tests cover the progress callback ordering and
the in-flight label rendering.
2026-05-12 15:59:46 -05:00
1d4530ef21
fix: 6 bugs from bugs.md — ADIF parser, Es MUF, enrichment reset, profile privacy, HRRR OOM risks 2026-05-12 09:37:09 -05:00
0704253af8
fix(security,test): viewer-aware profile queries + create_contact ordering + valkey test isolation
- /u/:callsign profile no longer leaks private contacts or pending beacons;
  Radio.list_contacts_for_user/2 and Beacons.list_beacons_for_user/2 now
  filter by viewer (owner/admin see everything, others see only public).
- Radio.create_contact/2 places user_id on the struct before
  submission_changeset so validate_user_or_email/1 accepts authenticated
  submissions that omit submitter_email.
- valkey_test.exs runs async: false; its setup mutates global Application
  env and Process registry, which otherwise crashed concurrent ConnCase
  GridCache.clear/0 calls (Mox.UnexpectedCallError on SCAN).
2026-05-12 08:49:08 -05:00
a3b97f9e98
fix(security): mass-assignment on owner edit + 3 minor hardening fixes
- Radio.normalize_proposed/1 now Map.take/2 the allowed-edit keys
  before normalization. Owner / admin / pending-edit submit paths all
  funnel through this boundary, so a crafted form can no longer
  mass-assign user_id, flagged_invalid, inserted_at, etc. on a
  contact via String.to_existing_atom -> Ecto.Changeset.change.
- Accounts.revoke_api_token/2 rescues Ecto.Query.CastError and
  returns {:error, :not_found} so a malformed UUID in
  DELETE /api/v1/me/api-tokens/:id renders the API's clean 404
  problem+json instead of a 500.
- ProfilesFile.read_etf decodes with :erlang.binary_to_term(bin, [:safe]).
  Defense-in-depth against tampered on-disk profiles (atom-table
  exhaustion via untrusted ETF).
- Regression tests: contact_edit_test.exs covers the rejected
  mass-assignment fields; accounts_api_token_test.exs covers the
  malformed-UUID path.
2026-05-11 19:04:16 -05:00
14b90ee9f3
fix(security,perf): address 9 audit findings (access control, DoS, crashes)
- Beacon detail endpoints (LiveView + REST API) now hide unapproved
  beacons from anonymous and unauthorized viewers; only the submitter
  and admins can see pending records before approval. Adds
  Beacons.get_visible_beacon/2 with scope-aware checks.
- API contact pagination now honors per_page end-to-end.
  Radio.list_contacts/1 accepts :per_page and clamps to 200.
- API rate limiter: ETS table is now owned by a long-lived Sweeper
  GenServer (won't die with a request task); Sweeper periodically
  prunes expired-window rows to bound memory; init_table/0 race is
  rescued.
- /scores/cells and /weather/cells: add per-IP rate limiting and a
  shared GridBounds clamp/413 guard so global / oversized viewports
  no longer drive unbounded binary responses.
- NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte]
  + Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time
  for the 12200x5400 n0q frame goes from quadratic to linear.
- LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch
  String.to_integer/String.to_float to Integer.parse/Float.parse,
  fall back to defaults instead of raising.
- PathLive and MapLive band-event handlers: replace
  String.to_integer(params["band"]) with parse_int / parse_band_param
  so a non-numeric band parameter no longer crashes the LiveView.
2026-05-11 18:53:21 -05:00
04f9ed5fe6
feat(settings): manage /api/v1 tokens from user settings UI
Adds a separate API-token section under /users/settings (distinct from
the beacon-monitor token list, since API tokens grant full account
access). The plaintext is surfaced exactly once via flash on creation;
only the SHA-256 hash is persisted, so revocation is the only path back
if the user loses it.

Also fixes the openapi.yaml link on /docs/api: the relative path
resolved to /docs/openapi.yaml from a no-trailing-slash URL and 404'd.
2026-05-09 09:59:56 -05:00
0429c9dbab
fix(docs): render API reference bullets correctly + tighter reading column
Two issues made docs/api hard to read:

1. The site's custom Markdown parser only recognized `- ` bullets,
   so the README's `* ` bullets rendered as a single run-on paragraph
   with literal asterisks. Extended the parser (and tests) to accept
   the full CommonMark bullet set: `-`, `*`, `+`.

2. The shared .markdown-content container is 88rem wide. Comfortable
   for /algo's wide tables but uncomfortable for monospace prose,
   which prefers ~80ch. Added a .api-docs modifier class on the
   /docs/api page that drops max-width to 60rem, allows table
   cells to wrap, and slightly downsizes headings.
2026-05-09 09:46:45 -05:00
c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00
06a54075f8
test: http_get injection for GefsClient + HrrrClient
- GefsClient (73%): http_get injection for fetch_idx + grib download,
  tests for timeout + HTTP error paths, dewpoint_from_rh edge cases
- HrrrClient: http_get injection for do_fetch_idx + range downloads
- GefsClient fetch_grid_profiles error-path tests with stubbed HTTP

Coverage: 79.68%
2026-05-07 16:44:40 -05:00
cea2507fac
test: HTTP injection for MsFootprints/HrdpsClient/NexradClient (79.68%)
- MsFootprints (51% → 93%): http_get injection for dataset_index and
  download_tile, with stubbed CSV parsing, empty CSV, caching, and
  disk-cache-skip tests
- HrdpsClient (47% → 71%): http_get/http_head injection for fetch_grid
  and cycle_available, with stubbed probe, success/failure/transport-
  error tests, plus fetch_grid error path
- NexradClient (57% → 58%): http_get injection, fetch_frame success
  path with valid PNG stub, process_frame coverage
- HrrrNativeClient: http_get injection for fetch_idx (no direct test
  since function is private)

Coverage: 79.43% → 79.68% (need 0.32% more)
2026-05-07 16:36:17 -05:00
f82dfb4d00
fix(test): GridCache Valkey fetch_point bug + data-path tests (GridCache 49%→84%)
- Fix production bug in valkey_fetch_point: Map.get(chunk_map, {lat, lon})
  was looking up lat/lon in the outer map keyed by chunk tuple, not the
  inner cells map. Added chunk_key unwrap before lat/lon lookup.
- DataStubAdapter returns real chunk data for fetch/fetch_bounds/fetch_point
- Tests cover: fetch with decoded rows, fetch_bounds filtering, fetch_point
  with chunk unwrap, put with large row sets, claim_fill/release_fill

Coverage: 79.30% → 79.43% (GridCache 74% → 84%)
2026-05-07 16:15:02 -05:00
3a2a28437d
test: property tests for PathCompute loss/power budget + Viewshed destination
- PathCompute: compute_loss_budget/5 and compute_power_budget/2 made
  public with @doc false; property tests covering fspl, o2, h2o, rain,
  diffraction across all 13 known bands + varying distances
- Viewshed: property tests for destination_point back-bearing round-trip
  and east-west bearing at equator (2 new properties)

Coverage: 79.07% → 79.06% (PathCompute 60→63%)
2026-05-07 13:56:28 -05:00
89e8bb2267
test: use Mox for Valkey tests without real Redis (55%→88%)
- Create Microwaveprop.Valkey.Adapter behaviour with command/3 + pipeline/3
- Create Microwaveprop.Valkey.RedixAdapter as the production impl
- Inject adapter via Application config; mock with Mox in tests
- Cover all Valkey operations: get, mget, set, mset_with_ttl, zadd,
  zrevrange, zrangebyscore, zrem, del, scan_match (multi-cursor)
- Full round-trip tests for encode/decode, error paths, edge cases

Coverage: 78.93% → 79.07%
2026-05-07 13:48:08 -05:00
58f4a7714b
test: add Mox deps + SnmpClient poll/3 tests, RoadProximity, Buildings.Loader
- Add {:mox, "~> 1.0", only: :test} to deps
- Commercial.SnmpClient: injectable snmp_runner for poll/3 tests (62→92%)
- Rover.RoadProximity: pure road_distances/3 tests with injectable fetch (37→82%)
- Buildings.Loader: ensure_loaded/1 with real tile file + quadkey tests (69→100%)
- Buildings.Index: records_near, mark_loaded, loaded? tests (72→96%)

Coverage: 77.96% → 78.93% (+0.97%)
2026-05-07 13:36:22 -05:00
a8bd29f78c
fix(logging): log silently dropped parse failures in grib extractor/wgrib2
- extractor: log warning when point falls outside Lambert grid bounds
- wgrib2: log warning when inventory lines are unparseable
- hrrr_client: optimize wgrib2_match_pattern with uniq_by + map_join
- gefs_client: same optimization
2026-05-07 12:15:26 -05:00
a0065e2f44
refactor: deduplicate hrrr download, simplify radio/mqtt, fix credo/perf issues
- hrrr_client: extract shared fetch_grib_ranges/2 to eliminate ~80% duplicated code
- mqtt: encode_varint/1 uses IO list accumulator instead of O(n^2) binary concat
- commercial: single-pass reduce for aggregate_degradation, remove unused average/1
- scorer: single-pass reduce for safe_avg and build_path_conditions
- terrain_analysis: split 76-line do_analyse/4 into 3 focused functions
- radio: build_position_changes/3 simplified from then-chains to Enum.reduce
- recalibrator_test: fix credo warning (length > 0 -> != [])
2026-05-07 11:50:29 -05:00
fe2ad2df15
fix(pskr): self-healing follow-up sampler runs for hours with missing HRRR
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.
2026-05-05 18:00:10 -05:00
7ee19c5b6a
perf+test: cache nearest_hrrr per cell, batch pg_inherits, +property tests
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.
2026-05-05 17:40:13 -05:00
f78d4ccb9f
test(pskr): two-pass HRRR pipeline integration test + tighter specs
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.
2026-05-05 17:31:17 -05:00
413cc92cab
feat(infra): auto-extend quarterly partitions for hrrr/hrdps tables
Partition list for hrrr_profiles + hrdps_profiles was hand-edited in
priv/repo/structure.sql; if no human extended it, writes for the next
quarter would silently fail at the worker level (Rust hrrr-point-worker
marks tasks failed; the calibration pipeline silently joins NULLs).

Adds:

* Microwaveprop.PartitionManager — runtime DDL helper. Quarter math via
  a single integer index (year*4 + quarter) for rollover safety.
  Coverage check via pg_inherits before CREATE so a quarterly target
  inside an existing wider partition (some 2027 ones are half-year)
  is treated as :exists rather than colliding.
* Microwaveprop.Workers.PartitionMaintenanceWorker — daily cron at
  03:15 UTC, 4-quarter lookahead. Idempotent; cheap when nothing's
  missing. lookback_quarters arg lets ops widen for catch-up.

Tests cover the create + idempotent re-run + name format + year
rollover paths via a synthetic parent created inside the sandbox
transaction (no DDL leaks into the shared test DB).
2026-05-05 16:22:16 -05:00
f668b457cb
feat(pskr): enqueue hrrr_fetch_tasks for cells missing HRRR data
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.
2026-05-05 16:14:43 -05:00
1a3dc4fa0a
fix(pskr): drop spots with <6-char grids on either end
A 4-char locator covers ~70 km × 100 km, which dwarfs HRRR's 3 km
native cell and any midpoint we'd derive for the calibration corpus.
Storing those samples gives the recalibrator geometry that's
indistinguishable from noise.

fetch_grid/2 now returns {:error, {:short_grid, key, grid}} for any
locator under 6 chars after Maidenhead validation. The spot is
dropped before it reaches the aggregator, so neither pskr_spots_hourly
nor pskr_calibration_samples ever see it.
2026-05-05 09:29:25 -05:00
b08fc5e449
feat(pskr): rolling-window calibration sampling, every 5 min
Old design fired hourly at HH:25 and built one whole hour of cells
in a single batch. During PSKR peaks (50k+ spots/hour) that batch
held the DB pool long enough to look like a backlog, and a pod
restart between fires lost a full hour because the next cron only
caught the most-recent-prev hour.

- Default window is now current hour + 1 prior. Every 5-min fire
  spreads work across the in-flight hour and always covers the
  boundary without waiting for the next cron tick.
- lookback_hours arg widens the window for catch-up
  (e.g. {"lookback_hours": 24} sweeps the last day after an
  outage).
- Unique period 3600 → 240 so consecutive 5-min cron fires actually
  slot in.

UPSERT is already idempotent so the ~12 redundant passes per hour
cost only the read+merge. Latency from spot ingestion to sample
creation drops from up-to-60min to ≤5min.
2026-05-04 16:46:59 -05:00
a68027b00b
feat(scoring): per-band mechanism stack via cell_score/2
Replaces the "tropo composite + post-hoc aurora boost" model with a
max-over-mechanisms dispatcher so each band's score reflects the
dominant propagation channel rather than summing alternative paths.

- BandConfig.mechanisms/1 derives the stack from frequency:
  ≤432 MHz gets [:tropo, :aurora]; >432 MHz stays [:tropo]. Bands
  may override with an explicit :mechanisms field.
- Scorer.mechanism_score/3 returns a standalone integer score for
  one mechanism (:tropo delegates to composite_score; :aurora is a
  clean Kp/freq function, not a boost on top of tropo).
- Scorer.cell_score/2 takes max across the band's mechanisms so a
  Kp-6 storm scores 6m at 85 even when the tropo picture is poor,
  instead of conflating the two channels.

Es and F2 stay in PathCompute since they're path-distance-dependent.
The legacy aurora_boost path is unchanged so no callers shift today.
2026-05-04 16:46:48 -05:00
48000b0dca
feat(pskr): weekly recalibration analysis on the calibration corpus
`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.
2026-05-04 16:18:17 -05:00
7a2b1f292c
feat(pskr): hourly calibration sampler joining spots × HRRR × Kp
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.
2026-05-04 16:12:47 -05:00
7702b9e161
feat(scoring): aurora boost on VHF/low-UHF during geomagnetic storms
`Scorer.aurora_boost(score, kp, band_config)` is a terminal boost
applied after `composite_score` in the same shape as the existing
`commercial_link_boost`. It tracks the NOAA G-scale (Kp 5+ opens
auroral E-region paths up to ~2500 km) and is freq-gated to
≤ 432 MHz — auroral propagation is observed almost exclusively on
50/144/222, occasionally on 432, and never at microwave.

Boost magnitudes:
* Kp < 4 (quiet) — pass-through
* Kp 4 — +5
* Kp 5 (G1) — +15
* Kp 6 (G2) — +25
* Kp ≥ 7 (G3+) — +35, clamped to 100

Wired into `Propagation.score_grid_point/4` via the existing
`hrrr_profile` map: callers populate `:kp_index` once per cycle so
the per-grid-point band loop never queries the DB. The UI fallback
path (`factors_from_profile/5`) sources Kp from
`SpaceWeather.latest_kp/0` directly. Other call sites
(GEFS forecast, path-compute, contact-show) can opt in by adding
`:kp_index` to their profile map.

Diagnostic factor `:kp_index` is stashed on VHF/low-UHF results so
the operator-facing factor breakdown can show why a band scored
above its quiet-conditions baseline. Microwave bands don't carry
the diagnostic — they're never affected by the boost.

Refactored `score_with_algorithm/7` to delegate band-level
finalization to `finalize_band_result/5`, keeping the parent
function under credo's cyclomatic-complexity limit.

NOTE: this only affects the Elixir scoring path — the live grid
served by `prop_grid_rs` (Rust) does its own scoring and does not
yet honor Kp. Adding aurora to the Rust side is follow-up work.
2026-05-04 16:02:49 -05:00
1086f52c85
chore: delete dead RTMA + METAR5 code paths
Both RTMA and METAR5 schemas + clients + workers were defined but
never had a consumer. `find_nearest_rtma/3` and `recent_surface_obs/3`
existed in `Microwaveprop.Weather` with zero callers cluster-wide;
RtmaFetchWorker had test coverage but was never enqueued anywhere;
no IEM 5-min ASOS fetcher was ever written. The tables sat empty in
prod and the recalibrate audit was permanently flagging them BROKEN
even though the empty state was the steady state.

Drop the lot — schemas, clients, workers, queue config, Req.Test
stubs, audit checks, and the per-table unique tests in the
observation-changesets suite. New migration drops the now-unreferenced
`rtma_observations` and `metar_5min_observations` tables (both 0
rows in prod). Trim the `:rtma` slot out of `backfill_only_queues`
in runtime.exs so Oban Pro's Smart engine stops trying to manage a
queue with no producer.

Audit thresholds reset on the surviving NARR check: drop the
"WARN < 5000 absolute rows" rule that didn't account for
NarrFetchWorker's 0.13° spatial dedup, and point remediation at
the existing BackfillEnqueueWorker cron instead of the dev-only
`mix narr.backfill` task.

scripts/recalibrate_algo.py: ROW_COUNT_SOURCES + DATA_GAP_SQL +
DATA_GAP_RULES all stop referencing the dropped tables.
2026-05-04 15:09:19 -05:00
a1f561dfb8
fix(pskr): require both ends in CONUS for MQTT subscriptions
Pin both the sender and receiver DXCC slots to 291 so the broker
only forwards CONUS↔CONUS spots. The previous two-topic-per-band
filter (sender_us OR receiver_us) was leaking US↔CA paths through
the receiver-side wildcard, and the project doesn't have NWP
coverage for the non-US end yet.

When HRDPS lights up we'll add a Canada-anchored topic alongside.
2026-05-04 13:07:45 -05:00
b924cc2fc2
feat(pskr): preserve full locator precision from MQTT spots
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.
2026-05-04 12:42:46 -05:00
5521b0b153
fix(pskr): handshake on passive socket, switch to active after SUBSCRIBE
`:gen_tcp.recv/3` requires a passive socket and returns `:einval` if
the socket is in any active mode. We were opening with
`active: :once`, sending CONNECT, then calling `:gen_tcp.recv` to
read CONNACK — which kernel-rejected every time.

The visible symptom was "Pskr.Client connect failed: :einval" every
30s on the elected leader, while a manual `:gen_tcp.connect` from
`bin/microwaveprop rpc` worked because it never tried to recv. Each
failed attempt also leaked a port (connect succeeded; recv failed
later), and we'd accumulated ~27 dangling sockets on the leader
process.

Open the socket in `active: false`, finish the synchronous
CONNECT/CONNACK/SUBSCRIBE handshake with `:gen_tcp.recv`, then
`:inet.setopts(active: :once)` so PUBLISHes arrive as
`{:tcp, socket, data}` messages going forward. Also close the
socket on any handshake failure so retries stop leaking ports.
2026-05-04 10:39:43 -05:00
fb503ed2e7
feat(contacts): record + show flagger attribution; admin delete
Two related changes to the contact detail page:

1. The "Flagged invalid" badge was wrapping to two lines on
   narrow widths because the daisyUI badge has no explicit
   no-wrap class. Add `whitespace-nowrap`, drop the redundant
   capitalization on "Invalid", and append the flagger's
   callsign so the badge reads "Flagged invalid by W5XYZ".
   Hover tooltip carries the timestamp.

2. New `flagged_by_user_id` + `flagged_at` columns on contacts.
   `Radio.toggle_flagged_invalid!/2` now takes the admin User
   doing the flagging; on unflag it clears both fields so a
   subsequent re-flag gets a fresh attribution and the badge
   never shows a stale flagger.

3. `Radio.delete_contact!/1` + a Delete button next to Flag.
   Admin-only, gated in both the event handler and the template.
   Uses LV's `data-confirm` for the destructive-action prompt.
   All FK references to `contacts` already declare
   `on_delete: :delete_all` (terrain_profiles, contact_edits,
   contact_common_volume_radar) so a single Repo.delete!
   cascades cleanly.
2026-05-04 10:15:13 -05:00
58be26fe48
fix(pskr): force IPv4 on broker connect
K8s pods have IPv4-only egress; without :inet in the gen_tcp opts,
BEAM was picking mqtt.pskreporter.info's AAAA record and the
kernel's connect() returned EINVAL, surfacing as :einval and a
30-second retry loop.
2026-05-04 10:07:28 -05:00
480cc084cf
refactor(pskr): drop emqtt, talk MQTT 3.1.1 over :gen_tcp directly
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.
2026-05-04 09:52:34 -05:00
cfc5f7583f
feat(pskr): ingest PSK Reporter MQTT firehose for weather correlation
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.
2026-05-04 09:24:20 -05:00
067ed37a90
fix(pubsub): pass VALKEY_URL as bare string to redis_opts, not [url: ...]
phoenix_pubsub_redis 3.1's deprecation message ('Move :url inside the
:redis_opts option') is misleading — wrapping the URL as
`redis_opts: [url: url]` forwards the keyword list to Redix, which
rejects `:url` with `unknown options [:url], valid options are:
[:host, :port, :password, ...]`. New pods on commit b4d6b04 onward
crashed at startup with that NimbleOptions.ValidationError; old pods
running e53a34d (top-level :url) coasted on already-established
connections so the breakage only surfaced when fresh pods rolled.

The library accepts `redis_opts` as either a URL binary OR a
keyword list. Pass the URL as a bare string so it takes the
binary-clause path that hands it directly to Redix.PubSub.start_link/1.
2026-05-04 08:29:30 -05:00
b4d6b0417b
fix(pubsub): nest :url under :redis_opts for phoenix_pubsub_redis 3.x
The library deprecated top-level Redis connection keys; passing :url
directly logged a warning on every boot. Functionally identical, just
silences the warning.
2026-05-03 17:27:05 -05:00
e53a34dd75
feat(pubsub): route Phoenix.PubSub through Valkey when configured
Switches the PubSub adapter to Phoenix.PubSub.Redis backed by the
shared Valkey instance (same backend GridCache uses), via the new
phoenix_pubsub_redis dep. Falls back to the default PG2 / Erlang-
distribution adapter when VALKEY_URL is unset, so dev/test boot
unchanged.

Decouples PubSub fan-out from libcluster's BEAM connection. A
flapping Erlang distribution (the OOM-cascade scenario from
2026-05-03 surfaced this — libcluster warnings dominated the logs
while pods CrashLoopBackOff'd) no longer takes broadcasts with it,
so the rest of the cluster keeps publishing while the bad pod
recovers.

node_name uses POD_IP for cluster-wide uniqueness, falling back to
Erlang's node() name in non-K8s environments. Required by the Redis
adapter so loopback filtering doesn't drop a pod's own broadcasts as
if they came from a peer.
2026-05-03 17:23:01 -05:00
9edaee9344
feat(weather): GridCache backend pluggable, Valkey for cross-pod cache
Each cached HRRR valid_time is ~32 MiB compressed × cap of 8 = up to
256 MiB per pod. With 4 hot pods + 1 backfill that's ~1.25 GiB of
cluster RAM holding identical content. Replacing the per-pod ETS
replicas with a single shared Valkey copy collapses that to ~256 MiB
total (off-pod) and makes the BEAM heap on each replica meaningfully
smaller — directly addresses the headroom the 2026-05-03 OOM cascade
ate into.

* New `Microwaveprop.Valkey`: single Redix connection (named conn,
  sync_connect: false, exit_on_disconnection: false). `start_link/0`
  returns `:ignore` when VALKEY_URL is unset so dev/test boot without
  Valkey. Helpers wrap GET/MGET/SET-with-TTL/ZADD/ZREVRANGE/SCAN with
  `:erlang.term_to_binary` round-tripping under the safe atom flag.

* `Microwaveprop.Weather.GridCache` now branches on
  `Valkey.configured?/0`. Public API is identical so callers (Weather,
  GridCachePruneWorker, MapLive) don't change. ETS path is preserved
  unchanged for dev/test.

* Storage layout when on Valkey:
    prop:wg:vts                   ZSET of valid_time iso8601 strings
    prop:wg:<vt>:<lat>:<lon>      one chunk per 5°×5° spatial bucket
  fetch_bounds MGETs only the chunks intersecting the viewport, so a
  regional view is 1-4 round-trips instead of pulling the full grid.

* Per-key TTL = 8 h (matches the ETS valid_time cap). Eviction is
  automatic; prune_keep_latest is a no-op on Valkey, prune_older_than
  uses ZRANGEBYSCORE + DEL.

* All Valkey errors fall back to disk (ScalarFile.read_bounds), logged
  as warnings — page works through Valkey outages, no user-visible
  break beyond a one-shot latency bump.

ScoreCache, Microwaveprop.Cache, NexradCache, telemetry/Postgres
protocol/libcluster registry tables remain ETS — they're sub-ms
hot-path lookups where a network hop would be felt.

VALKEY_URL must be present in prop-secrets for production rollout
(applied out-of-band, secret.yaml is git-ignored).
2026-05-03 17:00:51 -05:00
803130ee64
perf(weather): drop GridCache valid-time cap from 24 → 8
Found while auditing pod memory: :weather_grid_cache is by far the
largest ETS table (~32 MiB compressed per entry, vs ~2 MiB/entry for
ScoreCache) because each cell is a 22-field weather row instead of a
scalar score. Cap of 24 meant the cache could hoard up to ~768 MiB on
a single pod, and hot pods sit at 3-5 GiB against a 6 GiB limit — most
of that headroom belongs to GridCache.

The cap was sized for 'analysis + 18 forecasts + stragglers' but the
real access pattern is current-hour + a short forward scrub. Beyond
that, fall back to ScalarFile.read_bounds (sub-100 ms per file) instead
of permanently parking ~500 MiB on data nobody looks at.

Worst-case GridCache spend drops 768 MiB → 256 MiB. Steady-state win
is smaller (most pods don't fill all 24 slots), but bounds the tail.
2026-05-03 16:25:00 -05:00