Commit graph

44 commits

Author SHA1 Message Date
f35da3a935
beacon monitor work 2026-07-22 10:30:21 -05:00
aab3c3736c
feat: extend HRRR forecast horizon from 18h to 48h
HRRR publishes f21-f48 at 3-hourly intervals in addition to the
hourly f01-f18 we already fetch. This extends the grid_tasks seed
from 19 rows (1 analysis + 18 forecast) to 29 rows (1 analysis +
18 hourly + 10 3-hourly), and bumps the UI forecast window
constant from 18h to 48h so the map timeline and path calculator
forecast chart automatically show the extended horizon.

No Rust logic changes needed — the pipeline is data-driven and
u8 forecast_hour already handles f48.
2026-06-10 11:36:58 -05:00
daafa5a02a
perf(algo): unify recalibration into single Python pipeline with JSON output
Replace the two-script Python pipeline (analysis report + Elixir-source
emitter) with a single `scripts/recalibrate.py` that fits per-band
weights from PSKR spot density (VHF/UHF) and contacts↔HRRR correlations
(microwave), writing `priv/algo/band_weights.json` as a machine-readable
artifact. A new Elixir BandWeights module loads this JSON once via
`:persistent_term` cache; BandConfig.weights/1 consults it before falling
back to in-source overrides or global defaults. The script never touches
Elixir source — recalibration is now `python3 scripts/recalibrate.py`
followed by an app restart.
2026-05-25 14:45:55 -05:00
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
bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00
ff92979b50
docs(api): point users at /users/settings for token creation
The settings UI is the preferred path for interactive token minting
now that it exists; POST /api/v1/auth/tokens stays for scripts/CLIs.
2026-05-09 10:15:23 -05:00
04f9ed5fe6
feat(settings): manage /api/v1 tokens from user settings UI
Adds a separate API-token section under /users/settings (distinct from
the beacon-monitor token list, since API tokens grant full account
access). The plaintext is surfaced exactly once via flash on creation;
only the SHA-256 hash is persisted, so revocation is the only path back
if the user loses it.

Also fixes the openapi.yaml link on /docs/api: the relative path
resolved to /docs/openapi.yaml from a no-trailing-slash URL and 404'd.
2026-05-09 09:59:56 -05:00
c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00
58f4a7714b
test: add Mox deps + SnmpClient poll/3 tests, RoadProximity, Buildings.Loader
- Add {:mox, "~> 1.0", only: :test} to deps
- Commercial.SnmpClient: injectable snmp_runner for poll/3 tests (62→92%)
- Rover.RoadProximity: pure road_distances/3 tests with injectable fetch (37→82%)
- Buildings.Loader: ensure_loaded/1 with real tile file + quadkey tests (69→100%)
- Buildings.Index: records_near, mark_loaded, loaded? tests (72→96%)

Coverage: 77.96% → 78.93% (+0.97%)
2026-05-07 13:36:22 -05:00
fe1c10ce0c
docs(hrdps): record stage progress + dormant scaffolding state
CLAUDE.md adds HRDPS to Key Data Sources and the Background Job
Queues table with explicit "dormant until Rust ships" framing.

The 2026-04-29 plan gets a Status snapshot at the top: which stages
shipped (0-3 Elixir + 8) vs which are deferred to the Rust port
sessions (4-7, 9). Activation path is one-paragraph: land Rust HRDPS,
add cron entry, widen map bounds, extend FreshnessMonitor.
2026-04-29 17:13:08 -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
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
1d72f52dad
perf(weather/grid_cache): chunk-keyed ETS layout for viewport reads
Mirror ScalarFile's 5°×5° chunk layout inside GridCache. Each ETS entry
is now `%{{lat_band, lon_band} => %{{lat, lon} => row}}` instead of a
flat `%{{lat,lon} => row}`. fetch_bounds/2 walks only the chunks that
intersect the requested viewport, so latest-hour pans become
proportional to visible chunks (~2-4) rather than O(92k cells).

