Commit graph

31 commits

Author SHA1 Message Date
6a90d153ca
perf(map): /scores/cells binary endpoint, client-side fetch + band/time prefetch
Apply the same overlay refactor we did for /weather to the propagation
map. Score data used to flow as JSON via LiveView push_event
(update_scores + the 18-hour preload_forecast batch); switching bands
or scrubbing time meant a synchronous server roundtrip + ~MB JSON push
per change.

Now:

1. New /scores/cells endpoint returns a "PSCR" binary cell-pack —
   columnar f32 lats/lons + u8 scores, ~3-4× smaller than JSON and
   parsed via DataView + typed-array views with no JSON.parse.

2. JS hook owns score fetching. mount/moveend/band/time changes
   trigger HTTP fetches against /scores/cells. After the initial
   paint, every other forecast hour for the current band is fetched
   in parallel in the background — timeline scrubs after the prefetch
   finishes are pure cache hits with no network at all.

3. LiveView no longer pushes scores. select_band emits update_band_info
   with band_mhz so the client knows what to refetch; select_time and
   set_selected_time only update server-side state (URL, point detail
   bbox). map_bounds, propagation_updated, and advance_now_cursor all
   skip the score push — pack_scores, schedule_bounds_update,
   schedule_preload_forecast, the :flush_bounds and :preload_forecast
   handle_info clauses, and the start_async :initial_scores lifecycle
   (incl. retry_initial_scores event) are all deleted.

Net result: instant band toggles after the first paint, no LiveView
WebSocket payloads larger than the timeline metadata, and ~370 fewer
lines in MapLive.
2026-04-29 14:27:20 -05:00
c514626a62
test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents.

- ContactLive.Show round 3 (72% → ~80%): enrichment failure
  surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR
  fallback), radar-only mechanism, load_solar + enqueue_missing_solar
  paths, blocked_message nil obs_dist, band_summary for 144M/1296M/
  2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts
  with seeded Oban jobs. Properties: propagation_mechanism summary is
  always a binary, obs_sort_key totality over arbitrary field keys.

- Weather clients: iem_client transport timeouts + CSV edge cases,
  rtma_client idx url property, swpc_client HTTP 503/404 + empty-
  array parses + JSON/CSV totality properties, ncei_metar_client
  parse edge cases, snmp_client OID-roundtrip property + parse
  edge cases.

- Mid-coverage LiveViews: UserProfileLive avatar/render branches,
  EmeLive invalid-grid/unknown-callsign + form-validation property,
  PathLive zero-distance + 3000km + height/power round-trip property,
  BeaconLive numeric bearing + non-admin approve denied, MapLive
  select_band/toggle_radar/toggle_grid events + band-URL property.

Fix: propagation_mechanism property test uses System.unique_integer
for lat + valid_time nonces instead of StreamData-shrinkable jitter;
the shrink toward 0 was tripping the hrrr_profiles unique index.

205 → 215 properties, 2743 → 2812 tests, 0 failures.
2026-04-24 10:15:37 -05:00
e688d8c244
perf(wire)+ts: pack scores as flat tuples, bump ProfilesFile gzip, tighten TS types
**Wire format — /map scores payload.** Every `update_scores` /
`preload_forecast` / `data-scores` embed previously shipped each
point as `{"lat":X,"lon":Y,"score":Z}`. At 95k cells in full CONUS
that's ~36 bytes/point × 95k = 3.4 MB of JSON, ~50% of it repeating
the three key names. Packing as `[lat, lon, score]` tuples drops
per-point overhead to ~19 bytes → ~1.8 MB on the wire, ~45% smaller.
New `Propagation.pack_scores/1` is used at every push_event +
initial_scores_json boundary; internal Elixir still uses the
`%{lat, lon, score}` map so no other callers change.

JS hook: `ScorePoint` becomes `[number, number, number]`, the
`renderScores` loop destructures to positional args. Regression
test in map_live_test updated to match the new shape.

