Commit graph

1929 commits

Author SHA1 Message Date
327b4fc561
feat(rover-planning): inline per-group delete, drop standalone site list
The 'Rover sites' card no longer renders the long list of every site —
each rover-location now appears once as a path-profile group heading,
which carries its own delete button next to the n/m progress text. The
add-site form stays in place.
2026-05-03 13:54:46 -05:00
c2a23d1840
feat(rover-planning): hourly backfill worker recovers stuck paths
Adds RoverPlanning.backfill_paths/0 that walks every mission and
re-enqueues RoverPathProfileWorker jobs for any (rover × station) pair
whose Path is missing or not :complete. The path-profile worker
short-circuits on :complete, so this is idempotent.

Wires a new RoverMissionBackfillWorker into both dev (config.exs) and
prod (runtime.exs) crons at HH:30 — heals missions stuck in :pending
or :failed from transient elevation-API errors or interrupted
create_mission enqueues without operator action.
2026-05-03 13:52:27 -05:00
e84f28643f
fix(rover-planning): truncate long rover-site notes on a single row 2026-05-03 13:48:02 -05:00
39c4c287a5
feat(rover-planning): show callsign + grid (no bare coords) in stationary list
The Stationary-stations summary was rendering whatever the user typed
verbatim ('aa5c 33.1889, -96.4517'). Switch to a pair of helpers that
uppercase the callsign (covering legacy mixed-case rows) and synthesize
a 6-char Maidenhead grid from lat/lon when no grid was stored. Bare
coordinates no longer appear in this list — the grid carries the same
location info in a more amateur-radio-native form.
2026-05-03 13:41:53 -05:00
210e941db8
feat(rover-planning): propagation score column + linked location heading
The path-profile worker now also looks up the propagation grid score for
the path midpoint at the mission's band before flipping status to
:complete, so the row never appears 'done' until both terrain and
propagation prediction have run. The show table renders that score as a
red/yellow/green badge in a new Propagation column.

Each rover-location group heading is now a link to /rover-locations/:id
so the user can jump straight to the spot's detail page from the
mission view.
2026-05-03 13:35:54 -05:00
d3b96725c8
fix(rover-planning): two-line station, drop noise columns, fix verdict badge
Path table now shows callsign on top with grid muted below, and the
detail-only Min clearance / Diffraction columns are gone. The verdict
badge matched lowercase 'clear/blocked/marginal' but the worker stores
uppercase 'CLEAR/BLOCKED/FRESNEL_PARTIAL/FRESNEL_MINOR' from
TerrainAnalysis, so every cell silently rendered empty. Updated the
guard clauses to the actual upstream strings and added Fresnel variants.
2026-05-03 13:26:57 -05:00
bd77d1a5b0
feat(rover-locations): edit status + notes alongside marker drag 2026-05-03 13:10:47 -05:00
08ac76a4b8
refactor(rover): pattern-match-first event handlers + missing test branches
show.ex (rover-planning) and show.ex (rover-locations) auth + delete
flows go from nested case/case/case to a `with` ladder fed by a single
`authenticated/1` clause that returns `{:ok, user}` or
`{:error, :unauthenticated}`. The else block enumerates the small set
of failure tuples instead of rebuilding nested error returns.

Other idiomatic-Elixir tightening:
- `progress_summary/1` reduces with a `tally_path/2` multi-clause
  helper (status as a head pattern, not three Enum.count passes).
- `paths_by_rover_location/1` extracts `group_to_pair/1`,
  `station_position/1`, and `group_lat/1` so each transformation is a
  pattern match instead of an anonymous fn with `&& fallbacks`.
- `error_summary/1` is two clauses (empty vs populated errors)
  instead of a pipe-into-case.
- Drag-to-edit show.ex consolidates the working-coords assigns into
  `assign_working_coords/3`.

Bug fix uncovered while writing tests: `add_station` on a fresh
/rover-planning/new form was a visual no-op on the first click —
cast_assoc replaced the unseeded default Station struct with the new
params row instead of appending. Now the initial changeset is built
from `%{"stations" => %{"0" => %{"position" => "0"}}}` so add_station
appends from the get-go. Regression test added at
test/microwaveprop_web/live/rover_planning_live_test.exs.

New test branches:
- form: add_station appends on first click + remove_station drops
  the targeted row.
- rover-planning show: add_rover_site whitespace-input rejection,
  delete_rover_site permission denial.
- rover-locations show: save_edit rejects unauthenticated drivers.

Suite: 3228 tests, 0 failures. Credo strict: 0 issues.
2026-05-03 13:01:36 -05:00
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
0711d9af11
ci: rebuild prop-base weekly to pull Debian security patches
Adds a Sunday 06:00 UTC schedule trigger to build-base.yaml plus a
CACHE_BUST=<ISO year+week> build-arg keyed off `date -u +%G-W%V`. The
apt RUN steps in Dockerfile.base reference the arg in their command
line so BuildKit's layer hash changes when the week rolls over,
forcing apt-get update + install to re-execute and pull fresh
security updates.