fetch/1, fetch_point/3, put/2 and broadcast_put/2 keep their
pre-existing contracts.

Also closes the open items in docs/weather.md: Phase 3 is now done end
to end. The only remaining future-work item is moving WeatherLayers.derive
itself into the Rust pipeline so the BEAM doesn't pay the one-time
materialization pass per new valid_time — small runtime savings vs.
significant port effort, so left as documented future work.
2026-04-28 17:24:12 -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
3a1e79fb67
feat(scorer): retire HPBL multiplier and native-duct 1.15× boost
Both retired in light of the 2026-04-25 revision report
(`docs/algo-reports/2026-04-25-algo-revisions.md`):

  * HPBL boundary-layer multiplier in `Scorer.score_refractivity/{3,4,5}`
    is gone. On the n=47,418 10 GHz HRRR-matched corpus rho_hpbl is
    +0.004; binned distance is flat to within ±5 % across 200–2,000 m.
    The previously reported 2.3× ratio between shallow and deep HPBL
    bins was a small-corpus artefact that disappeared once the matched
    corpus passed ~5,000 contacts.

  * Native-profile 1.15× duct boost is gone. At 10 GHz on n=52,341
    matched contacts, cells where `best_duct_band_ghz` ≥ band ran
    *shorter* than no-duct cells (198 km vs 211 km, n=173 vs 44,658).
    The Bulk Richardson gate that existed only to suppress that boost
    in turbulent conditions is also retired; arity preserved on every
    `score_refractivity` overload so callers compile unchanged.

Same retirements applied in the Rust port (`rust/prop_grid_rs/src/scorer.rs`)
to keep the Elixir/Rust parity tests green.

algo.md updated end-to-end:

  * Finding 4 (HPBL) rewritten to "no usable signal" with the
    n=47,418 bin table.
  * Finding 5 (gradient) tightened: load-bearing only at 24 GHz,
    set band-specific gradient weight to 0 outside [10, 47] GHz on
    the next derive_band_weights run.
  * Finding 8 (time-of-day) augmented with the robust hour-bucketed
    amplitude index (40 / 82 / 101 % at 10 / 24 / 47 GHz) and a
    selection-bias caveat for VHF/UHF (where 129–148 % amplitude
    reflects evening contest scheduling, not propagation).
  * Finding 9 (sounding) refreshed against the n=27,058 corpus.
  * Part 2c "Native-profile duct boost" rewritten as
    "retired 2026-04-25" with the falsifying join.
  * Part 2c "Commercial-link inverse sensor" promoted from deferred
    with the 22-day, 38k-sample SNMP corpus, the +0.61 / −0.61 PWAT
    and pressure correlations against the 37-hour HRRR overlap, and
    the 04–05 / 10–12 local two-peak morning fade window.
  * Part 2b "Pressure" annotated with the U-shape at 10 GHz and the
    next-iteration target.
  * Part 2d "What stayed, what left" + "Open items" updated to
    reflect the four code/doc moves.

119 Elixir scorer tests green, 134 Rust scorer tests green, 2,888
total Elixir tests + 221 properties green via `mix precommit`.
2026-04-25 12:42:56 -05:00
4f82bd691e
perf(prop): cap ScoreCache LRU + drop eager warmers to fix hot-pod OOMs
ScoreCache held 437 entries × ~1.94 MiB each (~850 MiB per pod) of
{band_mhz, valid_time} grids — data already on NFS as compact .prop
files. NotifyListener and ScoreCacheReconciler both eagerly materialised
every band into ETS on each Rust completion / 60s sweep, so 5 hot
replicas wasted ~4.2 GiB of redundant cache and OOMed at 6 GiB.

Bound the cache to 32 entries with eviction by oldest valid_time, drop
the eager warm loops, and let LiveView callers lazy-fill via the
existing ScoresFile read path. ScoreCacheReconciler had no remaining
purpose and is deleted; runbook + prom_ex counters updated to match.

