ScalarFile.list_valid_time_dirs/1 stripped only a `.hrdps` suffix
before DateTime.from_iso8601/1, so `<iso>.sgrid` and
`<iso>.hrdps.sgrid` never parsed and were skipped by both
prune_older_than/1 and retain_window/2. Since .sgrid is the format the
pipeline actually writes, nothing on the dense tier was ever deleted —
production held 222 files spanning 8 days against a 48h window.
Strip the known suffixes in order, mirroring ProfilesFile's anchored
parser (which documents this exact failure mode for .hrdps.prop).
Orphaned .tmp.* writes still don't parse, so they stay with the tmp
sweep in Propagation.prune_old_scores/0.
Also corrects the CLAUDE.md entry that recorded this as already fixed —
the call site was present, the suffix parsing was not.
Raising the area cap to 4600 put it above the largest area clamp/1 can
emit (45° lat × 100° lon = 4500 sq deg), so :viewport_too_large became
unreachable and the 413 handlers in both cell controllers became dead
code.
Remove the cap, the error branch, the max_viewport_area_sq_deg/0
accessor and both payload_too_large/1 handlers. The clamp is what
bounds request cost: post-clamp nothing can ask for more than the full
grid (~92k cells, ~1.4 MB), which is materialized once per valid_time
and served from cache, so a global viewport costs no more than a fully
zoomed-out map.
Replaces the cap test with one asserting the clamp never lets absurd
input escape the supported extent — the property the guard was
standing in for.
The map sets minZoom: 4, and a z=4 viewport on a wide window clamps to
the full grid extent (45° lat × 100° lon = 4500 sq deg), which exceeded
the 4000 sq deg cap. The request 413'd and the hook dropped it
silently, so zooming all the way out blanked the overlay.
Raise the cap to 4600 — just above the largest area the clamp can
emit. The clamp, not this cap, is what bounds the work: post-clamp no
request can exceed the full grid (~92k cells, ~1.4 MB), which is
already materialized once per valid_time and served from cache. The
cap stays as defense-in-depth against the clamp window being widened
later.
Consequence: :viewport_too_large is now unreachable via clamp/1, so
the tests that pinned global bounds to a 413 are inverted to assert
the clamped extent is served.
duct_field/2 did d["base"] || d["base_m"], but ProfilesFile.read/1
atomizes both keys via its @mp_atom_keys whitelist. String indexing an
atom-keyed map returns nil, so the Duct Base and Duct Strength layers
were always nil on the cold-derive path and in point-detail popups —
despite the comment directly above claiming both shapes were handled.
Normalize keys before lookup, the way duct_min_freq/1 already did, and
share that normalization between the two.
weather_grid_hrdps_at/2 is the only production caller and backs every
/weather/cells?source=hrdps request, but it read the legacy chunked
.mp.gz dirs exclusively. The pipeline writes <iso>.hrdps.sgrid, so the
snap found a valid_time via list_valid_times_hrdps/0 (which does see
.sgrid) and then read zero chunks for it.
Delegate to read_bounds_hrdps/2, which already does the .sgrid-first,
chunk-fallback read.
Every existing test for this function hand-wrote legacy chunk dirs,
which is how the .sgrid migration slipped past. Added coverage against
a real .sgrid artifact, including the mid-migration case where both
formats exist for one valid_time.
RoverPathProfileWorker broadcasts {:rover_path_updated, path_id} after
updating a path. handle_info fetches via get_path!/1 and splices into
@paths, but get_path!/1 did not preload :rover_location — causing a
KeyError on %Ecto.Association.NotLoaded{} when the template accessed
location.id. Preload [:rover_location, :station, :mission] matching
the pattern in list_paths/1 and path_live.ex.
The ANALYZE queries run via Task.Supervised which doesn't hold
sandbox ownership. rescue only catches exceptions; sandbox-pool
checkout failures come as exit signals. Add catch :exit so the
Task logs a warning instead of crashing.
patch_beacons_json/2 and remove_from_beacons_json/2 were calling
Enum.reject/2 directly on @beacons_json, which is a JSON-encoded
string from Jason.encode!/0 (not a list). This crashed with
Protocol.UndefinedError when no approved beacons existed (string
was "[]") and a PubSub broadcast triggered an update.
Decode with Jason.decode!/1, operate on the list, then re-encode
with Jason.encode!/1 so the template's data-beacons attribute
remains a valid JSON string for the JS hook.
Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.
Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.
Includes automated DateTime.add → DateTime.shift migration from
mix format.
- Add Phoenix.Ecto.SQL.Sandbox.allow to all start_async closures in ContactLive.Show (8 sites) and PskrSpotsLive (4 sites), fixing silent empty results when async Tasks accessed DB in tests
- Add File.exists? skip-guards to wgrib2_test.exs (14 guards) and hrrr_client_test.exs (3 guards) so tests skip gracefully when GRIB2 fixtures are absent
- Remove leftover debug IO.puts from SandboxHook
- Fix stale homepage assertion in html_module_test.exs
- Add cdo and wgrib2 to nix shell.nix and Forgejo CI apt-get install
Test suite: 4049/4054 passed (99.9%), 5 remaining failures are environment gaps (cdo/wgrib2 not yet in CI runtime)
- Delete Microwaveprop.Aprs, AprsRepo, Aprs.PathParser modules
- Delete Mix.Tasks.Calibrate.Aprs144 mix task
- Remove AprsRepo from application.ex children and config files
- Remove APRS test files and sandbox references in test_helper
- Fix credo nested-module alias suggestions in conn_case.ex
- Switch polylines and circle markers to shared L.canvas() renderer,
eliminating ~58k SVG DOM nodes in favor of 1 GPU-composited canvas
- Add server-side callsign/date filtering to /api/contacts/map endpoint
to reduce payload size when filters are active
- Add early-exit fast path in applyCallsignFilter when no filter active
- Batch rebuildDots with requestAnimationFrame to avoid redundant work
during rapid filter changes
- Add Canadian HRDPS model coverage (to 60°N)
- Document Rust pipeline (prop-grid-rs) and its performance characteristics
- Add .sgrid/.pgrid binary format details
- Note ~6s chain step and 23-band fused pass
- Remove Nx/Axon/ExLA ML model references (scaffolding was removed)
- Update roadmap: drop model training, add PSKR calibration, propagation alerts
- Fix factor count (was 9, is 10) in roadmap item
Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.
Also fixes a broken NOTIFY that made every chain step run up to 5 times.
pg_notify
`NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
utility statement whose payload must be a literal, so a bind raises
42601. It shared a transaction with the `status='done'` UPDATE, so every
successful step rolled back, stayed 'running', and was requeued by
reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
NotifyListener never fired either, so ScoreCache warm and the
"propagation:updated" fan-out were dead.
FieldGrid
A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
on decode, ~3.7M on merge and ~14M lookups across three derivation
passes. wgrib2 -lola already emits one dense row-major f32 block per
message, so keep it: dense per-message planes, names hashed once per
grid into plane ids, NaN as the missing sentinel. This is what forced
PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.
Fused pass
Three 95k-cell derivation passes plus 23 band-major scoring passes over
a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
times) collapse into one pass: levels extracted once per cell, all 23
bands scored while the cell is hot, scores accumulated cell-major so
rayon chunks own disjoint slices. Scores land straight in the dense
score-file body — no ScorePoint scatter.
.pgrid
The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
file to return one cell on every map click and Skew-T load. Replaced
with a dense cell-major f32 record array carrying a self-describing
field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
readable so files written before this drain out of the 48h window.
Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
derive + score + build artifacts 0.022 s
profile write 3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
single-cell read whole-file decode -> 0.5 us
23 score files 0.003 s
Also
- hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
depends on it.
- fetcher: real semaphore capping in-flight ranges at
MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
(it spawned all 27 while the connection pool was sized for 8).
- metrics: per-stage histogram. Only chain-step and decode durations
were instrumented, which is why the write cost stayed invisible.
- profiles_file: parse_valid_time anchors on the known extension set, so
sibling-suffixed names like <iso>.hrdps.prop no longer parse as
<iso>.hrdps and vanish from prune and list operations.
- PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
observed at the new parallelism.
- cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
were already unformatted at HEAD and the pre-commit hook gates on it.
HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
- Drop skippy.w5isp.com:8080 as HRRR_BASE_URL in dev/prod config and
all K8s manifests. Default to direct NOAA S3 reads.
- Strip hrrr_cache_dir disk-caching infrastructure from HrrrClient
(read_cache, write_cache, download_and_cache_grib_ranges). Files are
now fetched fresh each time — no local filesystem cache.
- Update stale skippy references in network-policy, test comments, docs.
Microwaveprop.Propagation.Model only exists in lib_ml/ which is excluded
from prod elixirc_paths. Using apply/3 instead of direct @ml_module
calls avoids compile-time warnings when the module is absent. Added
credo:disable-for-next-line inline annotations since Credo flags
apply/3 with known arity but the runtime dispatch is intentional.
Also adds --warnings-as-errors to Dockerfile RUN mix compile so the
build fails on any project-level warning.
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT
P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1
P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades
P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
1. RoverPathProfileWorker sandbox ownership: fallback_hits/2 used
Task.async_stream spawning separate DB-querying processes that
lacked Ecto sandbox ownership in test mode. Replaced with
sequential Enum.map since miss list is ≤9 points — no meaningful
perf impact and eliminates the sandbox race entirely.
2. PSKR client test: asserted '6m' band in defaults, but the actual
microwave band name is '6cm'. Fixed assertion.
Uncommitted work from previous session:
- Add list_users_select/0 helper for admin select dropdowns
- Add regenerate_token/1 context function
- Preload assigned_by association on monitor queries
- Add admin nav link to beacon monitors
- Fix JS event targets (remove redundant target: @myself)
- Add reassign user form to admin monitor show page
- Add regenerate token button to admin monitor show page
- Show assigned_by admin on monitor detail page
- Add beacon monitors section to user management edit page
- Add unassign monitor capability from user management
- Add full test coverage for all new functionality
- Fix pre-existing /account route warning (redirect → /users/settings)
The LiveTable on /admin/contact-edits used the bare ContactEdit schema
as its data source, which caused three symptoms:
- '0 pending' counter but stale approved/rejected edits still visible
- Blank contact/submitted-by cells (select_columns stripped preloaded
associations, cell renderers received flat maps with no :contact/:user)
- Approve/reject didn't remove the row from the table
Fix: assign {Radio, :pending_edits_query, []} as the data_provider in
mount so handle_params threads it to stream_resources. The query variant
of list_resources preserves preloaded associations and includes the
WHERE status = :pending filter.
Added two tests that verify the table rendering and edit removal.
- Add hardware/config fields migration to beacon_monitors table
- Update BeaconMonitor schema with provision/config changesets
- Add context functions: create_hardware, update_config, list_all_monitors
- Remove user-facing monitor creation (browser POST + API POST)
- Update settings page: show assigned monitors table with hardware info
- Update profile page: show assigned monitors, remove register links
- Fix all tests to match new API
Add a Beacon monitors card to /u/:callsign that appears only when the
viewer is the profile owner. Shows registered monitors with truncated
token and last-seen timestamp, with a link to manage monitors in
settings. Includes tests for visibility rules.
Consolidate verify_magic_link/confirm/password_reset_token_query into
one-line delegations to a private helper, removing 29 lines of
duplicated decode/hash/query logic.
- accounts.ex: Repo.transaction in ecto_sql 3.14 wraps all return values in {:ok, ...},
causing callers to receive {:ok, {:error, ...}} and {:ok, {:ok, ...}} instead of
direct tuples. Unwrap in update_user_email and update_user_and_delete_all_tokens.
- rover_planning_test.exs: switch to async: false + Oban.Testing manual mode so
RoverPathProfileWorker jobs don't deadlock on sandbox connections inside
Repo.transaction. Introduced create_and_complete_mission!/2 and run_backfill!/1
helpers that separate transaction lifecycle from worker execution.
- Extract shared extract_bearer/1 from api/monitor_auth.ex → api/auth.ex
- Refactor admin_changeset to pipeline through validate_callsign/validate_name/validate_email
- Add limit: 500 to list_beacons/0 and candidate_rover_locations/1
- Combine get_mission_with_paths preloads into single round-trip
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
Previously required ≥6-char locators; 4-char fields (~70×100 km)
were dropped. They're less precise for HRRR calibration but still
useful for spot display and coarse path analysis. Lowers the
minimum from 6 to 4 characters.
Co-Authored-By: Claude <noreply@anthropic.com>
Some FT8/Q65 multi-decoder spots omit sender/receiver callsigns
from the JSON payload, but the MQTT topic routing path always
carries them in positions 5 (sc) and 6 (rc):
pskr/filter/v2/<band>/<mode>/<sc>/<rc>/<sl>/<rl>/<sa>/<ra>
Client now extracts callsigns from the topic and threads them
through Aggregator.ingest → Pskr.parse_spot, where they act as
fallback when the JSON sc/rc keys are nil or empty.
JSON payload values still take precedence when present — the topic
is only used as a safety net.
Co-Authored-By: Claude <noreply@anthropic.com>
RepoListener now connects directly to Postgres on port 5432 for
NOTIFY/LISTEN, bypassing pgbouncer (port 6432) which does not
support session-level LISTEN in transaction-pooling mode.
Additionally, connection and LISTEN are moved from init/1 to
handle_continue/2 so the GenServer starts immediately and the
pod boots healthy even when the DB is temporarily unreachable.
Connection failures are retried with exponential backoff
(200ms → 30s max). The notifications process is monitored and
reconnected if it dies.
Falls back to the configured port if port 5432 is unreachable
(e.g. dev/test environments without pgbouncer).
Co-Authored-By: Claude <noreply@anthropic.com>
PSK Reporter tab:
- Fix handle_info(:refresh_spots) to use start_async instead of blocking
- Add require Logger to fix compiler warning
- Make band counts clickable: clicking a band filters table to that band's
last 100 spots; clicking again clears the filter
- Highlight active filter badge, show Clear filter link when filtered
- Update subtitle and empty state dynamically based on filter
- Filter persists across auto-refresh (60s)
- Fix empty state condition to render immediately without async guard
Credo fixes (mix credo --strict now passes):
- weather_map_component.ex: replace @doc false with @impl true (missing spec)
- contact_weather_enqueue_worker.ex: extract hrrr_placeholder_for_contact/3
to reduce nesting depth
- profile_lookup.ex: replace MapSet+then+reject pattern with Enum.filter
to fix both nesting depth and cyclomatic complexity
Test infrastructure fixes:
- Fix Release.migrate/0 to handle repos without migration directories
(AprsRepo in test has no migrations)
- Fix insert_spot helper: add missing 19th param for inserted_at/updated_at
- Fix String.index/2 -> :binary.match for Elixir 1.20 compat
- Fix DateTime.add!/3 -> DateTime.add/3 for Elixir 1.20 compat
- Fix substring matching in limit test (GRID1 matched GRID10)
- Add Process.sleep+render calls for async band_counts in new tests
- Replace impossible 'empty state for filtered band' test with
'switching band filter updates table' test
- All 19 tests pass, mix credo --strict clean
- Fix handle_info(:refresh_spots) to use start_async instead of blocking
LiveView process on DB fetches
- Add require Logger to fix pre-existing compiler warning
- Make band counts clickable: clicking a band filters table to that band's
last 100 spots; clicking again clears the filter
- Highlight active filter badge, show Clear filter link when filtered
- Update subtitle and empty state dynamically based on filter
- Filter persists across auto-refresh (60s)
- Add 5 test cases for band filtering behavior
Close 6 cleanup gaps on the shared /data NFS mount:
- HRDPS .hrdps.prop score files: parse_valid_time regex now matches
the .hrdps.prop extension, so these files are found and pruned
- HRDPS scalar dirs (weather_scalars/{iso}.hrdps/): strip .hrdps suffix
before DateTime.from_iso8601 in list_valid_time_dirs
- ScalarFile: prune_older_than was defined but never called from
Propagation.prune_old_scores - now called alongside Scores/Profiles
- Orphan .tmp.* files: sweep all three base dirs (scores/profiles/scalars)
for .tmp.* files left by crashed atomic-write processes
- Building cache (/data/buildings/): add MsFootprints.prune_older_than
with 30-day mtime cutoff
- Canopy cache (/data/canopy/ + staging/): add Canopy.prune_older_than
with 30-day cutoff, cleanup empty staging dir
All cleanup is cutoff-based (older than X time) so if the prune worker
is missed for any period it catches up fully on the next run.
PropagationPruneWorker updated to call all new cleanup functions.