Commit graph

809 commits

Author SHA1 Message Date
89a5cfefd2
feat(rover-locations): drag-to-edit marker with live grid+coord preview
Owner / admin click "Edit" on /rover-locations/:id and the marker
becomes draggable. As they drag, the JS hook pushes `location_dragged`
events with the new lat/lon; the LiveView updates `working_lat` /
`working_lon` / `grid` so the page shows the live preview without
writing to the DB. "Save" persists via Rover.update_location, "Cancel"
reverts both the assigns and the marker position.

Implementation notes:
- Switched the map marker from L.circleMarker to a draggable L.marker
  with a divIcon (CircleMarker has no `dragging` handler).
- A second `draggingDotIcon` (amber, larger, grab cursor) makes the
  edit affordance obvious.
- Server <-> hook coordination uses push_event:
  set_marker_draggable / reset_marker. Drag results come back on
  pushEvent("location_dragged", { lat, lon }).
- Coordinates row binds to working_lat/working_lon so the displayed
  decimals + derived grid update on every drag, not only on save.
2026-05-03 12:51:12 -05:00
8fd9759e4e
feat(rover-planning): add/remove rover sites inline on the show page
A new "Rover sites" section between Stationary stations and Path
profiles lists the candidate rover locations the mission scores
against, with:

- An add form that takes the same flexible input as station inputs
  (callsign, Maidenhead grid, or `lat,lon`); on submit it creates a
  global :good rover-location and re-runs the path matrix.
- A per-row trash button visible to the location's owner / admins
  that deletes the location and re-runs the matrix.

`RoverPlanning.candidate_rover_locations/1` is now public so the show
page can list exactly what the worker enqueues against. Add/remove
both call `replace_mission_paths/1` so the matrix stays consistent
with the rover-site set.

Anonymous visitors see a sign-in prompt instead of the form.
2026-05-03 12:47:07 -05:00
5c3a791665
fix(rover-planning): derive grid from lat/lon for coord-only stations
When a user types raw "lat,lon" coordinates into a station input,
LocationResolver populates only `lat` / `lon` — `callsign` and `grid`
stay blank. The path-profiles "Station" cell was falling through to
"33.189, -96.452" instead of showing a useful grid.

Now `station_label/1` (display) and `station_endpoint/1` (path-URL
destination) derive a 6-/8-char Maidenhead grid from the stored
lat/lon when no grid is on the row, matching the rest of the page's
grid-first presentation.
2026-05-03 12:43:32 -05:00
7edfc64996
fix(path): stop warning on expected native-duct cache misses
`Weather.nearest_native_duct_info/3` returns `{:error, :not_found}`
whenever the midpoint has no native HRRR profile within ±0.07° / ±1 h
— the documented fallback for the sparsely-populated
`hrrr_native_profiles` table (only QSO-worker-touched cells get
ingested). The empty-map fallback was already the right behavior; the
warning was just noise that fired on every /path computation outside
the populated set.

Now the `:not_found` clause silently returns the same fallback shape
without logging.
2026-05-03 12:38:29 -05:00
dbd4a78502
feat(rover-planning): clickable rows + callsign·grid labels + 10-char heading grid
- Rows in each rover-location group are now phx-click handlers that
  navigate to /path with source (rover-loc as 10-char Maidenhead grid),
  destination (callsign > grid > coords), band, and rover/station
  heights pre-filled — one click takes you to a ready-to-compute Path
  Calculator screen.
- Station label now shows "AA5C · EM12kp" when both callsign and grid
  resolved, instead of falling back to lat/lon. Bare-grid and
  bare-coord stations keep their existing labels.
- Group heading uses Maidenhead.from_latlon(lat, lon, 10) so the
  10-char grid is shown in full instead of being truncated to 6
  characters.
2026-05-03 11:49:18 -05:00
a18586046d
feat(rover-planning): group path profiles by rover location
The flat path table forced you to scan column 1 to mentally group rows
when standing at a specific rover spot. Each rover location now gets
its own card with a heading (grid + lat/lon + notes) and an inner
station-by-station table, so the paths to the fixed stations from the
spot you're at are read in one place.

Sorted by latitude (north → south) so the same location keeps the same
slot across re-renders. `rover_label/1` is removed (its content now
lives in the group heading).
2026-05-03 11:34:36 -05:00
cff0c6775d
fix(rover-planning): suppress fresh-station error + tolerate integer JSONB values
- add_station no longer seeds `"input" => ""` in params, so used_input?/1
  treats the new row as untouched and hides the validate_required error
  until the user types.
- Show page's format_distance/format_db/format_meters helpers coerce with
  `* 1.0` because JSONB returns bare zeros as integers, which crashed
  Float.round/2 (FunctionClauseError on /rover-planning/:id render).