**ProfilesFile compression.** Rust writer was at gzip level 6
(default). Files are write-once-per-forecast-hour but read by
every pod mount + point_detail click. Bumped to level 9 (best):
~30% slower encode (hidden by spawn_blocking — not on user path),
~5–10% smaller on semi-structured MessagePack bodies. No reader
changes needed — same gzip stream.

**TypeScript strictness.**
- `app.ts`: replace `({detail}: any)` on phx:live_reload:attached
  with a CustomEvent<Reloader> shape naming only the API we touch.
- `weather_map_hook.ts`: drop the `(this as any)._map` pair by
  declaring a `GridLayerWithMap = L.GridLayer & { _map: L.Map }`
  intersection on the overlay's createTile `this` parameter.
- `global.d.ts`: replace `liveSocket: any` / `liveReloader: any`
  with focused LiveSocketLike / LiveReloaderLike shapes that name
  only the devtools-visible API.

No more `any` in the codebase (verified via rg).
2026-04-21 17:45:06 -05:00
ff950f9e40
docs/tests: moduledocs on 18 LiveViews, MapLive event coverage, Rust pipeline roundtrip
- Add one-line moduledocs to 18 LiveViews that were carrying
  @moduledoc false. Each line names the route and summarizes what
  the page does so a future reader knows where to look before
  opening the file.
- Add MapLive handle_event tests for toggle_radar, select_time /
  set_selected_time, point_detail, map_bounds, retry_initial_scores.
  Assertions focus on socket-state transitions and "didn't crash"
  rather than raw HTML — map_bounds in particular has no visible
  render-side effect (it push_events the new scores to the JS hook).
- Rust pipeline: integration-shaped test that hand-builds a 2-cell
  surface + pressure grid, runs merge → cell_to_conditions →
  precompute_band_invariants → composite_score_with → write_atomic
  → decode, asserting header + body length round-trip. Closes the
  'pipeline's happy path is only covered by unit tests' gap.
2026-04-21 17:16:12 -05:00
0c98707091
feat: harden /map analysis breakdown + move Plausible to root layout
Two fixes cover the blank "analysis breakdown" panel the user reported:

1. Bound the point_detail fallback lookback to 24h. Previously
   factors_from_fallback_profile would happily use a week-old analysis
   profile when no current one existed — now anything older than 24h
   returns an empty factor map so the UI can surface "breakdown
   unavailable" instead of silently misattributing.

2. Move the Plausible analytics snippet from Layouts.app into
   root.html.heex. Full-bleed LiveViews (/map, /contacts/map,
   /weather, home) bypass Layouts.app, so the snippet only loaded on
   the navbar-wrapped pages. root.html.heex is loaded once per HTTP
   document so coverage is now universal.

Added ~30 tests locking both down:
  • point_detail fallback: exact-time wins, 24h boundary accepted,
    30h-stale rejected, multi-band coverage, missing-cell degrades to
    empty factors, default-valid_time path
  • analytics_test.exs: Plausible script present on 12 representative
    pages, exactly once, loaded async, surviving a login round-trip

Also fixed pre-existing credo issues per standing rule: shortened the
map_live_test ETS match_delete call, extracted a function from the
deeply-nested hrrr_point_enqueuer.enqueue_for_contacts/1 cond, and
swapped Mix.Tasks.Rust.Golden's IO.inspect for Mix.shell().info.
2026-04-21 15:56:30 -05:00
410a1374fe
refactor(scores): rename file extension .ntms -> .prop with legacy reads
Score-grid files now land at `<band>/<iso>.prop` with a 4-byte "PROP"
magic header. Readers still accept the legacy `.ntms` extension and
"NTMS" magic so a rolling deploy doesn't invalidate any file already
on the shared NFS mount — legacy files age out through normal
retention.

