Commit graph

383 commits

Author SHA1 Message Date
77bc894f56
feat(skewt): add wet-bulb / virtual-T traces and right-edge level rail
Three additions that bring the diagram closer to the SPC sounding
viewer's full-information chart:

  * Wet-bulb temperature trace, plotted as a thinner blue line under
    the dewpoint trace. Comes from a Stipanuk-style bracket between
    the level's dewpoint and dry-bulb temperatures (already used by
    `SkewtParams` for the wet-bulb-zero level — now public so the
    renderer can call it per level).

  * Environment virtual-temperature trace and parcel virtual-T
    trajectory, both as thin pinkish dashed lines. The parcel
    virtual-T is the actual buoyancy curve CAPE integrates against,
    so showing it next to the dry-bulb parcel ascent makes the
    moisture correction visible. New `SkewtParams.virtual_temp_c/2`
    and `/3` helpers, plus `SkewtParams.mixing_ratio/2`.

  * Critical-level rail on the right edge. Each of LCL / LFC / EL /
    0 °C / WBZ that resolves on the current profile gets a short
    horizontal tick at the right of the plot and a `<label> <p> mb`
    tag just outside it — matches the SPC chart's `143 mb (mix)` /
    `158 mb (mix)` annotation column. SkewtLive computes the
    pressures (and converts the height-keyed 0 °C and WBZ from
    SkewtParams back to pressure via `pressure_at_height/2`) before
    handing them to `SkewtSvg.render/2`.

Wind-derived overlays (hodograph, wind barbs) remain off — HRRR
profile data is wind-free above 10 m AGL.

mix test: 27 / 27 across the four /skewt-related test files,
2,902 / 2,902 + 221 properties on the full suite. mix credo --strict
clean (one cyclomatic-complexity refactor extracted along the way).
2026-04-25 14:24:28 -05:00
b34b186e61
fix(skewt): clip grid + traces to the plot box
Skewed isotherms, dry adiabats, and mixing-ratio lines were drifting
outside the frame at the corners — most visibly the warm-side
isotherms exiting the right edge near the top of the diagram. The
existing `clip_polyline/1` only filtered with a ±200 px slack, so
segments crossing the boundary still drew past the frame.

Wrap every grid layer + the T/Td/parcel polylines in a
`<g clip-path="url(#skewt-plot-clip)">`. The clipPath defines a
rectangle from (left, top) to (right, bottom). Frame outline and
axis labels stay outside the clip group so they keep rendering at
the box edge unchanged. The hover-cursor crosshair also lives
outside the clip so its readout box can sit flush against the plot
corner without being trimmed.