- Lat/lon row uses items-start + mt-6 trash so the inputs stay aligned
  even when the callsign cell grows from validation feedback.
2026-05-03 11:29:57 -05:00
eca463add1
fix(rover-planning): keep only_known_good checked across phx-change events
The hidden 'false' shadow input was rendered after the checkbox, so when
both were submitted the 'false' value overwrote the checkbox 'true' in
the resulting params map. Move the hidden input before the checkbox so
the checked value wins (matching the core_components checkbox pattern).
2026-05-03 11:19:14 -05:00
3c9fbcdec6
feat(rover-locations): /rover-locations/map page with Maidenhead overlay
Adds a full-screen map view rendering every Good rover-location as a
green circle marker (popup links to the detail page). Uses the standard
OSM/Satellite + Maidenhead grid overlay pattern. Bad locations are
excluded. Adds a Map button next to Add location on the index header.
2026-05-03 11:14:58 -05:00
209244f364
refactor(rover-locations): rename status atoms ideal→good, off_limits→bad
Renames the rover-location status enum and every label that referenced
it. Existing rows are migrated in place. Also touches the consumers:

- Rover.Location enum + default
- RoverLocationsLive index, status filter, form, show, badges
- Rover.Compute and rover_live (only_ideal_locations → only_good_locations)
- RoverPlanning context candidate filter (status == :good)
- RoverPlanning.Show / Form copy
- All rover-related tests
2026-05-03 11:13:18 -05:00
d1a5442afb
feat(rover-planning): /rover-planning page with path-profile worker
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
  notes, "only check against known good locations" toggle (default on),
  and a dynamic list of stationary stations entered as callsigns,
  Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
  LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
  matrix of computed path profiles (distance, min clearance, diffraction,
  verdict) populated as the worker completes each pairing

After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.

Admins can edit/delete any mission; owners can edit their own.
2026-05-03 11:04:11 -05:00
a3fff198c9
feat(contact-map): date-range filter alongside callsign filter
Adds start/end date inputs (UTC) to the sidebar (mobile + desktop) with
Apply dates and Clear buttons. The hook keeps a sliced YYYY-MM-DD on
each line entry so the range check is a string compare during filter
re-application; combines with the callsign filter so e.g. AA5C between
2025-01-01 and 2026-01-01 is one query.
2026-05-03 10:36:34 -05:00
01341d20f3
fix(contact-map): wrap callsign filter in form, add Filter button
Bare-input phx-change wasn't reliably delivering the value to the
filter_callsign handler. Wrapping the input in a form with phx-submit
+ a Filter button gives explicit submission (Enter or click) on top
of the existing debounced live filter.
2026-05-03 09:54:44 -05:00
d052dcaa91
feat(rover): admins can edit/delete any rover location
Rover.update_location/3 and delete_location/2 now resolve any record
when the actor is_admin. The index + show LiveViews surface Edit/Delete
on every row for admin users, matching the existing owner UX.
2026-05-02 17:04:52 -05:00
558980a191
polish(rover): drop redundant View action; location cell already links 2026-05-02 17:02:56 -05:00
d44351366a
feat(rover): /rover-locations/:id detail page with map + marker
Each row now has a View link (and the location cell links too) to a
new show LiveView that renders the full record alongside a Leaflet
map centered on the marker. Owners get a Delete button there as well.
2026-05-02 17:02:00 -05:00
4a82571cda
feat(rover): two-way grid <-> lat/lon sync on location form
Adds a Maidenhead grid input to the rover-location form. Typing a grid
fills in lat/lon (using square center); editing lat/lon recomputes the
grid at 6-char precision. The phx-change `_target` decides direction so
edits never clobber what the user just typed.
2026-05-02 16:50:18 -05:00
7eafa4c71a
refactor(rover): /rover-locations as paginated table with status filter
Reuses the LiveTable pattern from /contacts and /users so the page gets
sort, search, and pagination. Adds an in-page Ideal/Off Limits filter.
2026-05-02 16:45:56 -05:00
6c5d066121
fix(metrics): defer ETS cron flusher on /metrics scrape
Without the deferral the cron-driven ETS flush GenServer fires every
7.5s alongside Prometheus scrapes, calls PromEx.get_metrics in a Task,
and dies with `Task.await ... time out` (hardcoded 10s) under contention.
Mirrors upstream PromEx.Plug, which calls defer_ets_flush after a
successful render specifically to suppress the cron while scraping is
healthy.
2026-05-02 10:59:24 -05:00
af08e72816
polish(aprs): consolidate callsign regex, document raise, note negative-sample v1 limit
- Extract PathParser.valid_callsign?/1 and reuse from the calibration
  Mix task instead of duplicating the regex.
- Document raise behavior on Aprs.recent_packets_with_paths/1 and
  Aprs.station_positions/1 (already noted in moduledoc; @doc reinforces
  for callers reading the function head).