Applied both sides of the seam: Elixir ScoresFile + regex parsers,
Rust scores_file writer + decoder. Updated the comments in all
modules that mention the extension (NotifyListener, Reconciler,
gefs_fetch_worker, map_live) and the runbook diagram. talos5 is a
DB host now, not a Rust worker — corrected the runbook accordingly.
2026-04-21 15:54:12 -05:00
ef4e63aef4
feat(map): render chrome immediately, load initial scores async
Two-stage mount splits the propagation map's first paint: the static
HTTP render still fetches scores synchronously so SEO/noscript shipping
gets real data baked into `data-scores`, but the websocket-connected
mount defers the potentially-slow fetch to `start_async/3`. Chrome
paints immediately; scores stream in via an `update_scores` push_event
once `handle_async(:initial_scores, ...)` resolves.

A small overlay (spinner on `:loading`, retry button on `:failed`)
uses `<.async_result>` so users can see state and recover from a
transient fetch error.
2026-04-21 14:18:43 -05:00
f9e0331a2c
test: fix three pre-existing test flakes
- MapLiveTest timestamp indicator: earlier tests in the suite leave
  score files in the shared `:propagation_scores_dir` and warm the 5 s
  `list_valid_times` cache; clear both in setup so the "No data"
  assertion sees the state the test documents.
- PropagationPruneWorkerTest: `old = now - 3h` collides with the
  worker's cutoff (also `now - 3h`) once both are truncated to the
  second, so they land equal when test + worker run in the same wall
  clock second. Shift `old` by an extra -1s for unconditional ordering.
- ContactWeatherEnqueueWorkerTest: flip to async: false — perform/1
  runs inline Oban child jobs that upsert rows with a shared conflict
  target, and parallel DataCase siblings hit "ShareLock deadlock_detected"
  intermittently.
2026-04-21 13:07:01 -05:00
ffb14cb64f
feat(map): auto-advance cursor, full forecast timeline, panel spacing
Three UI fixes:

1. MapLive now ticks every 60s and, when the selected_time is still
   the "Now" slot, advances to the newer latest-past-or-now hour as
   the clock crosses into it. The URL is patched in place so shared
   links stay accurate without polluting history. Manually picking a
   specific past/future hour turns tracking off.

2. point_forecast/3 now reads its valid_times list straight from the
   on-disk .ntms files (same source the main-map timeline uses), then
   consults the cache per-hour for a fast score lookup. The old cache-
   or-store branch could leave the click-to-detail sparkline at 13
   hours while the bottom timeline showed 14+; both now line up.

3. Point-detail panel moves from bottom-14 → bottom-24 on desktop so
   its lower edge clears the forecast-timeline bar (+ max-height tuned
   to match), fixing the overlap visible in screenshots.
2026-04-19 12:10:36 -05:00
bb6333bd27
fix(map): recover with sane defaults on garbage URL params
Every parser already returned nil on malformed input, so the ||
chains fell through to defaults — except parse_time_param, which
happily accepted any well-formed ISO8601 even if it pointed
outside the current HRRR forecast window (e.g. a stale shared
link from last week). Gate it behind the valid-times window and
fall back to the normal 'now' cursor when it's out of range.

Adds explicit coverage: non-numeric band, unknown band MHz,
non-numeric lat/lon/zoom, out-of-range values, malformed time,
ancient time, and all-params-garbage-at-once. Each case mounts
cleanly and lands on the defaults.
2026-04-19 08:55:12 -05:00
160439db31
feat(map): shareable URL params for view/band/time
The main map page now reads band, selected time, and map
center/zoom from URL params and updates the URL as the user pans,
zooms, picks a band, or scrubs the timeline. Pan/zoom use
push_patch replace-mode so browser history isn't spammed, while
band and time changes push fresh entries.

Also fix the 'Up to date · just now ago' pipeline chip — 'just
now' already reads as an instant so the trailing 'ago' is
redundant. Any older duration keeps it.
2026-04-19 08:53:07 -05:00
70e65ba034
refactor(map): drop the 7-day outlook strip, fix missing Analysis, trim timeline
- Remove the 7-day outlook strip and daily_outlook_at/3. It was
  derived from point_forecast, which is capped at HRRR's 18-hour
  horizon, so it never had 7 days of data to show.