Steady-state cache footprint ~60 MiB per pod instead of ~850 MiB.
2026-04-24 18:49:36 -05:00
86b2718916
infra(k8s): load-based HPAs, drop anti-affinity, retire talos5
Adds HorizontalPodAutoscaler resources for the three load-responsive
Deployments:

- prop (web): min 2, max 5, averageValue 800m CPU
- prop-grid-rs: min 1, max 4, averageValue 400m
- hrrr-point-rs: min 1, max 4, averageValue 400m
- prop-backfill is intentionally unmanaged (queue-driven, not CPU-bound)

The scale-up policy reacts in 30 s with up-to-2-pods or +100 % per
window; scale-down stabilises over 10 min so the hourly propagation
chain doesn't churn replicas right after the :05 cron.

Removes `spec.replicas` from the HPA-managed Deployments so Flux's
periodic reconcile stops fighting HPA's live scaling decisions.

Drops `podAntiAffinity` from prop, prop-grid-rs, and hrrr-point-rs
so HPA can actually reach its maxReplicas when one physical host has
capacity for two pods; the scheduler picks whatever fits.

Updates runbook + project memory to drop the `talos5` hostname —
Postgres is now addressed strictly by IP (10.0.15.30).
2026-04-23 18:43:18 -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
733a7f5bf1
fix(review): address code-reviewer findings
Fixes flagged by the code-reviewer agent's pass over the session's
commits (cc9220b..7b78a25):

- Propagation.warm_cache_and_broadcast/2 now uses ScoresFile.read/2
  directly and returns {:ok, :ok} | {:error, :enoent | :invalid_format}.
  Previously it called ScoresFile.read_bounds/3 which silently returns
  [] on missing/corrupt files, poisoning ScoreCache with an empty grid
  that the reconciler couldn't heal.
- NotifyListener.warm_band/2 and ScoreCacheReconciler.warm_one/2 now
  pattern-match {:error, reason} and skip (log, return :error) instead
  of caching empty. rescue clauses kept as defense-in-depth for
  unexpected faults in PubSub.broadcast / ETS writes.
- ScoresFile.extract_points/2 promoted to @doc public — callers that
  need to distinguish missing file from empty grid can feed read/2
  payloads here themselves.
- Weather.build_grid_cache_row/4: replaced || fallbacks with prefer/3
  helper (Map.fetch) so a legitimate persisted ducting_detected: false
  is not clobbered by a derived-from-sounding true.
- Weather.hrrr_data_fully_present?/1 @spec tightened from map() to
  Contact.t() | field-constrained map, plus is_nil(qso_timestamp)
  guard so callers with partial contacts get a clean false rather
  than a HrrrClient.nearest_hrrr_hour/1 crash.
- AdminTaskWorker.native_derive bulk-UPDATE: chunk reduced 2000 → 500
  and wrapped in try/rescue with per-row fallback on Postgrex errors
  so a single bad row doesn't kill the remaining 1999 in its chunk.
- Runbook FM3 rewritten to match the actual code path (no rescue;
  explicit {:error, _} pattern match). FM5 clarifies the surviving
  PropagationGridWorker is a cron-fired seed worker, not a fallback
  compute path.
- Dialyzer: strict flags added (:error_handling, :unknown,
  :unmatched_returns, :extra_return, :missing_return). Baseline
  emitted 130 warnings, mostly discarded Task.start/PubSub/Logger
  returns; a follow-up will tighten those and backfill @specs.

New tests:
- ScoreCacheReconciler GenServer lifecycle: run_on_start true/false,
  interval_ms rescheduling, info-level log line.
- Weather.hrrr_data_fully_present?/1: nil qso_timestamp returns false.
- Weather.build_grid_cache_rows/2: explicit ducting_detected: false
  on profile beats derived true from sounding params.
- RadarFrameWorker: pins the NexradClient "NEXRAD n0q HTTP <code>"
  error string contract so permanent_error?/1 classification doesn't
  silently regress if the client's error format changes.