- Note in the BandConfig guard comment why it sits before the Oban pause
  (so a misconfig exits without leaving queues paused).
- Reword the band-mismatched-negatives note as a v1 limitation (no TODO
  tag — codebase convention is zero TODOs).
2026-05-01 13:10:57 -05:00
81e3a54a97
refactor(aprs): mix calibrate.aprs_144 surfaces silent drops + guards
- Print '<computed> / <attempted>' for both positive (HRRR-coverage) and
  negative (random-baseline → HRRR) factor compute stages so an operator
  can see how much of the corpus survived each filter.
- Guard at task entry: Mix.raise if BandConfig has no 144 MHz entry.
  Without it the Recalibrator silently falls back to 10 GHz physics.
- random_baseline now draws from sample_size: max(n, 5_000) so small-n
  fits don't cluster geographically.
- Wrap body in try/after Oban.resume_all_queues so iex -S mix workflows
  don't leave Oban paused after the task returns.
2026-05-01 13:03:47 -05:00
7a8623aed8
feat(aprs): mix calibrate.aprs_144 task for fitting 144 MHz weights
Pulls verified RF hops from aprs.me's 24h packet retention, computes
factor vectors at each hop's midpoint via the existing Recalibrator,
and runs gradient descent against random-baseline negatives.

Dry-run: prints proposed weights but does NOT mutate band_config.ex.
Refuses to fit on <50 positive or negative samples.
2026-05-01 12:57:41 -05:00
a7f5bd5f18
refactor(aprs): drop unreachable defensive branches, force UTC since
- decode_packet_row no longer guards Decimal.to_float / DateTime.from_naive
  with passthrough clauses — Postgrex returns Decimal for numeric and
  NaiveDateTime for timestamp without time zone, so the WHERE clause
  filters out the only path that could have hit the alternate branches.
- recent_packets_with_paths shifts :since to UTC before to_naive so a
  caller passing a non-UTC %DateTime{} doesn't silently match the wrong
  window.
- moduledoc now documents that the module raises on connection/SQL
  errors and is unsafe in async contexts without a catch.
2026-05-01 12:52:11 -05:00
65e97bec6b
feat(aprs): add Aprs context for read-only queries against aprs.me 2026-05-01 12:47:35 -05:00
f4a7696cf2
fix(aprs): tighten Q-construct allowlist and broaden WIDE/TRACE alias regex
Q-construct now matches the explicit APRS-IS set (qAC/qAX/qAU/qAo/qAO/
qAS/qAr/qAR/qAZ/qAI) instead of any qA[A-Za-z]. WIDE/TRACE n-N now allows
n and N up to 7 per the APRS New-N paradigm (was [12]).
2026-05-01 12:43:23 -05:00
4759d1313e
fix(aprs): tighten PathParser self-loop check and Q-construct regex
Three spec-compliance fixes to PathParser:

1. Q-construct regex now accepts lowercase letters (qAo, qAr, etc.).
   The moduledoc lists qAo as a Q-construct to skip but the previous
   ~r/^qA[A-Z]$/ rejected it, leaving it to be misclassified.

2. self_loop?/2 now compares callsigns only; the spurious
   src_pos == dst_pos clause would falsely suppress two distinct
   stations that happen to share coarse coordinates (e.g. same building).

3. Strengthen the "lookup returned nil → drop hop, do NOT advance source"
   test: extend the path to three tokens so the assertion actually proves
   the third hop chains from the previous good source rather than from
   the dropped one. The two-token version passed regardless of behavior.

Self-loop fixture updated to trigger via callsign equality (the only
remaining mechanism) instead of position equality.
2026-05-01 12:40:17 -05:00
4a5864f090
feat(aprs): add PathParser for verified RF hops from TNC2 paths
Pure-Elixir parser that walks an APRS digipeater path left-to-right and
emits verified hops for callsigns carrying the used flag (`*`). Filters
Q-constructs, TCPIP/TCPXX, and routing aliases (WIDE/TRACE/RELAY/etc.);
handles anonymous-digi cases, missing station-position lookups, and
self-loops without advancing source past unattributable hops.

Used by upcoming 144 MHz calibration to attribute aprs.me receives to
real digipeater geometry without persisting any APRS data on our side.
2026-05-01 12:35:53 -05:00
714d1ce432
feat(aprs): add read-only AprsRepo for aprs.me database access
Wire up Microwaveprop.AprsRepo as an optional secondary Ecto repo that
points at the sister aprs.me Postgres for read-only access. This is the
foundation for upcoming 144 MHz calibration work that uses verified APRS
RF hops as ground truth.

* lib/microwaveprop/aprs_repo.ex declares a read_only repo; no schemas,
  no migrations, no writes — Microwaveprop never persists APRS data.