The wgrib2-builder stage doesn't see CACHE_BUST so the ~5-min wgrib2
compile keeps hitting layer cache across weeks; only the apt layers
rebuild. Effective cost of the weekly job: ~2 min for the cdo /
gdal-bin reinstall.
2026-05-03 11:51:08 -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
5ac625915d
ci: switch app Dockerfile to FROM prop-base
The wgrib2-builder stage and the runtime apt-install stages
(libstdc++6/openssl/locale + cdo/gdal-bin) now live in
git.mcintire.me/graham/prop-base, built separately by build-base.yaml.
The app Dockerfile FROMs that image and just COPYs the Elixir release
on top.

Net effect: typical pushes drop ~8 min of wgrib2 compile + ~2 min of
cdo/gdal-bin apt installs. Bumping wgrib2 or g2c is now a one-line
change to Dockerfile.base — the next push picks up :latest
automatically.
2026-05-03 11:46:28 -05:00
8ea31e4317
ci: add prop-base image build pipeline
Pre-builds the runtime base (wgrib2 + g2c + cdo + gdal-bin + locale +
runtime apt deps) into git.mcintire.me/graham/prop-base. The base
workflow is path-filtered to fire only on Dockerfile.base /
build-base.yaml changes, so version bumps stay deterministic and
explicit.

Tag convention: `wgrib2-<v>-g2c-<v>-<unixts>` for traceability +
rollback, plus `:latest` for everyday consumption.

This commit only adds the new files — the app Dockerfile + build.yaml
still build wgrib2 from source. A follow-up will swap the app
Dockerfile to FROM prop-base:latest once this image lands in the
registry.
2026-05-03 11:40:50 -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
0b0718e047
feat(rover): Maidenhead grid overlay on location detail map
LocationMap hook now adds a togglable grid layer (matching the contact
map pattern) so the rover-location detail page shows the standard
Maidenhead overlay alongside the marker.
2026-05-02 17:14:45 -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
1312d84bb2
fix(aprs): handle empty APRS_DATABASE_URL and dedupe boot warning
- Empty-string env var (k8s secret resolves to '') was passing the nil
  check and handing Postgrex an empty URL — now treated the same as unset.
- Drop runtime.exs warning; keep the single warning in application.ex
  where the supervisor decision is made.
2026-05-01 12:29:16 -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
b14c87126c
fix(prop-grid): keep HRDPS lat=50 boundary row to close /weather seam
HRDPS at 0.5° step previously dropped its lat=50 row inside HRRR's lon
range, so its first painted cell sat at lat=50.5 (halfStep=0.25 →
50.25°N). HRRR's top row at lat=50 paints to 50.0625°N. The ~0.19°
(~21 km) blank strip between them was visible on /weather wherever the
viewport crossed the US-Canada border.

Make in_conus_bbox exclusive on the north edge (lat < LAT_MAX) so the
boundary row stays in HRDPS. The merge_prefer_hrrr layer dedupes by
exact (lat, lon) on the merged read path, and the canvas overlay paints
HRRR above HRDPS, so the new overlap is invisible everywhere HRRR has
data.

Won't take effect on prod until the next HRDPS chain run produces fresh
chunk files.
2026-04-30 16:44:53 -05:00
abb93c9511
feat(weather): always-visible close button on weather detail panel 2026-04-30 14:04:14 -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
b6fb7d2a13
feat(weather): main map renders HRRR + HRDPS in parallel
/weather now fetches both grids and paints each as its own canvas
overlay at its native cell size (HRRR 0.125°, HRDPS 0.5°). Server-side
chunk filtering means a US-only viewport gets an empty HRDPS pack and
vice versa — no wasted payload. /weather-ca's HRDPS-only mode is
unchanged.

Hook state moves from single (pack/overlay/abort) to per-source maps
keyed by "hrrr"|"hrdps". Render order is fixed (hrdps first, hrrr
second) so HRRR's higher-res canvas stacks on top along the border
where both sources cover the viewport.
2026-04-30 12:47:15 -05:00
fe7d998f67
fix(weather-ca): paint HRDPS cells at 0.5° size, not HRRR's 0.125°
The canvas overlay hard-coded a 0.0625° half-step when projecting each
cell to canvas pixels — correct for HRRR (0.125° native grid) but wrong
for HRDPS, which we downsample to 0.5° (0.25° half-step). At HRRR's
half-step every HRDPS cell got painted at 1/16th its actual area, so
the overlay over Canada looked like a sparse pinstripe of vertical
streaks instead of contiguous coverage.

Plumb halfStep through the WeatherCanvasOverlay constructor and pick
0.25 vs 0.0625 in the hook based on data-source.
2026-04-30 12:34:24 -05:00
47031e17f1
fix(prop/cron): fire HrdpsGridWorker 3x/hour to survive rolling deploys
The hourly :35 fire was reliably being missed in production: the prop
deployment is pinned to a single replica, and frequent ArgoCD deploys
restart the pod often enough that some fires land in the swap window.
Between 13:35Z and 17:25Z today the cron fired exactly zero times.

Switch to :05/:25/:45. Each fire calls seed_current_hour which is
idempotent on the (run_time, forecast_hour, kind, source) unique
index, so duplicate inserts in the same hour are no-ops at the DB
level and don't add cost on the happy path. Three shots per hour
gives two retry windows when a deploy kills the pod mid-cron-tick.
2026-04-30 12:30:00 -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