2026-04-21 10:09:46 -05:00
33be6d008f
feat(propagation): add ScoreCacheReconciler + pipeline runbook
Periodic 60s sweep re-warms ScoreCache from /data/scores whenever
{band, valid_time} pairs are on disk but absent in local ETS.
Covers the three silent-fail modes of the NOTIFY/LISTEN path:
dropped notifies on reconnect, missing listener, and stale NFS
reads. Each pod reconciles its own cache — no PubSub fan-out,
no coordination required. Rescue on File.Error handles the
rare window where an atomic-rename write races the reader.

docs/runbook_propagation_pipeline.md names every failure mode
in the Rust↔Elixir seam and what recovers it.
2026-04-21 09:24:16 -05:00
48708621c5
chore(mrms): delete pipeline — consumer AsosAdjustmentWorker already disabled
MrmsFetchWorker fired every 2 min on the :propagation queue, decoding an
MRMS PrecipRate GRIB2 into MrmsCache. Its only consumer,
AsosAdjustmentWorker, is disabled in all configs (dev / config / runtime).
Net effect: 30 GRIB2 decodes/hour of dead work on hot pods.

Removed:
- MrmsFetchWorker, MrmsClient, MrmsCache and their tests
- cron entries in config.exs, dev.exs, runtime.exs
- mrms_req_options stub in test.exs
- MrmsCache from supervision tree in application.ex
- stale MRMS references in PropagationGridWorker docstring and test

Also adds docs/plans/2026-04-19-rust-migration-phase3.md which tracks
the broader Stream A (f00 port) and Stream C (HrrrFetchWorker port)
follow-on work.
2026-04-19 17:25:59 -05:00
f40e88e305
docs: plan for GFS-driven extended-horizon forecasts (18 h → 240 h)
HRRR caps at f018, so beyond ~18 hours the map and path calculator
go blank. This plan scopes a GFS open-data ingest that runs
alongside HRRR to drive the 18-240 hour forecast window, with a
visible seam on the timeline where the resolution drops from 3 km
to 0.25°.

Scope: GFS 0.25° CONUS subset from AWS S3, four runs/day on the
standard cycle, chained fetch/score workers that mirror the HRRR
pipeline shape, per-source binary score files so HRRR and GFS stay
independent. ECMWF IFS HRES listed as a follow-up once GFS works.
2026-04-18 12:35:08 -05:00
5fd37a17fd
feat(propagation): per-band weight calibration from full-corpus correlation
Ran recalibrate_algo.py against the full local prop_dev (81,994 contacts,
18.6M HRRR rows) and derived per-band composite weights for the nine
bands with >=200 matched contacts. Moisture (dewpoint/PWAT/surface N)
is consistently beneficial through 5.76 GHz, reverses at 24 GHz; rain
scales sqrt(rain_k) not linearly so 24 GHz gets 0.215 rain weight
instead of the linear-ratio 0.95. 10 GHz stays as the reference band
(defaults); 47+ GHz inherits defaults (n<200).

- BandConfig.weights/1 returns per-band override or default fallback
- @band_configs carries :weights on 222/432/902/1296/2304/3400/5760/24G
- Recalibrator.compute_factors/3 + fit(band_mhz:) band-aware fitting
- Scorer + ContactLive.Show pass band_config to weights/1
- algo.md Part 2d documents the 2026-04-18 analysis + derivation rule
- scripts/derive_band_weights.py turns correlations into weight maps
- report preserved at docs/algo-reports/2026-04-18-recalibration.md
2026-04-18 10:19:26 -05:00
fa0f2828a6
chore(db): rename dev database to prop_dev, add prod restore script
Rename the local dev database from microwaveprop_dev to prop_dev so
the name matches the production database. Add scripts/restore_prod_to_local.sh,
which pulls the latest pg_dump off the backup server, drops the local
prop_dev, recreates it, and pg_restores into it.
2026-04-18 09:36:52 -05:00
13180d572e
NARR backfill plan + spike-captured inventory fixture
Replaces the broken ERA5/CDS path with NCEP NARR fetched anonymously
from NCEI. NARR is GRIB1, anonymous HTTPS, byte-range-fetchable, with
1979-01-01 → 2014-10-02 coverage that lines up perfectly against the
HRRR archive start. Variables match the existing era5_profiles schema
1:1 (DPT is a direct surface dewpoint record, no SPFH derivation
needed at the surface).