* Application supervisor starts the repo only when configured (dev/test
  via config blocks, prod via APRS_DATABASE_URL). A missing config logs
  a warning and skips startup so the main app still boots.
* config/dev.exs + config/test.exs add local aprsme_dev / aprsme_test
  endpoints. Test pool uses Ecto SQL Sandbox; tests that don't touch
  APRS data don't need to check the connection out.
* config/runtime.exs guards the prod block on APRS_DATABASE_URL —
  unset env logs a warning and leaves the repo unconfigured.
2026-05-01 12:25:33 -05:00
1f8765f757
fix(weather): tolerate stale map_bounds from /map → /weather navigation
/map's PropagationMap hook pushes `map_bounds` on every moveend/zoomend.
When a user live-navigates from /map to /weather, an in-flight bounds
push can land on the new WeatherMapLive (the same LV channel survives
the navigation), and WeatherMapLive's `handle_event/3` had no clause
for it — the LV process crashed with FunctionClauseError, taking the
user's session down.

WeatherMapLive doesn't need viewport bounds — the /weather/cells HTTP
endpoint reads them off the URL the client builds — so the safe fix is
a no-op handler. Tested.
2026-04-30 17:34:38 -05:00
b37692592b
fix(weather): default selected time to current hour, not clock-closest
Past minute 30 of the wall clock the next forecast hour is closer to
utc_now() than the current hour, so the picker would land on (e.g.)
19:00Z when the user is at 18:43Z. HRRR has 19:00Z, but the HRDPS
chain only seeds the *current* hour (18:00Z), so the dual-source map
showed an empty Canadian half whenever we crossed the half-hour mark.

Switch to "most-recent valid_time <= now", with a fallback to the
closest future when nothing on disk is yet at-or-before now (cold
start). Hoist the picker into a public function so it can be unit-
tested deterministically — anchoring the integration test on
DateTime.utc_now() truncated to the hour would have only verified the
new behavior past minute 30.

/weather-ca delegates to the same picker so the two maps agree on
"current."
2026-04-30 13:50:33 -05:00
040ebf9d3d
fix(weather): snap HRDPS reads to nearest valid_time within 6h
The /weather dual-source timeline picks times from HRRR's hourly
cadence, but HRDPS publishes 4×/day with multi-hour latency, so the
requested time almost never has an exact HRDPS dir on disk. The
controller was returning empty rows for HRDPS, leaving the Canadian
half of the map blank.

Snap to the closest HRDPS valid_time within a 6h window. /weather-ca
keeps exact-time semantics in practice because its timeline is built
from HRDPS-only listings, so the nearest is always the same time.
2026-04-30 13:46:32 -05:00
4901c83916
fix(weather-ca): drop the 1h freshness cutoff so HRDPS data renders
The 1-hour valid_time filter inherited from /weather is wrong for
HRDPS: the model publishes 4x/day with 3-4h publication latency and
the chain step adds ~13 min on top, so the freshest data on disk is
routinely 4-6h old. With the 1h cutoff in place, /weather-ca's
timeline was empty whenever there wasn't an active chain step running,
which left the JS hook with nothing to fetch. The sidebar already
shows the selected valid_time prominently — users can see for
themselves how stale the data is and don't need the page to hide it.
2026-04-30 12:24:34 -05:00
fdadba52b8
fix(prod-build): silence Nx.* undefined warnings in dev/test-only modules
Recalibrator and FrontalAnalysis use Nx for tensor math, but Nx is
declared `only: [:dev, :test]` in mix.exs because neither module has a
prod entry point — Recalibrator is invoked by hand from IEx and
FrontalAnalysis is research scaffolding. The prod compile would emit
"Nx.to_number/1 is undefined" warnings on every Nx call. Add a
`@compile {:no_warn_undefined, Nx}` directive to both modules so the
prod build is clean without dragging Nx/Axon/EXLA (~hundreds of MB) into
the runtime image.
2026-04-30 12:19:16 -05:00
ac3a2517d9
feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.

Also drops "— Unified" from the algo.md H1.
2026-04-30 12:08:48 -05:00
d5726a89d5
feat(hrdps): scope to current-hour-only at 0.5° step
The HRDPS pipeline as written was unusable in production: each chain
step took ~5 hours (not 30-90s as the dev-bench claim) because every
`wgrib2 -lon` batch redundantly JPEG2000-decodes ~150 records. With 57
batches × 18 forecast hours, a single cycle would take days to drain.

The /weather Canadian view only needs the current hour, so the cheapest
fix is to stop seeding the rest of the chain entirely:

* Rust grid: HRDPS_STEP=0.5 (was 0.125) drops point count 16× to ~3.5k.
  Coarser cells than HRRR, but /weather gets visible Canadian coverage
  inside one chain step (~20 min) instead of never.
* GridTaskEnqueuer.seed_current_hour/2 inserts exactly one forecast row
  whose valid_time is closest to `now`. fh is clamped to 1..18 so the
  Rust F00Reserved branch never fires.