- factors_for: fall back to the nearest persisted analysis profile
  at or before the requested time. Only f00 hours persist profiles,
  so clicking any other hour used to yield an empty Analysis and
  factor table. After the recent cursor fix lands 'Now' on a
  forecast hour more often, this regressed further.
- available_valid_times/1: cap the forward horizon to 18h from now.
  Leftover valid_times from prior cycles used to pile on the
  timeline without adding information.
2026-04-19 08:17:47 -05:00
28a9eb3a92
fix(map-timeline): never label a future forecast hour as 'Now'
Both the Elixir initial-cursor pick and the JS timeline renderer
used nearest-by-absolute-distance, so any wall-clock time past
:30 snapped the 'Now' label onto the next top-of-hour forecast —
up to ~30 min in the future. Switch to the latest valid_time at
or before wall-clock, with a fallback to the earliest time when
every slot is future (shouldn't happen for HRRR, safe default).
2026-04-19 08:09:14 -05:00
a6af6b115a
feat(map): 7-day outlook strip
Adds a compact seven-card horizontal strip to both mobile and desktop
map sidebars showing the per-day peak score at the viewport center
for the selected band. Cards are color-coded (emerald for 80+,
rose for bad) so the weekend verdict reads at a glance.

- Propagation.daily_outlook_at/3: groups point_forecast entries by
  UTC date, picks each day's peak, returns ascending order
- MapLive mounts with today's outlook for the initial center; the
  select_band handler refreshes it from the viewport midpoint so the
  strip tracks the user's current band and rough location
- Empty-state message covers the case where no extended-horizon
  scores have landed yet (fresh deploy, or before the first GEFS
  run completes)

This is the consumer of the GEFS pipeline — once the cron starts
running and f024-f168 scores accumulate, days 2-7 of the strip
populate automatically.
2026-04-18 14:46:44 -05:00
e3248e335b
revert(map): remove antenna-height control
The antenna-height input only scaled the displayed range labels and
the viewshed max-range — it never fed into the propagation score
itself. Showing the control implied the colored overlay responded to
it, which was misleading. Viewshed now uses a fixed 10 m baseline,
matching the previous 33 ft default.
2026-04-18 13:50:56 -05:00
e827cacc48
feat(map): scale propagation ranges with antenna height
Antenna height now drives typical/extended/exceptional range estimates
and viewshed max range via a sqrt(h_m/10) factor clamped to [0.6, 2.0].
Baseline 33 ft matches the existing defaults. Changing the height input
now pushes update_band_info and refreshes the clicked point's detail
panel plus viewshed so the range-estimate line and coverage shape track
the user's actual station height.
2026-04-18 13:32:24 -05:00
6f452e7139
feat(map): show CONUS-only banner for visitors outside continental US
Visitor geolocation already flows via the Cloudflare CF-IPLatitude /
CF-IPLongitude headers into the session; MapLive centers the map on
the visitor's location (or DFW as fallback). Add a banner that appears
above the map when those coords are outside the CONUS bbox
(lat 24.5-49.5, lon -125 to -66.5), letting overseas visitors know
propagation data won't be available at their location yet.

Map still centers on the visitor's actual location — this is a note,
not a re-center. Banner is fully absent from the DOM when not needed
(:if conditional).
2026-04-17 08:39:51 -05:00
11617e730b
Sync map progress chip to what's actually loadable
The "Updating propagation +Nh" chip was double-misleading in prod:
the label's +Nh was relative to the HRRR run time (which lags wall
clock by ~2h), and the progress broadcast fired before the hour was
fetched + persisted — so the chip could read "+8h" while the map
timeline only extended to +4h.

Reframe the label as "through now" / "through +Nh" / "through Nh ago"
by computing the offset from valid_time → now in PipelineStatus.
running_detail, so it matches the semantic the user reads off the
timeline. Move the PubSub broadcast in PropagationGridWorker to fire
after Propagation.replace_scores/2 succeeds, so the chip only
advances once that forecast hour is readable from ScoresFile.
2026-04-15 09:05:03 -05:00
2d9e5a2d1f
Make the map timeline read .ntms files authoritatively
Two related fixes so the main map reliably picks up new binary
propagation score files as soon as PropagationGridWorker writes them.