Phase 1 spike done by hand: byte-range fetched 5 surface records and
3 pressure-level records for DFW 2010-06-15 12Z. wgrib2 turned out to
NOT read GRIB1 messages (rd_grib2_seq_msg, grib1 message ignored, use
wgrib), so the plan now uses cdo for both GRIB1 decoding and Lambert
conformal nearest-neighbor remap. cdo -merge is the right operator
for combining records of different variables — cdo -cat is for time
concat and silently drops mismatched levels, shell cat produces an
invalid composite. cdo emits param numbers as varNNN; mapping table
is in the plan's Architecture section.

Spike values for DFW 2010-06-15 12Z (all physically sane):
  TMP_2m=296.94 K (23.8°C)   DPT_2m=294.49 K (21.3°C)
  HPBL=703 m                  PRES_sfc=99383 Pa
  PWAT=45.1 mm                TMP_850mb=291.52 K
  HGT_850mb=1547.8 m          SPFH_850mb=10.4 g/kg

Plan file kept at the original 2026-04-15-merra2-historical-backfill
filename for git history continuity even though the implementation
is NARR not MERRA-2 (history block at the top documents the pivot).
2026-04-15 17:10:46 -05:00
33a8ba0956
Move NFS share from skippy to node3 at 10.0.15.103:/data
node3's NVMe SSD is the new shared-storage home. Mount the whole
/data export from 10.0.15.103 at /data inside the pod (instead of
the old skippy export of /data/srtm at /srtm) so there's room for
future shared stuff — Parquet score files, maybe a DuckDB database,
or whatever the next thing is — without re-exporting a new path.

SRTM tiles now live at /data/srtm rather than /srtm. Update the
srtm_tiles_dir runtime config to match, and fix the two doc
pointers (CLAUDE.md, duckdb plan) that referenced the old path.

Already applied to the cluster and rolled out; pods confirmed
running with tiles visible at /data/srtm.
2026-04-14 14:25:42 -05:00
5ec66df135
Cut propagation_scores write cost with DELETE+COPY, skip-factors, UNLOGGED
The scoring+upsert phase was ~4m40s per forecast hour and dominated
wall time. Three stacked optimizations attack it from different
angles.

replace_scores/2 is a new hot-path writer that does DELETE WHERE
valid_time = $1 followed by a plain insert_all (no ON CONFLICT
resolution). The chain worker rewrites the full (valid_time, all
bands) slice every forecast hour, so conflict detection was pure
waste. AsosAdjustmentWorker still uses upsert_scores because it
only rewrites the subset of cells near a station.

factors is now nullable. Forecast hours f01-f18 pass factors: nil
so the JSONB encode + toast write is skipped entirely — roughly
halves the data volume per run. point_detail/4 coalesces nil to
an empty map so the JS popup renders without a TypeError, and
scorer_diff only pulls the most recent valid_time that still has
factors (the f00 row).

propagation_scores is now UNLOGGED, so inserts bypass WAL entirely.
Durability tradeoff: an unclean shutdown truncates the table, but
PropagationGridWorker rebuilds it from HRRR every 3h so a lost
table is re-populated within one cron cycle.