* HrdpsGridWorker switches to seed_current_hour and drops the
  6-hour-cycle cron in favor of HH:35 hourly fires. Reseeds are
  idempotent on the 4-column unique key.

Also fixes a pre-existing credo "nested too deep" in
ScalarFile.read_point by extracting lookup_in_chunk + hit_or_false
helpers — happened to be on the read path that surfaces this work, so
folding it into the same commit.
2026-04-30 10:36:00 -05:00
aa8b789d2e
feat(weather/map): persist viewport to URL for reload-stable view
Adds lat/lon/zoom URL params to /weather so reload / share-link
restores the exact map state. The existing ?layer= param keeps
working alongside.

LiveView side:

- mount() reads lat/lon/zoom from query params, clamps to valid
  ranges (lat -90..90, lon -180..180, zoom 4..10), and falls back
  to DFW@z7 when missing or invalid. Initial values flow to the
  hook via three new data attributes.
- handle_event("viewport_changed", ...) accepts {lat, lon, zoom}
  pushed by the hook on debounced moveend, clamps + rounds, and
  push_patches the URL with replace: true so browser history
  doesn't grow an entry per pan tick.
- build_path/2 helper centralises URL construction so select_layer
  and viewport_changed both produce a deterministic param order
  (layer, lat, lon, zoom). Tests assert exact URLs.

JS hook side:

- mounted() reads data-initial-lat/lon/zoom from the el dataset
  with parseFloat/parseInt + Number.isFinite gates, then seeds
  Leaflet's center/zoom from those instead of the previous
  hardcoded (32.897, -97.038, 7).
- moveend handler now also schedules a 400 ms-debounced
  pushEvent("viewport_changed", {lat, lon, zoom}) so the URL
  catches up with whatever the user panned/zoomed to. Separate
  debounce timer from the cells fetch since the URL push and the
  network fetch have different cadence sweet spots.

Tests cover: initial seeding from URL params, clamping of bad
values, viewport_changed event producing the right patched URL,
and the original layer-shareability test (now expecting the full
viewport in the URL).
2026-04-30 09:10:36 -05:00
3607a915ba
feat(hrdps): /weather merges HRRR with HRDPS for north-of-CONUS cells
The /weather map now shows HRRR-derived data wherever HRRR has
coverage and falls back to HRDPS-derived data for Canadian cells
outside HRRR's bbox. North of the 49°N HRRR-coverage line the map
stays populated instead of going blank.

Rust:

- weather_scalar_file::dir_for_hrdps + write_atomic_hrdps land HRDPS
  scalar chunks at `<vt>.hrdps/` so HRRR's `<vt>/` write isn't
  clobbered. The HRRR writer wipes its dir before each write — both
  sources need their own dir to coexist.
- pipeline::run_chain_step_hrdps now derives a ScalarRow for each
  scored cell (same derive_row that HRRR uses; same wire format) and
  writes them via write_atomic_hrdps alongside the .hrdps.prop score
  files.

Elixir:

- ScalarFile.dir_for_hrdps + read_bounds + read_point + exists? all
  walk both `<vt>/` (HRRR) and `<vt>.hrdps/` (HRDPS). read_bounds
  concatenates with HRRR-precedence on overlap (defensive — Rust
  pipeline doesn't write HRRR-overlap cells, but cheap to enforce).
- Weather.weather_grid_at unchanged: it routes through ScalarFile,
  which now transparently returns the union. GridCache stays
  per-valid_time so a single cache entry holds both regions.

Tests cover four scenarios: HRRR-only, HRDPS-only, both-present
union, and HRRR-precedence on the same cell.
2026-04-29 17:36:03 -05:00
1da78a80e5
feat(hrdps): activate Canadian propagation chain end-to-end
Stages 6, 7, 10 of the HRDPS plan, plus the Elixir read-side merge
that makes Rust's `.hrdps.prop` writes show up on /map.

Read side:

- `ScoresFile.path_for_hrdps/2` + `read_hrdps/2` — companions to the
  HRRR equivalents. Same decode path; different filename.
- `ScoresFile.read_bounds/3` now reads HRRR + HRDPS files and
  concatenates the cells. Files are disjoint by construction
  (Grid.hrdps_only_points excludes CONUS) so no de-dup needed.
- `ScoresFile.read_point/4` checks `.prop`, then `.hrdps.prop`, then
  legacy `.ntms` — first hit wins. Cells outside their owning region
  read as no-data in the other file's grid, so the order doesn't
  matter for correctness.
- `Propagation.warm_cache_and_broadcast/2` reads both files and
  merges before broadcasting to the cluster ScoreCache. A single
  missing file (HRDPS pre-cycle, HRRR briefly absent) is now OK —
  the cache warms with whichever side is available.

Activation:

- runtime.exs cron: HRDPS seed worker fires at HH:35 of each cycle
  hour (05:35Z, 11:35Z, 17:35Z, 23:35Z) — 5h after cycle, 30 min
  staggered from HRRR's */15 schedule.
- HrdpsGridWorker moduledoc updated: no longer dormant; Rust
  prop-grid-rs handles source='hrdps' rows.
- map_live North America bbox extended to (24.5, 60.0, -141.0, -52.0)
  for the out-of-coverage banner check; visitors anywhere in CONUS
  or the Canadian extent now stay banner-free.

Cleanup:

- mix hrdps.probe deleted — its risks are retired and the production
  HrdpsClient covers the same surface.

What's still in motion (not blocking the Canadian /map):

- Per-valid_time profile + scalar artifacts for HRDPS (would let
  /weather show Canadian temperature/refractivity layers). Rust
  pipeline currently skips those so the HRRR-written companion files
  aren't clobbered — separate stage when /weather UX is decided.
- FreshnessMonitor HRDPS staleness tracking — HRDPS has its own cron
  and a different cadence than HRRR; HRRR-stale logic doesn't apply
  cleanly. Defer until prod tells us what alarms we actually want.
2026-04-29 17:29:37 -05:00
020f05f8eb
feat(hrdps): grid_tasks source column, Canadian mask, HrdpsGridWorker
Stage 3 (Elixir foundation) of the HRDPS plan. Lands the scaffolding
that lets the Rust prop-grid-rs HRDPS branch be wired up cleanly when
it ships, without further DB or Elixir-side changes.

What ships:

- `grid_tasks.source` column (default 'hrrr', backfill is a no-op).
  Replaces the (run_time, forecast_hour, kind) unique index with a
  4-column key (..., source) so HRRR and HRDPS analysis/forecast rows
  for the same cycle coexist.
- `Grid.hrdps_only_points/0` — Canadian cells inside HRDPS bbox
  (49-60°N, -141 to -52°W) but outside HRRR's CONUS bbox. Disjoint
  from `conus_points/0` by construction so the two grids never
  double-write the same (lat, lon).
- `Grid.point_source/1` — `:hrrr | :hrdps | :neither` classification
  for a (lat, lon) pair. Routes per-QSO enrichment to the right NWP
  source.
- `GridTaskEnqueuer.seed_with_analysis/2` and `seed/2` accept a
  `:source` option; defaults to "hrrr" so existing callers are
  unchanged.
- `HrdpsGridWorker` cycle seeder mirroring PropagationGridWorker's
  shape but for HRDPS's 4×/day cadence. `:hrdps` queue added to base
  Oban config.

What's deferred:

- The Rust `prop-grid-rs` worker doesn't yet branch on
  `task.source`. Until that ships, `HrdpsGridWorker` stays out of
  runtime.exs cron — it would just produce rows that the Rust worker
  mishandles as HRRR. Module doc explicitly notes the dormant state.
- Map overlay extension and FreshnessMonitor HRDPS staleness deferred
  to follow-up sessions once real data flows.

Activation path documented in the updated plan file.

Coverage cap recorded as Stage 8 decision: 60°N for v1 (SRTM stops
there). Arctic CDEM deferred to a follow-up plan.
2026-04-29 17:13:00 -05:00
15248239fe
feat(hrdps): hrdps_profiles partitioned table + HrdpsProfile schema
Stage 2 of the HRDPS plan. Mirror of hrrr_profiles for HRDPS-derived
rows so HRDPS can be dropped/reprocessed without disturbing the 4500+
HRRR profiles already in prod. Same column set, same indexes,
PARTITION BY RANGE (valid_time) with quarterly partitions starting at
the deploy quarter.

Schema follows the @primary_key {:id, :binary_id, autogenerate: true}
convention and the same set of derived fields HrrrProfile carries
(surface_refractivity, min_refractivity_gradient, ducting_detected,
duct_characteristics, etc.) so the rest of the pipeline can consume
HRDPS-derived rows interchangeably with HRRR-derived rows.

Unique constraint on (lat, lon, valid_time) is enforced at the DB
level via the partitioned index; schema-level unique_constraint/2
matching against changeset errors doesn't apply because the
partition's index name varies per partition. Production upserts go
through `on_conflict: {:replace_all_except, ...}` per project
convention.
2026-04-29 17:12:41 -05:00
139d9f7cbe
feat(hrdps): HrdpsClient — fetch_grid for Canadian propagation grid
Stage 1 of the HRDPS plan. Mirrors HrrrClient.fetch_grid/3's signature
so PropagationGridWorker can call either polymorphically.

Architectural shape per the probe wedge findings:
- ECCC's date-prefixed datamart URL structure
  (dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/...)
- One-variable-per-file model — fetch ~14 small GRIB2s concurrently,
  byte-concatenate into a single multi-record binary, hand to
  Wgrib2.extract_points_from_file. wgrib2 reads multi-record files
  natively so no -merge step is needed.
- wgrib2's -lon flag handles the rotated-lat/lon reprojection
  internally — no manual rotation math.

Variable catalog covers the surface set the propagation scorer reads
plus the 1000-700 mb pressure-level set used by the refractivity
gradient. PWAT is unavailable in HRDPS f000 inventory; the scorer's
PWAT factor will fall back to its default for HRDPS-derived points.

DEPR (T-Td depression) is treated as an alternate primary; when DPT
is absent build_profile_from_extracted derives dewpoint as
temp - depression so downstream consumers see the same shape as HRRR.

Plan task 1.6 (CYYR projection sanity check vs UWYO sounding) is
covered by the live probe at lib/mix/tasks/hrdps_probe.ex which
already validated wgrib2's rotated-grid handling against five
Canadian cities; the production implementation routes through the
same Wgrib2.extract_points_from_file path that the probe used.
2026-04-29 16:58:47 -05:00
14e81f9251
docs(hrdps): plan + probe wedge for Canadian propagation grid
Adds mix hrdps.probe — throwaway wedge that retires URL/projection/decode
risks against MSC Datamart. Verified against five Canadian cities with
plausible 2m temperatures via wgrib2's native rotated-lat/lon support.

Findings update what we knew:
- ECCC reorganized to date-prefixed paths; the old /model_hrdps/ root
  404s now. Real URL is dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/...
- HRDPS publishes one variable per GRIB2 file (~3.5 MB each), not
  HRRR's bundled wrfsfcf format. ~14 files per cycle/forecast hour.
- wgrib2 -lon LON LAT handles the rotated grid natively — no manual
  reprojection math needed.

New plan at 2026-04-29-hrdps-canadian-prop-grid.md supersedes the
2026-04-13 one (which assumed HRRR-style idx + byte-range fetches that
don't apply). Probe deletion is a Stage 10 step in the new plan.
2026-04-29 16:47:15 -05:00
c20aceba95
perf(map): chunk-keyed ScoreCache for viewport reads
Bucket each cache entry into 5°×5° spatial chunks matching the layout
used by Weather.GridCache. fetch_bounds/3 now walks only the chunks
intersecting the viewport instead of the full 92k-cell CONUS map; a
typical zoomed viewport hits ~1500 cells per chunk × the chunks the
viewport overlaps instead of scanning all 92k.

Public API and observable behavior unchanged. Add cross-chunk-boundary
regression tests so future refactors can't silently flatten the layout.
2026-04-29 16:47:03 -05:00
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
9e4ca154ff
fix(weather): drop valid_times older than one hour from the timeline
The HRRR scalar cache can hold a previous run's forecast hours past
their useful life — the timeline was offering "current conditions"
points that were 2-3 hours old, which is more misleading than helpful
on a map labeled "Now".

Filter Weather.available_weather_valid_times() through a `now - 1h`
cutoff in mount, :weather_updated, and :propagation_pipeline_progress
so the timeline only ever shows the most recent analysis hour plus
forecast hours.
2026-04-29 14:03:55 -05:00
2de1b89efc
chore(weather): drop tile endpoint and JSON cells back-compat
The client moved to the binary cell-pack and canvas overlay last
commit, so the per-tile SVG endpoint, the TileRenderer module, and the
JSON shape of /weather/cells are all dead code. Remove them.

/weather/cells now always returns the binary WCEL pack (the ?format=bin
toggle is gone — there's only one format). The data-weather-tile-url
attribute and the weather_tile_url assign are dropped from the LiveView.

Also fix a latent bug surfaced once tests covered the default-layers
path: nil row values were writing the atom :nan into the binary segment
at runtime. Replaced with the explicit f32 quiet-NaN bit pattern so
Number.isNaN flags them correctly on the JS side.
2026-04-29 13:45:43 -05:00
af8b787915
perf(weather): binary cell-pack + canvas overlay + lazy layer prefetch
Three changes that shrink first-paint and make layer toggles instant:

1. Server: /weather/cells now supports ?format=bin and ?layers=a,b,c. The
   binary "WCEL" pack is ~3× smaller than JSON (Float32 columns, no key
   overhead) and parses with DataView + typed-array views — essentially
   memcpy on the client.

2. Client: replaced JSON parse + per-cell SVG <rect> with a custom
   Leaflet canvas layer. The pack is columnar (lats/lons + Float32Array
   per layer) and each draw is one fillRect per cell against a
   precomputed 256-step color LUT. At low zoom (CONUS, ~80k cells) SVG
   paint cost dominated; canvas keeps it under one frame.

3. Lazy prefetch: the initial fetch only requests the *current* layer
   (~1/19 the bytes). Right after paint, a single background fetch
   pulls every other layer into the same pack. Layer switches that land
   after the prefetch finishes are pure recolor — no network, no
   server CPU.

The /weather/tiles endpoint stays for backward compat. The JSON path of
/weather/cells stays too — the binary path is opt-in via ?format=bin.
2026-04-29 13:41:21 -05:00
f4a177c9d0
perf(weather): single-fetch viewport cells, client-side recolor on layer switch
Previously every layer toggle triggered 21 fresh tile requests with the
new layer in the URL — ~21 server-side SVG renders, plus the round-trip
overhead. Even with the GridCache fix the server still spent real CPU
generating thousands of <rect>s per tile, and the user felt it.

Add /weather/cells: a single JSON endpoint returning every layer field
for every cell in the requested viewport. The hook fetches once on
mount, time change, or pan/zoom, caches the cells in memory, and paints
them via L.svgOverlay. Layer switches now re-color the same cached
cells locally — zero network, zero server CPU.

The single SVG uses lat/lon coords with preserveAspectRatio="none";
Leaflet stretches it linearly to the bbox. There is mild equirect→
mercator distortion at low zoom over CONUS but it's imperceptible at
typical use (z6+) and the layer-toggle UX win is large.

Tile endpoint kept for back-compat. svgOverlay opacity matches the
prior tile renderer's 0.55 fill-opacity.
2026-04-29 13:31:17 -05:00
b67c0e88c0
perf(weather): cache forecast-hour grids in GridCache to skip ScalarFile decode
Each /weather/tiles request was running the full ScalarFile decode path
(File.read → gunzip → Msgpax.unpack → normalize) for any non-analysis
valid_time, because GridCache deliberately skipped forecast hours to keep
memory low. With 21 simultaneous viewport tiles all hitting the same few
chunk files, each tile took 1.5-3.2s of contended IO + CPU.

On a GridCache miss when a ScalarFile exists, hydrate GridCache from the
file once (deduped via the existing claim_fill primitive) and serve all
subsequent reads from ETS. Bound memory with prune_keep_latest/1 — keep
the 24 most-recently-touched valid_times, which covers a full HRRR run
(analysis + 18 forecasts) plus a few stragglers from the previous run.

In the steady state, the first viewport read for a given forecast hour
takes one full ScalarFile decode (~50-100 ms for 92k cells); every
subsequent tile, point lookup, and viewport read for the same hour
serves from ETS in <1 ms.
2026-04-29 13:10:12 -05:00
9f583a16bf
perf(weather/scalar_file): single MessagePack format for both Elixir and Rust
Move ScalarFile from a dual-writer dual-format setup (ETF on the Elixir
side, materialized via NotifyListener; nothing on the Rust side) to a
single chunked-MessagePack format that both languages produce and
consume.

Elixir side:

- ScalarFile now writes gzipped MessagePack `.mp.gz` per chunk via
  Msgpax. Reader decodes with Msgpax + zlib. Atom keys are stripped to
  strings on encode and re-atomized via a whitelist on read so callers
  see the same shape regardless of which side wrote the bytes.
- DateTime values round-trip through ISO8601 strings.
- New cross-language wire-format test that decodes a hand-rolled
  payload mirroring exactly what `rmp_serde::to_vec_named` emits, so a
  format regression on either side fails the suite.

Rust side:

- New `prop_grid_rs::weather_scalar_file` module:
  - 1:1 port of `Microwaveprop.Weather.WeatherLayers.derive/1` plus
    the surface fields `Weather.build_grid_cache_row/4` adds on top
    (lapse rate, mid lapse rate, inversion strength + base, 850/700 mb
    interpolation, surface RH, surface refractivity, ducting flag).
  - `write_atomic` chunks rows into 5°×5° buckets, writes one
    `<lat_band>_<lon_band>.mp.gz` per chunk via tmp + rename, drops
    pre-existing chunks first to keep writes canonical.
  - 5 unit tests covering surface-only rows, upper-air derivation,
    write/read round-trip, chunk band boundaries, and the
    out-of-physical-range surface-temp filter.
- Pipeline integration: both `run_chain_step` (f01..f18) and the f00
  analysis path now build `Vec<ScalarRow>` in the same merged-cell
  pass that builds `CellEntry` and `Conditions`, then fire a parallel
  `spawn_blocking` write that overlaps with scoring. Awaiting the
  scalar future after the score-band writes keeps the failure-surface
  symmetric with the existing profile_future.

Result: every new forecast hour lands with the scalar artifact already
on disk, so /weather reads never fall through to ProfilesFile decode +
per-cell SoundingParams + WeatherLayers derivation. The Elixir-side
`NotifyListener.handle_propagation_ready/1` materialization remains as
an idempotent safety net for hours that pre-date this commit.
2026-04-29 08:26:28 -05:00