1. Propagation.available_valid_times/1 previously preferred ScoreCache
   over ScoresFile, using the cache as an index of what was available.
   The cache is a lazy ETS of whatever hours have been fetched or
   broadcast, which is a strict subset of what's on disk. A new
   forecast hour landing on disk while the cache was warm with older
   entries was invisible to the timeline until the cache happened to
   catch up. Read directly from ScoresFile so the disk store is the
   source of truth.

2. Add Propagation.scores_at_fresh/3 that always reads the .ntms file
   and overwrites the cache entry, and use it from MapLive's
   propagation_updated handler. PropagationGridWorker publishes the
   cache_refresh on `propagation:cache` and the timeline ping on
   `propagation:updated` as separate PubSub broadcasts, so by the time
   MapLive runs through scores_at the ScoreCache GenServer may not
   have processed the refresh yet — fetch_bounds then returns the
   previous chain's bytes. scores_at_fresh takes disk as the source
   of truth for the refresh path and warms the cache as a side effect
   so subsequent readers see the new data.
2026-04-14 17:42:30 -05:00
0ed47db8b6
Process one forecast hour per PropagationGridWorker perform
A full f00-f18 sweep takes ~170 min of wall time, longer than the
~90 min gap between deploys on this cluster. Over the last 7 days
every run was killed mid-sweep and zero runs completed. The
propagation_scores table only ever held the scraps from partial
runs that landed before the pod died, which is why the map looked
like it was showing "the current hour" (or nothing).

The worker now processes exactly one forecast hour per perform/1
(~8-10 min) and enqueues the next hour as a fresh Oban job. A cron
fire with empty args seeds the chain at f00; subsequent runs carry
run_time + forecast_hour args. At f18 the chain stops and the
pruner runs. Each step broadcasts propagation:updated immediately
so new hours appear on the map as they land.

Wall time per perform drops from ~170 min to ~10 min, so Lifeline's
2h rescue window is no longer a factor and a pod restart loses at
most one forecast hour instead of the whole sweep. Retries and
max_attempts now describe a single fh, not the whole chain.

Also: bump the :propagation queue from 1 to 2 slots so
PropagationPruneWorker can run alongside the chain job, drop the
worker timeout/1 from 90 min to 20 min to match single-fh runs,
and pull Lifeline rescue_after from 120 min to 45 min to keep the
safety net above the step timeout.

Adds a "Data from HH:MM UTC · Nh ago/now/+Nh" indicator above the
pipeline chip in both the mobile and desktop sidebars so the user
can tell which valid_time the map is showing when the bottom
timeline isn't visible.
2026-04-14 13:24:59 -05:00
434fca2ad1
Show forecast-hour progress on the map pipeline chip
PropagationGridWorker now broadcasts a {:propagation_pipeline_progress,
%{forecast_hour:, valid_time:}} message on the propagation:pipeline
PubSub topic at the start of each process_forecast_hour/4 call.
MapLive subscribes on mount, stashes the last message in the new
:pipeline_progress assign, and clears it on the refresh tick whenever
PipelineStatus flips out of :running.