mix test: 2,902 / 2,902 + 221 properties green. mix credo --strict
clean.
2026-04-25 13:30:25 -05:00
be1174914f
feat(skewt): NOAA SPC line colors + sounding-parameter parity
Two changes that bring /skewt visually and analytically into parity
with the NOAA SPC sounding viewer
(https://www.spc.noaa.gov/exper/soundings/):

NOAA-style line set
-------------------

Updated the SkewtSvg style block to match the public SPC chart:

  * Temperature trace ........ pure red (#ff0000) at 2.5 px stroke
  * Dewpoint trace ........... pure green (#00aa00) at 2.5 px stroke
  * Isotherms ................ dashed teal (#14b8a6)
  * Dry adiabats ............. solid orange (#f59e0b)
  * Moist (saturated) adiabats dashed sky-blue (#38bdf8)
  * Mixing-ratio lines ....... dotted darker green (#16a34a), with
                               labels at 700 mb showing the g/kg
                               value, drawn only below 600 mb (matches
                               the SPC chart's lower-band number row)
  * Parcel ascent ............ new dashed grey (#475569) line, drawn
                               whenever SkewtParams.derive returns a
                               non-empty parcel_trace

The mixing-ratio set is the SPC standard (0.4 / 0.7 / 1 / 2 / 3 / 5 /
8 / 12 / 16 / 20 g/kg) instead of the prior arbitrary set.

SkewtParams: SPC parcel-theory + indices module
-----------------------------------------------

New `Microwaveprop.Weather.SkewtParams` derives the parameter set that
ships in the SPC `*.txt` companion file:

  * LCL pressure / temperature / height (Bolton 1980 eq 22)
  * LFC pressure / height
  * EL pressure / height
  * SBCAPE, SBCIN (J/kg, virtual-temperature integration with
    surface parcel)
  * 3 km CAPE
  * Lifted Index (parcel ↔ environment T at 500 mb)
  * Freezing level (T = 0 °C height) and Wet-Bulb Zero
  * DCAPE (downdraft CAPE — picks the min-θₑ source level in
    400–700 mb, descends moist-adiabatically to surface)
  * Layer lapse rates: sfc–3 km, 700–500 mb, 850–500 mb
  * `parcel_trace` — pressure/temperature trajectory used by
    SkewtSvg to draw the dashed parcel-ascent overlay

Wind-derived indices (SRH, BWD, Bunkers motion, SCP, STP, SHIP) are
deliberately *not* produced and the LiveView surfaces a one-line note
explaining why: HRRR persists wind only at 10 m AGL, so per-pressure-
level shear can't be computed from this data source.

LiveView panel restructured into grouped sections (Surface, Convective
parcel, Levels, Lapse rates, Moisture/stability, Refractivity) so the
layout matches the SPC viewer's order. The Levels rows show pressure
*and* height ("872 mb · 1,140 m") for quick cross-reference between
the diagram axis and the readout.

Tests: 8/8 new SkewtParamsTest cases (LCL Bolton check, saturated-air
edge case, full field-set presence, summer profile produces SBCAPE > 0
and LI < 0, capped profile produces SBCAPE = 0 and LI > 0, freezing
level interpolated correctly, sfc–3 km lapse rate, empty profile no
crash). 25/25 across all skewt-related test files. mix test green
(2,902 / 2,902 + 221 properties), mix credo --strict clean, mix
assets.build clean.
2026-04-25 13:26:41 -05:00
a4cd7632eb
feat(skewt): hover crosshair + DB fallback for missing on-disk profiles
Two follow-ups to the earlier /skewt work:

1. Interactive crosshair (matches the SPC-viewer feel from the
   reference screenshot). Mousing over the diagram now drops a
   horizontal pressure cursor with a top-left readout box showing the
   pressure at the cursor's altitude (mb), the height in m and ft,
   and the linearly interpolated temperature and dewpoint at that
   level. Two coloured dots track the T and Td traces along the
   cursor so the user can read the spread visually.

   Mechanics: SkewtSvg.geometry/0 exposes the plot geometry (left,
   right, top, bottom, p_bottom, p_top, t_min, t_max, skew) as JSON;
   SkewtSvg emits an initially-hidden <g class="skewt-cursor"> layer.
   A new Skewt hook in assets/js/app.ts inverts pressure_y to recover
   pressure from the cursor's viewBox-Y, walks the (descending-pres-
   sorted) profile to interp T/Td/height, and updates the elements.
   The hook also handles mouseleave to hide the cursor and uses the
   SVG's screen-CTM so the math stays correct under any container
   aspect-ratio resize.

2. DB fallback for `available_valid_times`/`load_profile`. When the
   on-disk `ProfilesFile` has nothing for the location-and-window
   (true on dev, transient on prod between chain runs), SkewtLive
   now falls back to `HrrrProfileLookup.{list_valid_times_near,
   read_point_near}/3` against the `hrrr_profiles` Postgres table.
   The lookup uses the same ±0.07° spatial tolerance and the
   `(lat, lon, valid_time)` unique index that `Weather.find_nearest_
   hrrr/3` already relies on, so the queries are index-only.

   Returned shape matches `ProfilesFile.read_point/3` so SkewtLive
   reads from either source transparently. NaiveDateTime → UTC
   normalisation is centralised in `ensure_utc/1` so the LiveView
   sees `%DateTime{time_zone: "Etc/UTC"}` regardless of which side
   produced the value.

Tests: 6/6 new HrrrProfileLookupTest cases (list/limit/tolerance,
read nearest cell, nil for far cells, picks closest among matches),
3/3 SkewtLiveTest still green, 2,894 total Elixir tests + 221
properties green via mix test (one pre-existing flaky test in
admin/contact_edit_live re-runs clean). mix credo --strict clean.
mix assets.build clean.
2026-04-25 13:17:58 -05:00
9e776a07d5
fix(skewt): write profile files for f01..f18 forecast hours
The /skewt page was empty in prod because Phase 2 of the Rust cutover
moved f01..f18 grid scoring to Rust but left profile-file writing only
on the analysis (f00) path. Result: /data/scores/profiles/ accumulated
one file per chain run instead of nineteen, so SkewtLive only ever saw
the analysis hour — and that hour falls outside the 1 h past cutoff
within an hour of the chain finishing, leaving the page empty most of
the time.

Two surgical fixes:

  * `pipeline::run_chain_step` now builds `profile_entries` alongside
    `prepared` from the merged grid in a single iter pass, then spawns
    `profiles_file::write_atomic` on the blocking pool to overlap the
    NFS write with the score-band scoring/write fan-out — same pattern
    `run_analysis_step` already uses. `ChainStepStats` gains
    `profile_cells_written` so the worker log line is symmetric with
    the analysis step's existing field and a future failure mode that
    drops the profile would show up as a divergence.

  * `SkewtLive.available_valid_times/0` widens the past cutoff from
    1 h to 3 h. The chain's analysis hour can be ~70 min old by the
    time the next chain finishes (and ~2 h on a missed cycle), so the
    1 h cutoff was leaving the page blank during normal transients.
    18 h forward window unchanged.

Cosmetic: cargo fmt picked up trivial rewrites in decoder/fetcher/
native_duct/hrrr_point_worker that had drifted; rolled in.

Verification: cargo build --release, cargo clippy --lib --bins -D
warnings, cargo test --lib (134/134), mix test
test/microwaveprop_web/live/skewt_live_test.exs (3/3) all green.
2026-04-25 12:58:10 -05:00
e40ade3b19
feat(skewt): add /skewt LiveView with HRRR-backed Skew-T-Log-P plot
Single search bar accepts an address, Maidenhead grid square, or
callsign; resolves it to lat/lon via Geocoder / Maidenhead /
CallsignLocation (cheapest classification first), snaps to the HRRR
grid via the existing ProfilesFile reader, and renders an SVG
Skew-T-Log-P with isobars, skewed isotherms, dry/moist adiabats, and
mixing-ratio lines. A button row picks between every available
forecast hour (now → f18); default is the most recent analysis.
Right pane lists derived stability and refractivity stats from
SoundingParams.derive (PWAT, BL depth, K-index, lifted index, min
dN/dh, ducting status + per-duct base/top/ΔM).

Renderer is server-side SVG so the page works without JS and serializes
into the LiveView payload as a single tag. Wind barbs are deliberately
omitted — HRRR's persisted profile carries wind only at 10 m AGL.
2026-04-25 12:13:46 -05:00
90bf44ce90
fix(submit): allowlist switch_tab values instead of trusting client input
`handle_event("switch_tab", ...)` ran `String.to_existing_atom/1` on
the raw `tab` value from the browser, so a stale, forged, or
mistyped client event with any unknown string crashed the LiveView
with `ArgumentError: not an already existing atom`.

Compares against an explicit `@valid_tabs` allowlist (`:single`,
`:csv`, `:adif`) — known values switch the active tab, anything
else is ignored.
2026-04-25 10:10:42 -05:00
938af4d6f2
fix(plugs): normalize IPv4-mapped IPv6 peers so cf-connecting-ip wins
Bandit's dual-stack listener delivers IPv4 connections to the app as
IPv4-mapped IPv6 tuples (`::ffff:a.b.c.d`). The trust check compared
that 128-bit form against our IPv4 trusted_proxies CIDRs and never
matched, so cf-connecting-ip was ignored on every cloudflared-relayed
request — every visitor logged as the cloudflared sidecar pod IP
(rendered as `0:0:0:0:0:65535:2804:101` etc.).

Collapses the mapped tuple to plain IPv4 before the trust check and
records it that way on the conn, so logs and session storage see the
real client IP that Cloudflare set in cf-connecting-ip.
2026-04-25 09:54:20 -05:00
3e458fb76a
feat(nav): add EME menu item to map + weather sidebars
The top navbar in the root layout already linked to /eme, but the
in-page side sidebars on /map and /weather (mobile + desktop) skipped
straight from Path Calculator to Beacons. Adds the EME entry between
them in all four panels so the Earth-Moon-Earth calculator is reachable
without bouncing through the navbar.

Also demotes the LiveStashGuard "stash skipped" warning to debug. It's
the known OTP 28 / LiveStash 0.2.0 ArgumentError that fires on every
event triggering a stash; the guard already handles it, the noise was
adding nothing.
2026-04-25 09:49:06 -05:00
0e0529cd10
feat(weather): ?layer= URL param to deep-link a specific overlay
Adds a `layer` query parameter to `/weather`. Loading
`/weather?layer=temp_700mb` boots straight into that overlay; clicking
a different layer pill push_patches the URL so the active selection is
shareable and survives a refresh. Unknown layer ids fall back to the
default (temperature) instead of crashing.
2026-04-25 08:57:59 -05:00
2e5b61f2f9
fix(weather): drop expensive grid fetch from mount to avoid LV join timeout
/weather mount called Weather.latest_weather_grid/1 directly, which
runs WeatherLayers.derive over 92k cells and Jason-encodes the
result. Production HTTP logs showed dead renders consistently
landing at 9.8–10.2 s — right at LiveView's socket join deadline.
The browser would render half the page, the WS would fail to mount,
and the user saw "attempting to reconnect".

The JS hook already fires map_bounds via requestAnimationFrame
immediately after mount, and the existing handle_event("map_bounds")
path returns viewport-scoped weather data via the update_weather
push event. So the dead-render data fetch is redundant — drop it.
Mount now returns initial_data_json: "[]"; the layer paints
~50–200 ms after WS connect via the bounds-driven flow.
2026-04-24 20:17:31 -05:00
3ac6963c74
feat(weather): add 700 mb T/Td and mid-layer lapse rate for cap diagnostics
850 mb T alone shows that warm air is present aloft but doesn't reveal
whether it's the textbook cap structure — a warm 700 mb above an EML
plume. The three new layers expose the canonical capping diagnostics
visually:

  * T @ 700 mb — ≥10 °C is the southern Plains "moderate cap" threshold
  * Td @ 700 mb — wide T-Td depression at 700 mb signals the EML plume
  * Lapse 850→700 — steep mid-layer lapse rate (≥7 °C/km) over a moist
    boundary layer is the cap mechanism itself

All three derive purely from the existing pressure-level profile (Rust
already fetches up through 700 mb), so no Rust pipeline changes needed.
2026-04-24 19:27:23 -05:00
13fad936a3
fix(status): mobile layout — tables no longer truncate column headers
The three tables on /status (Job Queue, Rust Workers, Table Sizes)
were collapsing to fit the viewport on narrow phones, wrapping
header text ("Siz…") and splitting values like "160 GB" into two
lines ("160." / "GB"). The overflow-x-auto wrappers were already
present but column auto-sizing still squeezed width below content.

Adds `whitespace-nowrap` to each `<table>` so cells never wrap, and
`-mx-2 px-2` on the overflow wrapper so horizontal scroll extends
edge-to-edge of the mobile viewport instead of being inset by the
layout's outer padding. Tables now scroll horizontally as intended
on mobile; desktop layout unchanged.
2026-04-24 15:45:01 -05:00
32e7cccc40
fix(weather): accept Rust profile shape in SoundingParams + WeatherLayers
The Rust f01..f18 pipeline writes ProfilesFile cells, and the Rust
hrrr_points worker writes hrrr_profiles rows, with profile entries
keyed "pres_mb" / "hght_m" / "tmpc" / "dwpc" (string keys pre-
atomization; atom keys post-ProfilesFile.read). Elixir's
SoundingParams.derive and WeatherLayers.sort_profile filtered on
"pres" / "hght" — so every derived field returned nil for any
Rust-provided profile.

Visible symptoms on /weather: N-gradient, Refractivity, T @ 850mb,
Td @ 850mb, Lapse Rate, Inversion, Inv. Base all rendered no overlay.
For per-contact analysis, min_refractivity_gradient from the Rust
HRRR point worker's rows silently dropped.

- SoundingParams.normalize_profile_entry/1: single-source normalizer
  accepting legacy, Rust-string, and Rust-atom shapes; returns the
  canonical "pres"/"hght"/"tmpc"/"dwpc"/"drct"/"sknt" shape.
- SoundingParams.derive/1 and WeatherLayers.sort_profile/1 both run
  every entry through it before the rest of the existing logic.
- Weather.build_grid_cache_row/4 now falls back to native_min_gradient
  when SoundingParams can't derive one (surface-only profiles).

Also:
- Default /weather overlay changed to Temperature (LiveView assign +
  JS fallback in the hook's dataset default) per user request.
2026-04-24 13:53:02 -05:00
c87334d31d
chore(deps): drop gettext
No translations are actually maintained and no UI is shipped in a
non-English locale. Replaced the handful of gettext(...) calls in
core_components + layouts with their literal English strings, and
rewrote translate_error/1 as a small %{key} interpolator over the
Ecto changeset error tuple.

Removes gettext + expo (compile dep). sutra_ui stays — it's a
transitive dep of live_table's TableComponent.
2026-04-24 12:31:21 -05:00
d100e56edb
fix(status): format active/queued jobs stat with thousands separator 2026-04-24 12:03:37 -05:00
d88bdd70a8
fix(map): harden :preload_forecast against slow NFS + LiveStash crash
Two user-visible crashes in MapLive seen on prod pods today:

1. `:preload_forecast` handle_info timed out a Task.async_stream subtask
   after 10 s and the `Enum.map(fn {:ok, h} -> h end)` match cascaded
   the `:exit, :timeout` into the LiveView process, terminating the
   session. Adds `on_timeout: :kill_task` and flat-maps only :ok
   elements — a slow NFS forecast hour now drops from the preload
   payload instead of killing the LV.

2. `LiveStash.stash/1` crashed with `** (ArgumentError) errors were
   found at the given arguments: 2nd argument: not a valid match
   specification` inside `:ets.select_replace/2` on OTP 28. The dep
   (live_stash 0.2.0) has a latent bug against the tightened OTP 28
   match-spec validator. Both call sites (MapLive.select_band,
   SubmitLive.switch_tab) now route through a new
   `MicrowavepropWeb.LiveStashGuard.stash/1` wrapper that rescues
   ArgumentError, logs a warning, and returns the socket unchanged.
   Stash is a convenience for reconnect state — failing it does not
   need to kill the user's session.

Drop the guard module once the upstream match-spec fix lands.
2026-04-24 10:15:37 -05:00
2900b9a959
fix(eme): destructure :timer.send_interval return, silence dialyzer
EmeLive.mount/3 dropped the {:ok, tref()} return from send_interval
on the floor. Elixir 1.19 + :unmatched_returns dialyzer flag catches
the discarded tuple; the if/else with explicit :ok arms keeps both
branches typed as :ok.
2026-04-24 09:30:42 -05:00
f99d07bd29
test: push line coverage from 71.7% → 78.67%
Adds ~600 new test cases (2013 → 2613 tests; 170 properties; 0
failures) across 12 new test files plus expansions of eight existing
files. Big lifts per module:

  ContactLive.Mechanism      33 → 100%
  MetricsPlug                54 → ~100%
  Microwaveprop.Release      15.8 → 70%+
  Telemetry                  38 → 76%
  HrrrNativeGridWorker       27.7 → 76%
  ContactLive.Show           34 → 48% (handler + render branches)
  Admin.ContactEditLive      49.7 → 70%+
  GefsFetchWorker            35.8 → ~55%
  IonosphereFetchWorker      56.3 → 75%
  PathLive                   58.9 → 67%
  WeatherMapLive             66.9 → 80.2%
  RoverLive                  0 → 70.3%
  ContactMapLive             59.2 → 89.8%
  ContactMapController       0 → 100%
  Mix tasks (Rust.Golden, Notebook, Backtest, HrrrBackfill,
    HrrrClimatology, HrrrNativeBackfill, NexradBackfill,
    RadarBackfill, ImportContestLogs, PropagationGrid,
    ResetEnrichment, Hrrr.PurgeGridPoints)  0 → ~60-100%

Two incidental fixes made while adding tests:

- ContactLive.Show.handle_event("toggle_flag", ...) was passing
  socket.assigns.current_scope to admin?/1 instead of socket.assigns,
  so admins never matched the pattern and every toggle ran the
  "Admins only." flash branch. Flag now toggles for admins again.

- Commercial.PollWorker.fetch_weather/1 promoted from private to
  @doc'd public so the IEM-fetch + ASOS-upsert path can be tested
  directly without driving perform/1 through live SNMP (which was
  timing out for ~50 s per test in the earlier attempt).

Stable property-test additions cover the dewpoint-from-RH monotonicity,
nearest_run/1 idempotence, and the ionosphere station envelope.
2026-04-23 18:43:18 -05:00
109c4e141f
feat(eme): 3D WebGL Earth-Moon globe with lazy-loaded Three.js
Replaces the SVG schematic on /eme with a textured WebGL scene: NASA
Blue Marble Earth, real-size Moon, outbound beam, dashed return ray
to the sub-lunar point, and a red shading on the anti-moon hemisphere
that marks who can't see the Moon (outside the bounce footprint).

Three.js (~680 kB) now lives in its own chunk; esbuild --splitting
keeps it out of the main bundle so it only loads when a user hits
/eme. Main app.js drops from 1.6 MB to 922 kB.

Also swaps the Elevation/SNR rows so the badge comes before the number.
2026-04-23 17:00:53 -05:00
bd107ee747
feat(eme): add /eme Earth-Moon-Earth calculator
New /eme page — antenna aiming + link budget for moonbounce.

Given a station (callsign / grid / lat,lon), TX power, antenna gain,
detection bandwidth, and system Tsys, the page shows in real time:

- Moon azimuth / elevation from the observer (ticks every 10 s)
- Slant range to the Moon
- EIRP breakdown: TX(dBm) + gain(dBi) = EIRP(dBm)
- Path-loss decomposition (Earth → Moon → Earth):
    free-space spreading (×2)
    − moon reflection gain 10·log(4π σ / λ²) where σ = ρ·π·R²
    = round-trip path loss
- Band-dependent lunar albedo ρ (VHF ≈ 0.065, microwave ≈ 0.08,
  mm-wave ≈ 0.02) surfaced as a named line item with the current
  ρ shown to three decimals
- Received power, thermal noise floor, and SNR with a badge that
  classifies the margin (strong / marginal / below noise)
- Self-echo Doppler shift with its radial-velocity driver

New modules:

- `Microwaveprop.Moon` — low-precision lunar ephemeris
  (Astronomical Almanac Sec. D.4 truncated series). Provides
  `julian_day/1`, `geocentric/1`, `observer_position/3`, and
  `radial_velocity_km_s/3`. Accurate to ~0.3° position / ~200 km
  distance — well inside any amateur EME-dish beamwidth.
- `Microwaveprop.Propagation.Eme` — path-loss helpers:
  `moon_albedo/1` (band lookup), `moon_rcs_m2/1`,
  `fspl_round_trip_db/2`, `moon_reflection_gain_db/2`,
  `path_loss_db/3`, `received_power_dbm/1`, `noise_floor_dbm/2`,
  `snr_db/1`, `doppler_shift_hz/2`.

Wired into the main nav ("EME" button) and the router between
/path and /rover.

Tests: 3 properties + 41 unit tests cover JD conversion, moon
position envelope, band-dependent albedo lookup, decomposition
identity, linearity/monotonicity of path loss and Doppler, and a
LiveView integration test against the rendered DOM.

Full suite: 2,460 tests + 170 properties; credo strict clean.
2026-04-23 16:18:20 -05:00
75ec3f8d1e
chore(deps): upgrade live_stash 0.1.2 → 0.2.0
Two breaking API changes in 0.2:

- `stash_assigns/2` is gone. Keys to persist are now declared on the
  `use LiveStash, stored_keys: [...]` macro and `stash/1` picks them
  up automatically.
- The TTL unit switched to seconds — this codebase never called the
  TTL setter, so no config change is needed.

Updated both callers (MapLive persists selected_band + selected_time;
SubmitLive persists active_tab). `recover_state/1` signature is
unchanged. Full LiveView suite passes (67 tests).
2026-04-23 13:56:28 -05:00
063e9e3ae4
fix(grid-tasks): reclaim orphan running rows on hourly seed
Rust workers (prop-grid-rs) that die mid-claim (SIGKILL, OOM, node
drain) leave grid_tasks rows stuck in status='running' forever,
because claim_next uses FOR UPDATE SKIP LOCKED and nothing resets the
orphan. The /status page then shows a permanent spinner with stale
f00/f10/f18 badges — currently 21 rows claimed as far back as
2026-04-20.

GridTaskEnqueuer.reclaim_stale_running/1 flips rows whose claimed_at
is older than 15 minutes back to 'queued'. Rows that have already
burned through 5 claim/reclaim cycles become 'failed' with a
reclaim-orphan error so the next hourly seed can replace them.
Wired into PropagationGridWorker.seed_chain/0 so it runs every :05
cron tick before new rows are seeded.

Also rename the status panel "Retrying" column to "Failed" — it was
always showing terminal `failed` rows, never retrying ones.
2026-04-23 12:57:39 -05:00
13088a6906
feat(nav): add Status link to admin dropdown 2026-04-23 12:49:48 -05:00
b2ff27945d
feat(adif): carry NOTES / COMMENT into contact notes
ADIF records frequently include operator commentary in the
<NOTES> field (multi-line, detailed) or <COMMENT> (short
one-liner). Now that contacts have a notes column, pass that
value through the import pipeline instead of dropping it.

Preference is NOTES over COMMENT to match the ADIF 3.1.4 spec's
intended split — NOTES is the richer field. Whitespace-only
values collapse to nil so a program that always emits a blank
NOTES tag doesn't fill every row with an empty string.

Tests cover NOTES capture, COMMENT fallback, NOTES-over-COMMENT
preference, and the absent-field nil case. ADIF upload blurb
lists the new carry-over so users see it before confirming.
2026-04-23 11:05:39 -05:00
cd3950f366
feat(contacts): add free-form notes field
Operators want a place to jot observations that aren't captured by
the structured fields — weather anecdotes, propagation mode
commentary, equipment details, band conditions. Add a text column
on contacts plus the two ingest paths users submit through:

  * /submit single-contact form gets a 3-row textarea under the
    other fields. Optional, max 2000 chars, with a live length cap
    via maxlength. Whitespace-only input collapses to NULL so the
    column reflects "no notes" rather than an empty string.
  * CSV importer recognises a `notes` (or `note`) column via the
    existing header_aliases table and flows values straight through
    submission_changeset. Sample template gets a matching example
    row with embedded commas so the quoted-field round-trip is
    exercised on download.

ADIF import and the refinement/refinement-notify paths are out of
scope — users specifically asked for CSV + single-contact. Tests
cover the changeset (accept/blank/over-length), CSV header parsing
(plain + RFC-4180 quoted), and LiveView form submit end-to-end.
2026-04-23 11:02:27 -05:00
93b526c761
fix(web): suppress HEAD request logs
The log_level(%{method: \"HEAD\"}) clause never matched because
Plug.Head rewrites the method to \"GET\" before Plug.Telemetry's
register_before_send callback fires, so by log time conn.method is
\"GET\". Stash the original method in private before Plug.Head runs
and key the filter off it.
2026-04-22 16:50:58 -05:00
04aa19839a
feat(status): surface mechanism_status alongside the other enrichment columns
Contacts have had a `mechanism_status` enum since the rain-scatter
classifier landed — it's in the schema and it's what drives the
Mechanism badge on /contacts/:id — but /status's progress
breakdown still only tracked hrrr/weather/terrain/iemre/radar/narr.
Add Mechanism to both `count_unprocessed_raw`'s FILTER and the
progress-bar list so the ops dashboard reflects the real shape of
the backfill pipeline instead of silently hiding one of the seven
enrichment types.
2026-04-21 17:46:46 -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
4d07f89260
perf(maps): debounce map_bounds repaint + parallelize point_forecast
Two wins that directly shrink /map and /weather latency under
interactive use:

**Debounce the moveend → repaint path (150ms).** Leaflet fires
`moveend` once per pan but 5–10× per second during a wheel-zoom.
Previously each one ran a full `scores_at` (or `weather_grid_at`)
read + a ~20–100KB websocket push. Now they coalesce into a single
trailing-edge flush via `schedule_bounds_update` (and the
/weather twin `schedule_weather_flush`) — the same pattern we
already use for forecast preload. 150ms is short enough that the
user doesn't perceive the pause; >90% of the burst disappears.

**Parallelize point_forecast across 4 tasks.** The sparkline that
pops up on point click was reading up to 18 `.prop` files
sequentially off NFS. Each read is cheap (pread one byte at
row*cols+col) but RTT-dominated. `Task.async_stream` with
max_concurrency=4, ordered=true brings 18-file wall time from
~50ms down to ~15ms on NFS without changing the public API.

No test changes: existing map_bounds test only asserts the bounds
assign lands, which the debounced path still does synchronously.
2026-04-21 17:34:32 -05:00
d3ca2e5791
fix(map): dedupe duplicated forecast-timeline chips + extract Mechanism helpers
The main /map timeline was rendering each forecast hour twice
('Now 22:00' + '0h 22:00', '+1h 23:00' + '+1h 23:00', …).

Root cause: during the rolling `.ntms` → `.prop` rename window,
both extensions live on NFS for the same valid_time. `list_score_files`
matches either, so `list_valid_times_raw` mapped 2 files → 2 DateTimes
with no dedupe. Propagation.available_valid_times inherited the dup,
push_timeline pushed it to the JS hook, renderTimeline drew one chip
per entry — hence the visible duplicate.

Fix: `Enum.uniq` before the sort. Regression test writes a `.prop`
file, copies it as the same valid_time's `.ntms` legacy twin, and
asserts list_valid_times returns a single DateTime.

Also land a small refactor that was queued behind this: move the three
pure mechanism display helpers (label/1, badge_class/1, explainer/1)
out of the 2500-line contact_live/show.ex into a new
MicrowavepropWeb.ContactLive.Mechanism module. No behavior change — just
shrinks the LiveView and gives the display mapping a testable home.
2026-04-21 17:21:11 -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
49d0594d28
refactor(format): consolidate number/bytes formatters + handle flaky IEM transports
StatusLive had a hand-rolled thousands-separator format_number/1 and a
binary-prefix format_bytes/1 — duplication that was already part of the
review punch-list. Move both to Microwaveprop.Format (which previously
held only distance_km) and delete the local defps. Fourteen call sites
switched to Format.number / Format.bytes.

Also extend the IEM error classifier to snooze on Req.TransportError
{:closed, :timeout, :econnrefused, :nxdomain}. Same rationale as the
429 snooze in batch 1: upstream TCP resets mid-fetch aren't a job
failure, they're transient — returning {:error, _} blows them up into
Oban.PerformError logspam with full stacktraces.
2026-04-21 17:09:41 -05:00
e9a38623d8
perf+hygiene: batch 1 of system-review fixes
Batched from a system-wide review pass.

**Rate-limit + transient-error log hygiene**
- WeatherFetchWorker: classify IEM HTTP 429 as {:snooze, 300} instead
  of {:error}. Stops Oban.PerformError stack-trace spam on every
  routine rate-limit during backfill, keeps the retry semantics.
- IonosphereFetchWorker: compress GIRO TLS :unknown_ca error from a
  ~400-char inspect blob to 'TLS unknown_ca (CA bundle missing)'.
- FreshnessMonitor: log only when an enqueue actually lands (the
  Oban unique conflict case was silently dropping jobs but still
  producing 'enqueuing grid worker' info lines every 5 minutes).

**Perf**
- Rust grid_level_keys(): pre-format 'TMP:{p} mb' / DPT / HGT keys
  once via OnceLock instead of format!()'ing per-cell. Removes ~3.6M
  String allocations per f01..f18 chain step across pipeline.rs,
  hrrr_points.rs. Same for the wgrib2 :(TMP|DPT|HGT): pattern in
  hrrr_points.process_batch (sfc_pattern / prs_pattern OnceLock).
- MapLive preload_forecast: Task.async_stream with max_concurrency=4
  replaces the 18-wide Enum.map serial walk. Forecast cache warms
  ~4× faster after a band change, with ordering preserved.
- grid_tasks: partial composite index on (run_time DESC,
  forecast_hour ASC) WHERE status='queued' AND kind='forecast',
  matching the Rust claim query's ORDER BY. Drops the old
  status-only partial that forced a sort per claim.

**Correctness**
- ContactImportWorker: add Oban unique:[keys: [:import_run_id,
  :offset]]. Was missing on a worker whose perform() does a
  non-idempotent atomic counter increment — a retry would double-
  count imported rows.
- CommonVolumeRadarWorker: x_min..x_max default step is -1 when
  x_min > x_max, triggering a runtime deprecation warning. Force
  Range.new(_, _, 1) explicitly.

**Cleanup**
- Drop unused tmp_dir parameter from hrrr_point_worker + its
  process_batch signature.
2026-04-21 17:06:07 -05:00
8ec5d895ee
chore(logs): suppress Phoenix Sent log for /live liveness probe
/live fires every 10s per pod; the request line and Sent 200 response
from Plug.Telemetry drowned out real requests. log_level/1 now returns
false for /live too, same treatment /health already got.
2026-04-21 16:34:17 -05:00
a2701780ab
fix(probes): stop DB saturation from SIGKILLing every pod
Investigated why all three prop pods were restart-looping every few
minutes (RESTARTS counts 7–11 in 99m). The trail:

  20:53:33.058 [error] Postgrex.Protocol ("db_conn_10") disconnected:
    client (:"Elixir.Microwaveprop.PromEx.Poller.5000") timed out
    because it queued and checked out the connection for longer than
    15000ms

PromEx's Oban queue-depth poller was scanning the oban_jobs table
every 5s and holding an Ecto connection past its checkout timeout.
That starved the rest of the pool, so /health's `SELECT 1` couldn't
acquire a connection inside the 3s liveness probe timeout, the
kubelet's failureThreshold=3 tripped, and SIGKILL landed on the
BEAM. Every replica cycled on the same cadence.

Two fixes:

1. Split /live (no DB) from /health (DB ping). Kubernetes livenessProbe
   now points at /live so a saturated Ecto pool can never cascade into
   SIGKILL. Readiness still hits /health so a genuine DB outage drains
   the pod from Service endpoints gracefully.

2. Turn off the PromEx Oban plugin in prod via the same flag already
   used in test. The queue-depth query isn't worth pod instability;
   the oban_web dashboard surfaces the same information without
   scanning the job table on a timer.

Locked both down with HealthController tests that verify /live never
touches the Repo (no sandbox owner, controller.live/2 called directly,
200 ok) and /health does (sanity query round-trips).
2026-04-21 16:00:04 -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
d0dbb5c5b4
style(layouts): split plausible script tag onto two lines for heex 2026-04-21 15:30:51 -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
48d30f1688
feat(web): add Plausible analytics snippet to the shared layout footer
Self-hosted Plausible instance at a.w5isp.com. No cookies, no personal
data; drops one small JS file per page view.
2026-04-21 14:12:54 -05:00
ef54125aa6
fix(security): only trust forwarded-ip headers from known proxies
The remote_ip plug previously trusted Cf-Connecting-Ip and X-Forwarded-For
on every request, so any client reaching a pod directly (cluster-internal,
kubectl port-forward, misconfigured Service) could spoof conn.remote_ip,
which flows into session storage and logs.

Now only honour those headers when the immediate TCP peer sits inside a
configured CIDR list. Default list covers loopback, RFC1918, CGNAT,
link-local, and IPv6 ULA — matches the k8s pod/service network.
Override via config :microwaveprop, :trusted_proxies, or per-environment
via the TRUSTED_PROXY_CIDRS env var in runtime.exs.

Also refresh the stale "nginx/dokku proxy" moduledoc — deployment has
been k8s + Cloudflare for a while.
2026-04-21 13:19:52 -05:00
1400c38f44
fix(dialyzer): actually fix LiveTable warnings instead of hiding them
Rework of d61fbd3's dialyzer suppression: the 4 warnings from
LiveTable.LiveResource's macro expansion are now fixed at the
root rather than hidden via .dialyzer_ignore.exs.

Changes:

- Delete .dialyzer_ignore.exs and remove ignore_warnings from
  mix.exs. No more hidden suppressions.

- New MicrowavepropWeb.LiveTableResource shim that wraps
  use LiveTable.LiveResource and overrides:
  * maybe_subscribe/1 — explicit :ok = on Phoenix.PubSub.subscribe/2
    (previously discarded, tripping :unmatched_return).
  * handle_info/2 — explicit _ = on File.rm, Process.send_after.
  Switch all 4 LiveTable-using LiveViews (contact_live/index,
  beacon_live/index, admin/contact_edit_live, user_management_live/index)
  to use MicrowavepropWeb.LiveTableResource instead.

- New priv/dep_patches/live_table-0.4.1.patch adds _ = in front of
  LiveTable.LiveSelectHelpers.restore_live_select_from_params/2 in
  the dep's macro-expanded handle_params/3. The call's return was
  silently discarded, tripping the fourth warning per LiveView.

- New lib/mix/tasks/deps.patch.ex applies every
  priv/dep_patches/*.patch to its target dep idempotently after
  mix deps.get. Wired into the :setup alias + overridden
  mix deps.get so the patch survives cache regeneration in CI.

- Other modified files in this commit are the spec-coverage
  additions from the parallel agent pass (beacons.ex,
  commercial.ex, propagation.ex, weather.ex, narr_client.ex,
  run_timing.ex, 3 workers) — tighter specs for bounds, tuple
  shapes, schema-typed params. Dialyzer remains at 0 after
  the changes.

Upstream TODO: send a PR for the live_table `_ =` fix. Once
merged and released, drop the patch + mix task + bump the dep.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2165 tests, 2 pre-existing flakes (MapLive timestamp +
PropagationPrune ScoresFile), 0 regressions.
2026-04-21 12:58:10 -05:00
38ef716a7c
fix(logging): suppress /metrics logs and harden scorer hot path
- endpoint.ex log_level/1 now filters by conn.request_path instead of
  conn.path_info. Plug.Router.forward/2 (deps/plug/lib/plug.ex:170)
  rewrites path_info to the unmatched remainder and extends script_name
  with the matched prefix before dispatching to the forwarded plug.
  /metrics is routed via forward "/metrics", MetricsPlug; by the time
  Plug.Telemetry's before_send callback fires inside MetricsPlug.call/2
  the conn it observes has path_info: [] and script_name: ["metrics"],
  so the old log_level(%{path_info: ["metrics" | _]}) clause never
  matched and the default :info level fired. request_path is set by
  the adapter at entry and is never rewritten, making it the correct
  discriminator. New regression test in metrics_log_suppression_test.exs
  captures both the direct shape and the integration path via Plug.Test.

- Scorer.dbz_to_rain_rate_mmhr/1 now uses a compile-time @mp_inv_b
  constant (1 / 1.6) instead of dividing on every rain pixel.
  composite_score/2's band-invariant fallback switched from four
  separate  short-circuits (which silently
  mixed cached and freshly-computed values if only some keys were
  passed) to a single Map.has_key?/2 branch that honors the "all or
  none" contract. Dropped unused band_invariant_tod/1 helper.

- Propagation.replace_scores span scope tightened: the
  Instrument.span([:db, :replace_scores]) now wraps only the per-band
  ScoresFile.write! loop, not the upstream Enum.group_by grouping
  phase. The span name + metadata stay unchanged so Grafana panels
  keep working, but the fixed telemetry dispatch cost (~100µs x 2)
  is no longer paid for trivially small result sets.

- Added @type t :: %__MODULE__{...} to Accounts.UserToken and
  Weather.HrrrClimatology — the last two schemas that lacked one.
  Elixir 1.19's set-theoretic inference benefits from every struct
  having an explicit t/0 so callers can flow through tightly.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2165 tests, 3 pre-existing flakes, 0 new regressions.
2026-04-21 10:44:25 -05:00
d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00
fbbb8b2e1e
feat(logging): add method + request_path to log metadata
Every Sent log line now carries method and path so noisy traffic
(probes, scrapers, redirects) is identifiable without a tracer.
RemoteIp plug already sets remote_ip metadata; add method and
request_path alongside it and register the extra keys with the
default formatter.
2026-04-21 09:07:31 -05:00
30525a18f3
perf(status): collapse 7 contact-count queries to a single FILTER scan
count_unprocessed_raw fired seven separate COUNT(*) scans on the
contacts table every 2 s refresh (total, terrain, hrrr, weather, iemre,
radar, all-done). Each one went through the 58k+ row table
independently, so the status page was hitting Postgres with ~3.5 full
scans per second of connected viewer time.

A single query with COUNT(*) FILTER clauses pulls every count in one
sequential scan. Same result, roughly 1/7 the CPU on Postgres.
2026-04-20 16:56:15 -05:00
aebf3911ce
perf(map): debounce preload_forecast + single-pass score filter
Two wins in the /map hot path.

1. preload_forecast fired on every Leaflet moveend — which arrives in
   bursts during pan/zoom — and each firing read + filtered + shipped
   18 forecast-hour score lists (up to 90k cells per hour at full
   CONUS view) over the LiveView websocket. Now debounced to the
   trailing edge: the user must stop moving for 750 ms before we pay
   the preload cost. select_band and propagation_updated go through
   the same scheduler so a storm of PubSub updates during an hourly
   chain also coalesces.

2. ScoreCache.grid_to_filtered_list walked the 92k-cell grid twice
   (Enum.filter then Enum.map) and allocated an intermediate tuple
   list between them. Enum.reduce emits only in-bounds result maps
   directly — halves map traversal + drops the tuple intermediate.
2026-04-20 16:51:52 -05:00
ee732d03b1
fix(contact-detail): re-enqueue HRRR on every page view when profile missing
The 'Fetching HRRR atmospheric data' spinner stayed up forever for
contacts stuck in :queued without a matching hrrr_fetch_tasks row.
Ways a contact lands in that state:

  - HrrrPointEnqueuer.enqueue/1 raises, the rescue swallows the error
    and returns {:ok, 0}, but the caller still marked the contact
    :queued before it noticed the failure.
  - Rust worker marks a (valid_time) task :done with a points array
    that never included this contact's point, so no profile is ever
    written for it.
  - hrrr_fetch_tasks row is cleaned up by retention before the contact
    page loads.

Drop the hrrr_status != :queued short-circuit and always call the
enqueuer when the profile is nil. The ON CONFLICT clause in
HrrrPointEnqueuer.enqueue/1 unions points into the existing row,
resets :done/:failed rows back to :queued with attempt=0, and no-ops
when the point is already present — so re-entering on every page view
is cheap and self-healing.
2026-04-20 16:37:14 -05:00
bd3b1148a4
perf(map): collapse point_detail/forecast NFS I/O + silence /metrics logs
Clicks on the map were hanging for 1-3s, triggering the Phoenix topbar
progress bar visible to the user. Three hot-path improvements:

1. ScoresFile.read_point now uses a two-range :file.pread/2 — reads the
   33-byte header + one cell byte (~60 bytes) instead of the full ~93 KB
   file. point_forecast walks 19 forecast hours per click, so the old
   full-read path was the dominant cost on cold cache.

2. ProfilesFile.read/1 and list_valid_times/0 cached via
   Microwaveprop.Cache (5s TTL). Decoded profile maps are ~10 MB each
   (92k cells); timeline scrub that re-clicks the same valid_time
   within seconds now hits ETS instead of re-gunzipping + decoding.
   Cache keys include base_dir so test setup that swaps dirs doesn't
   see stale entries. Writes + prune + retain_window invalidate.

3. Endpoint log_level now filters /metrics the same way it already
   filters /health — Prometheus scrapes every 5s and was producing
   visible log spam.

Pod-local ETS is right for this workload (per-click read, tiny working
set); Valkey / shared memstore would not help here since each pod needs
its own fast-path lookup. File a follow-up if cross-pod score lookups
ever show up in flame graphs.
2026-04-20 16:30:16 -05:00