Commit graph

7 commits

Author SHA1 Message Date
e3d430f8c4
feat(weather): add duct cutoff band map layer
Show on /weather where the detected duct geometry traps each microwave
band. Overview mode bins cells by their lowest trapped frequency
(Bean & Dutton cutoff); band-picker mode masks cells whose duct doesn't
reach the selected band. Cutoff is pre-computed per cell in both
writers (Elixir derive + Rust derive_row reads best_duct_freq_ghz from
CellValues), persisted to ScalarFile, and shipped to the JS hook via
the existing binary cell pack. Also cleans up six pre-existing
length/1 credo warnings in unrelated test files.
2026-05-15 19:51:24 -05:00
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
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
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
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
a14f14485e
perf(weather): serve /weather from chunked scalar artifacts
Move the /weather render path off raw HRRR profile decoding + per-cell
SoundingParams + WeatherLayers derivation. The dominant cost was
re-deriving the same scalar fields on every viewport pan and timeline
scrub.

Server side:

- New Microwaveprop.Weather.ScalarFile persists pre-derived scalar
  rows on NFS, bucketed into 5°×5° chunk files under
  <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport
  reads only decode the chunks that overlap the requested bounds;
  point-detail clicks read exactly one chunk.
- weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile;
  ProfilesFile is now the cold-start fallback only.
- warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0
  also persist the scalar artifact, and the cold-derive path kicks
  off a per-valid_time-locked async materialization so the next
  reader gets the cheap path.
- NotifyListener.handle_propagation_ready/1 fires
  Weather.materialize_scalar_file/1 in a detached Task on the Rust
  pipeline's NOTIFY propagation_ready, so steady-state forecast hours
  arrive with their scalar artifact already on disk.
- available_weather_valid_times/0 unions ScalarFile + ProfilesFile so
  the timeline survives an aggressive retention sweep on either side.

Browser side (finding #8):

- Mount the legend Leaflet control once and patch its inner content
  on layer changes instead of remove + re-add on every renderLayer.
- renderTimeline now keys off the rendered button set: selection-only
  updates take an applyTimelineSelection patch path that restyles
  existing buttons in place. Full innerHTML rebuild only fires when
  timelineData itself changes.

Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting
interpolate_pair/2 from the inner anonymous function.
2026-04-28 17:15:36 -05:00