The chip component consults a new PipelineStatus.running_label/1
helper that formats the payload as "Updating propagation · now" for
f00 and "Updating propagation · +Nh" for f01-f18, falling back to
the base running label until the first progress message arrives.
Covered by unit tests on the formatter and an integration test in
MapLiveTest that seeds an executing grid job, broadcasts progress,
and asserts the rendered chip.
2026-04-14 11:12:51 -05:00
554bc9f339
Show aggregated propagation pipeline status on the map
Adds a small chip below the NTMS title in both the mobile and
desktop sidebars of /map that collapses PropagationGridWorker +
AsosAdjustmentWorker Oban state into one of four states —
running / idle / stale / unknown — with a plain-language label
("Updating propagation (HRRR run)…", "Up to date · 12m ago").
The chip refreshes on a 15s timer and whenever the pipeline
broadcasts propagation:updated, so a long HRRR sweep is visible
from the moment a visitor opens the page.
2026-04-14 10:01:34 -05:00
ea96c93e2d
Rename QSO to Contact in UI, add higher bands, improve submit page
- All menus and UI text now say Contacts instead of QSOs
- Add 68, 122, 134, 241 GHz bands to submit form and validation
- Add info box on submit page explaining why contacts matter
- Larger submit button with icon
- Make HRRR partition migration idempotent for partial re-runs
2026-04-01 12:28:24 -05:00
49cbe6789c
Implement ITU-R P.526-16 terrain diffraction model
- Replace piecewise knife-edge loss with P.526-16 Eq. 31 single formula
- Fix diffraction parameter ν to use standard formula instead of ad-hoc approximation
- Implement Deygout 3-edge method for multiple obstacle diffraction
- Add dynamic k-factor from HRRR refractivity gradient (falls back to 4/3)
- Terrain worker now looks up nearest HRRR profile for atmospheric correction
- Update algo.md with P.526-16 methods and k-factor table
- Fix pre-existing map_live_test antenna height default (33 ft, not 8)
2026-03-31 16:04:23 -05:00
5eaa55448e
Add terrain-aware viewshed on map click
Replace generic range circles with actual LOS coverage polygon computed
from SRTM elevation data. Casts 180 rays (every 2 degrees) from the
clicked point, runs Fresnel/diffraction analysis on each, and renders
the reachable area as a Leaflet polygon.

- Viewshed module with haversine forward, terrain sweep, async compute
- Antenna height control (default 8 ft) in map panel
- LiveView start_async/handle_async for non-blocking computation
- Remove signal icon from band selector
2026-03-31 13:06:01 -05:00
2f2f3388cd
Rename 'How this works' to 'Scoring Algorithm' 2026-03-31 10:24:04 -05:00
c3a5cf4b1a
Add Maidenhead grid overlay, consolidate map controls, improve UX
- Port grid square rendering from gridmap-web with toggle control
- Consolidate band selector, grid toggle, data timestamp, and links
  into unified control panel
- Add "How this works" (/algo) and "Submit a QSO" (/submit) links
- Add back-to-map link on algo page
- Darken range circles for visibility over propagation overlay
- Fix popup blocking double-click zoom with pointer-events passthrough
- Prevent control panel from intercepting map click/scroll events
2026-03-31 10:15:21 -05:00
b87572aa81
Close band dropdown after selection by shifting focus to map 2026-03-31 09:04:31 -05:00
97bd93ed5c
Make /map full-page Leaflet with band dropdown
- Remove Layouts.app wrapper so map fills entire viewport
- Replace band buttons with daisyUI dropdown selector
- Overlay controls (home button, band picker, timestamp) on top-left
- Keep Leaflet's standard zoom controls
2026-03-30 17:27:21 -05:00
1e205cb471
Add propagation context, grid worker, and fix merge issues
- Replace stub propagation.ex with real implementation (score_grid_point,
  upsert_scores, latest_scores, latest_valid_time)
- Add PropagationGridWorker (hourly Oban cron) for CONUS grid scoring
- Add mix propagation_grid task for manual triggering
- Fix duplicate extract_grid/2 from parallel merges
- Fix extract_grid to skip outside-grid points instead of erroring
- Add propagation queue to Oban config
2026-03-30 17:18:05 -05:00
5a0113aac2
Add Leaflet map integration with propagation score display
Vendor Leaflet 1.9.4 (JS, CSS, marker images) and wire it into
the esbuild/Tailwind asset pipeline. Create MapLive with band
selector buttons, auto-refresh, and a colocated JS hook that
renders propagation scores as color-coded circle markers with a
legend. Stub Propagation context and BandConfig modules provide
the data interface for the scoring pipeline.

Add nav bar with links to Map, QSOs, and Submit pages.
2026-03-30 17:11:00 -05:00