Also adds docs/plans/2026-04-14-duckdb-scores-storage.md — a
speculative plan for a flat-file / DuckDB rewrite with explicit
trigger conditions for when to pick it up (partitioning deferred
too; revisit only if these three don't solve it).
2026-04-14 13:47:35 -05:00
34212c4910
Refresh 2026-04-13 recalibration report 2026-04-13 12:29:51 -05:00
5af4142e28
Extend recalibrate_algo.py with NEXRAD, native-duct, commercial sections
Three new analysis passes in scripts/recalibrate_algo.py that match the
scoring factors landed in commit 86364f4:

* NEXRAD composite reflectivity vs distance per band — checks that the
  Marshall-Palmer rain rate in Scorer.dbz_to_rain_rate_mmhr/1 moves the
  needle in the expected direction at 24+ GHz.

* hrrr_native_profiles.best_duct_band_ghz vs distance per band —
  validates the 1.15× boost in Scorer.score_refractivity/4. Current
  corpus shows essentially zero contacts with a native duct supporting
  ≥5 GHz at the target band, consistent with the 0.12% rate in Part 2c;
  the boost is physics-correct but almost never fires today.

* Commercial-link rx_power degradation vs contemporaneous DFW contacts —
  pearson + binned distance. Empty today because commercial_samples only
  covers Mar 30–Apr 13 and contests don't overlap. Scaffolding is in
  place so the moment contest-season overlap exists, the signal shows.

Report rebuilt at docs/algo-reports/2026-04-13-recalibration.md.
2026-04-13 12:14:05 -05:00
fc3eb58910
Refresh propagation algo against expanded prod corpus (2026-04-13)
Direct queries against prod (75M HRRR profiles, 16,864 soundings, 392k
surface obs, new hrrr_native_profiles table) drive these changes:

* Reinstate shallow-BL bonus as an HPBL multiplier on the refractivity
  score (1.10× <200m → 0.78× ≥2000m). The Apr 11 "shallow BL bonus
  removed" conclusion was an artifact of the prior matching strategy;
  with the cleaner contact↔HRRR join (n=680) the binned data goes 230 km
  avg at HPBL <200m vs 100 km at ≥2000m, monotonic across all bins.
* Add 6 missing bands (142, 145, 288, 322, 403, 411 GHz) so contacts in
  those bands stop being silently dropped. Coefficients extrapolate
  ITU-R P.676/P.838 trends from the existing 134/241 GHz entries.
* Bump ERA5 poll timeout 10 min → 1 hour and Era5MonthBatchWorker
  max_attempts 3 → 5 so CDS slowness stops discarding tiles. Also dump
  the full failure body when CDS omits the message field — the prior
  "ERA5 job failed: nil" was masking real error reasons in oban_jobs.
* Add scripts/recalibrate_algo.py so this analysis can be re-run any
  time new data lands without manual SQL. Reads PROP_PROD_DB_URL from
  .envrc, drops a Markdown report into docs/algo-reports/.
* Append a dated Part 2c to algo.md documenting the corpus expansion,
  the honest accounting of historical HRRR coverage (only ~1,020 of 58k
  contacts are precision-matchable), and the empty era5/rtma/climatology
  tables. Update the gaseous-absorption and rain-attenuation tables to
  include the new bands.

Test suite: 1,335 tests, 0 failures.
2026-04-13 10:59:01 -05:00
8ea0c3b94a
Ingest Canadian radiosondes via UWYO + plans for RDPS/HRDPS
IEM's RAOB endpoint stopped keeping Canadian stations current — most
stalled around Sep 2024 — which leaves a gap in refractivity/ducting
calibration over all the CW*/CY* stations already in weather_stations.
MSC's own tephi CSV is fixed-width and rendering-focused; the WMO TEMP
bulletins would need a full decoder. University of Wyoming serves the
same stations in a clean space-delimited format, is current, and
accepts the 3-letter station code (drop the leading C from Canadian
ICAO). Its output shape matches IemClient.parse_raob_json/1 exactly,
so it drops straight into Weather.upsert_sounding/2.

- New UwyoSoundingClient with URL builder, Req-based fetch_sounding,
  and a fixed-width parse_sounding_html that extracts PRES/HGHT/TEMP/
  DWPT/DRCT/SKNT from the <PRE> block and DateTime from the <H2> header.
- New CanadianSoundingFetchWorker: Oban batch worker that finds all
  sounding stations whose code starts with C, picks the most recently
  publishable 00Z or 12Z slot (90 min publish delay), calls UWYO per
  station, and upserts with SoundingParams-derived refractivity/
  ducting fields.
- Oban cron at 01:30Z and 13:30Z (dev + prod) to drive the worker.
- Req.Test plug wiring in config/test.exs.
- Captured Goose Bay fixture as test/support/fixtures/uwyo_sounding_yyr.html.

Also drops two implementation plans under docs/plans/:
- 2026-04-13-rdps-vertical-profiles.md: ~2 day plan for RDPS 10 km
  Canadian ducting outside HRRR's Lambert footprint.
- 2026-04-13-hrdps-canadian-prop-grid.md: ~5 day plan for the full
  HRDPS 2.5 km Canadian prop grid. Explicitly recommends doing RDPS
  first.

TDD throughout, 19 new tests, 1318/1318 passing, credo strict clean.
2026-04-13 09:14:34 -05:00
a66d3094ca Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields
(callsigns, grids, band, mode, timestamp). Edits enter an admin
approval queue with field-by-field diff view. On approve, changes
are applied and enrichment re-enqueued if grids/band changed.
Users receive email notification on approve or reject.

Also updates dependabot.yml for mix ecosystem.
2026-04-11 16:15:49 -05:00
0f0e5e8d43 Phase 3 spike (parked) + Phase 6: temperature anomaly
Phase 3 NEXRAD spike: IEM n0q composite available at 5-min cadence
back to 2022+. Compression-ratio proxy shows afternoon images have
13-81% more texture than dawn (directionally correct), but the n0q
product thresholds out the faint clear-air returns needed for BL
stability detection. Parked until MRMS or Level III products can be
investigated. See docs/research/nexrad_spike.md.

Phase 6: hrrr_climatology table aggregating surface_temp_c by
(lat, lon, month, hour) from the 42M+ hrrr_profiles grid-point
rows. mix hrrr_climatology builds it via a single SQL GROUP BY +
upsert. Backtest.Features.temperature_anomaly computes current_temp
minus climatological mean — the meteorologist's "temperature
deviation above normal" predictor for summer afternoon enhancement.
2026-04-10 08:47:11 -05:00
900685aa06 Phase 1 tasks 1.1-1.5: HRRR native hybrid-sigma ingestion
- Spike docs at docs/research/hrrr_native_levels.md confirming files
  are on AWS S3 for 5+ years, 50 hybrid levels, and include TKE and
  SPFH needed for Phase 2 turbulence features. Architectural finding:
  per-point on-demand fetching is impractical (~530 MB/file), so
  the ingestion worker batches per (date, hour) instead.
- hrrr_native_profiles schema: arrays per level plus cached surface
  scalars and placeholder columns for Phase 2/4 derived fields.
  Strictly additive — the existing hrrr_profiles table is untouched.
- HrrrNativeClient: pure URL/message-list helpers, build_native_profile/1
  that turns a parsed wgrib2 map into the schema shape (TDD'd).
- Exposed HrrrClient.download_grib_ranges/2 so the native client
  reuses the existing parallel byte-range download + disk cache.
- HrrrNativeGridWorker: Oban worker keyed on {year, month, day, hour},
  unique at :infinity, pulls distinct (lat, lon) points from contacts
  in the ±30 min window, downloads the native grib2, extracts per
  point, bulk-upserts.
- mix hrrr_native_backfill --limit N enqueues the top-N hours by
  contact count.

Phase 1 gate still pending Task 1.6 (sanity-check backtest after
live data lands).
2026-04-09 16:23:51 -05:00
34489ac9f4 Plan: propagation modeling improvements from April 2026 review
Multi-phase plan to incorporate the meteorologist's feedback on
algo.md: hybrid-sigma vertical resolution, boundary-layer turbulence
features, ray-traced duct geometry, frontal geometry, radar-based
stability, climatological anomalies, regional seasonal tuning, and
5-minute METAR. Every phase has a backtest-gated stop criterion so
features that don't show lift against the historical QSO corpus
don't ship.
2026-04-09 16:10:54 -05:00
7652e41971 Path-integrated HRRR scoring across pos1/mid/pos2
Contact scoring now uses all HRRR profiles along the path instead of
just pos1. Aggregation strategy:
- Best along path for beneficial factors (refractivity, pressure)
- Worst along path for harmful factors (rain, wind)
- Average for neutral factors (temp, dewpoint, PWAT, BL depth)

Scorer.path_integrated_conditions/2 merges multiple profiles into a
single conditions map. Falls back gracefully to single-profile scoring
when only one profile is available.
2026-04-07 12:04:16 -05:00
fea9523943
Correct Finding 10: Great Lakes firing squad explains 24G PH anomaly
SSB is not possible on rainscatter - the original analysis was wrong.
The 24 GHz PH advantage is entirely a contest strategy artifact: Great
Lakes operators line up on opposite shores for rapid-fire SSB contacts.
With EN/CM/DM cluster activity removed, CW leads by 16% at 24 GHz.
CW advantage is monotonically increasing with frequency as physics
predicts. No 24 GHz anomaly exists.
2026-04-01 11:07:58 -05:00
95907c90d3
Detailed Finding 10: mode advantage breakdown by band/frequency
CW advantage scales dramatically with frequency: +29% at 10G, +48% at
47G, +221% at 75G. At 24 GHz the pattern reverses (PH wins by 8%)
due to rainscatter favoring wider-bandwidth modes. Above 122 GHz,
100% of contacts are CW — SSB can't close the path.
2026-04-01 10:26:55 -05:00
69b5caf876
Normalize ML features to prevent NaN gradient explosion
Raw features had vastly different scales (pressure ~1013, sin/cos ~[-1,1])
causing gradient explosion. Normalize all atmospheric features to ~[0,1]
using known physical bounds. Add Polaris dep for optimizer.
2026-04-01 09:08:33 -05:00
e82e631135
HRRR forecast hours (f00-f18) with timeline map UI
- HrrrClient.hrrr_url accepts forecast_hour param (wrfsfcfHH.grib2)
- PropagationGridWorker fetches all 19 forecast hours per run
- Propagation.scores_at/3 queries scores at specific valid_time
- Propagation.available_valid_times/1 returns all forecast times for timeline
- Pruning keeps scores with valid_time >= now - 2h (forecast-aware)
- MapLive: select_time event, timeline data pushed to JS
- JS: forecast timeline bar at bottom of map with clickable hour buttons
- PubSub broadcast sends list of valid_times instead of single time
2026-03-31 16:44:47 -05:00
d97de91978
Add meteorologist feedback on model choice to RRFS doc 2026-03-31 09:27:23 -05:00
0ed10e695c
Add RRFS data assessment — not yet suitable, continue with HRRR 2026-03-31 09:25:45 -05:00
a283ad9c66
Refactor HRRR fetch to batch points per hour
QSO enrichment now groups all path points by HRRR hour and creates
one batch job per hour instead of one job per point. The batch job
downloads the GRIB2 data once and extracts all needed points from
the same binary. Legacy single-point jobs are still supported for
backward compatibility.
2026-03-30 17:21:47 -05:00
10e8feb486
Add commercial link monitoring via SNMP polling
Poll UBNT AirFiber radios (AF11X + AF60-LR) every 5 minutes via
net-snmp CLI, storing signal metrics in commercial_samples. Fetches
ASOS weather alongside each cycle for propagation correlation.

Includes 7 seeded link definitions, Oban cron worker, and net-snmp
in the Docker image.
2026-03-30 13:02:59 -05:00