Commit graph

398 commits

Author SHA1 Message Date
b8eea1bd45
prom: wire rust workers into prometheus
prop-grid-rs already exposed prop_grid_rs_chain_step_duration_seconds
+ tasks_in_flight on :9100 but the comment still pointed at the old
prom server (10.0.15.25). hrrr-point-rs had no metrics listener at all.

- metrics.rs: new POINT_BATCH_DURATION / POINT_BATCHES_TOTAL /
  POINTS_PROCESSED_TOTAL series + record_point_batch helper, plus unit
  tests serialized via a static Mutex (the prometheus crate's global
  registry races otherwise)
- hrrr_point_worker.rs: spawn metrics::serve on METRICS_ADDR (default
  0.0.0.0:9100), wrap process_batch in InFlightGuard, record outcome +
  per-point counts on every batch
- deployment-hrrr-point-rs.yaml: prometheus.io/{scrape,port,path,job}
  annotations, METRICS_ADDR env, ports.metrics, /health readiness probe
- deployment-grid-rs.yaml: refresh stale 10.0.15.25 comment, add
  prometheus.io/job for clean dashboard up{} selectors

Also fix Microwaveprop.PromExTest: `assert plugins != []` triggered
Elixir 1.19 type-checker warning ("non_empty_list != empty_list always
true"); replaced with membership assertions for the InstrumentPlugin +
Plugins.Beam.
2026-05-08 10:36:49 -05:00
99103e5cd6
test: PromEx + Canopy.BulkFetch + Maidenhead.valid? coverage
- PromEx: added dashboards/0 + plugins/0 tests
- Canopy.BulkFetch: 0% -> covers run/4 entry path (all tile fetches fail
  cleanly without network); cache/staging dirs created
- Maidenhead.valid?/1: rejects integer/atom/tuple/list/map inputs
- Canopy.tile_filename/2: S/E hemisphere prefix branches
- ScoreCache.max_entries/0 invariant test

3624 tests, 0 failures.
2026-05-08 10:06:36 -05:00
da332d3174
test: 100% coverage push — Valkey.RedixAdapter, Buildings.BulkFetch, AprsRepo, Maidenhead, Canopy
- Valkey.RedixAdapter: 0% -> 100% (delegation lines exercised)
- Buildings.BulkFetch: 0% -> 56% (run/4 with stubbed empty index)
- AprsRepo: 50% -> exercises repo config + Ecto.Repo callbacks
- Maidenhead.valid?/1: covers non-binary non-nil arms
- Canopy.tile_filename/2: S/E hemisphere prefix branches
2026-05-08 10:01:28 -05:00
35caae861e
test: more Weather + RoverLive + GefsFetchWorker coverage tests
- Weather: warm_grid_cache_*, materialize_scalar_file/1,
  build_grid_cache_rows/3 bounding-box filter
- RoverLive: rover_progress info handler, select_candidate Maidenhead
  fallback + malformed grid no-op
- GefsFetchWorker: build_profile_attrs/3 comprehensive 14-field test

Total: 82.74% (up from 82.7%).
2026-05-08 09:46:16 -05:00
363646f34f
test: enable :slow tests by default + add coverage tests across modules
Removed the `exclude: [:slow]` from test_helper.exs — the slow-tagged
wgrib2 fixture tests pass with the binary on PATH and pull
Microwaveprop.Weather.Grib2.Wgrib2 from 23% to 90%+ in coverage runs.

Additional tests:
- Pskr.Client: tcp/tcp_closed/tcp_error stale-socket fall-throughs
- Weather context: weather_point_detail/3, latest_grid_valid_time/0,
  available_weather_valid_times/0, available_hrdps_valid_times/0
- PathCompute.compute/4: with HRRR profile near the path
- RoverPathProfileWorker: real Path row + skip-when-complete
- RoverLive: URL stations encoding, add_station error paths
- PathLive: rover_path_id branches (404, malformed UUID)

3599 tests, 0 failures.
2026-05-08 09:28:51 -05:00
888701e627
test: bump coverage 80% -> 81.3% via more LiveView + module tests
- Pskr.Client: 0% -> 18% (init/standby/handle_info/terminate)
- RoverPlanningLive.PathShow: 0% -> 82% (mount/load_path)
- Rover.CandidateDetail: 0% -> partial (summarize/5 with stations)
- Mix.Tasks.Weather.RebatchAsos: 0% -> covered (wrapper run/1)
- HrrrNativeClient: 57% -> 67% (fetch_native_duct_grid error paths)
- RoverLive: 40% -> 48% (toggle/delete/update_station_grid logged-in)
- PathLive: 62% -> 63% (path_forecast_detail + rover_path_id branches)
- PathCompute: 64% -> 64% (compute/4 full pipeline + resolve_location)
- SkewtLive: 29% -> 31% (search/select_time event no-ops)
- SkewtLocationResolver: 38% -> 60%+ (callsign cache + error paths)

3557 tests passing.
2026-05-08 09:10:34 -05:00
f644d1796f
test: push coverage 79.64% -> 80.04% across multiple modules
New test files: RoverPlanning.Station, LiveStashGuard, RebatchAsos,
RoverMissionReconcileWorker, RoverPathProfileWorker. Extended existing
suites for PathCompute (resolve_location, build_ionosphere_readout),
HrdpsClient (DEPR fallback, missing-level skip), NexradClient
(fetch_rain_cells HTTP error paths), UserHomeQthLookupWorker (error +
Maidenhead-fallback paths), RoverPlanning.Path (statuses/0 + schema).

3512 tests passing.
2026-05-08 08:42:03 -05:00
06a54075f8
test: http_get injection for GefsClient + HrrrClient
- GefsClient (73%): http_get injection for fetch_idx + grib download,
  tests for timeout + HTTP error paths, dewpoint_from_rh edge cases
- HrrrClient: http_get injection for do_fetch_idx + range downloads
- GefsClient fetch_grid_profiles error-path tests with stubbed HTTP

Coverage: 79.68%
2026-05-07 16:44:40 -05:00
cea2507fac
test: HTTP injection for MsFootprints/HrdpsClient/NexradClient (79.68%)
- MsFootprints (51% → 93%): http_get injection for dataset_index and
  download_tile, with stubbed CSV parsing, empty CSV, caching, and
  disk-cache-skip tests
- HrdpsClient (47% → 71%): http_get/http_head injection for fetch_grid
  and cycle_available, with stubbed probe, success/failure/transport-
  error tests, plus fetch_grid error path
- NexradClient (57% → 58%): http_get injection, fetch_frame success
  path with valid PNG stub, process_frame coverage
- HrrrNativeClient: http_get injection for fetch_idx (no direct test
  since function is private)

Coverage: 79.43% → 79.68% (need 0.32% more)
2026-05-07 16:36:17 -05:00
f82dfb4d00
fix(test): GridCache Valkey fetch_point bug + data-path tests (GridCache 49%→84%)
- Fix production bug in valkey_fetch_point: Map.get(chunk_map, {lat, lon})
  was looking up lat/lon in the outer map keyed by chunk tuple, not the
  inner cells map. Added chunk_key unwrap before lat/lon lookup.
- DataStubAdapter returns real chunk data for fetch/fetch_bounds/fetch_point
- Tests cover: fetch with decoded rows, fetch_bounds filtering, fetch_point
  with chunk unwrap, put with large row sets, claim_fill/release_fill

Coverage: 79.30% → 79.43% (GridCache 74% → 84%)
2026-05-07 16:15:02 -05:00
5d90d52a45
test: format all test files + GridCache Valkey backend (49%->74%)
Coverage: 79.09% -> 79.30% (GridCache 49% -> 74%)
2026-05-07 16:04:32 -05:00
6028e8a838
test: add GridCache prune_older_than ETS test
Coverage: 79.07%
2026-05-07 13:59:42 -05:00
3a2a28437d
test: property tests for PathCompute loss/power budget + Viewshed destination
- PathCompute: compute_loss_budget/5 and compute_power_budget/2 made
  public with @doc false; property tests covering fspl, o2, h2o, rain,
  diffraction across all 13 known bands + varying distances
- Viewshed: property tests for destination_point back-bearing round-trip
  and east-west bearing at equator (2 new properties)

Coverage: 79.07% → 79.06% (PathCompute 60→63%)
2026-05-07 13:56:28 -05:00
89e8bb2267
test: use Mox for Valkey tests without real Redis (55%→88%)
- Create Microwaveprop.Valkey.Adapter behaviour with command/3 + pipeline/3
- Create Microwaveprop.Valkey.RedixAdapter as the production impl
- Inject adapter via Application config; mock with Mox in tests
- Cover all Valkey operations: get, mget, set, mset_with_ttl, zadd,
  zrevrange, zrangebyscore, zrem, del, scan_match (multi-cursor)
- Full round-trip tests for encode/decode, error paths, edge cases

Coverage: 78.93% → 79.07%
2026-05-07 13:48:08 -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
b764034e46
test: push coverage to 78.6% — MapLayers 100%, LinkMargin 100%, Elevation 94%
- Weather.MapLayers: unit tests for all/0, default_id/0, valid_id?/1 (25→100%)
- Rover.LinkMargin: link_margin_db ScoresFile round-trip tests (57→100%)
- Rover.Elevation: SRTM tile file read path + void sentinel (44→94%)
- Pskr.Aggregator: pending_count, empty flush tests (65→74%)
- Buildings.MsFootprints: quadkey/bbox pure function tests (51→51%)
- Weather.Grib2.Wgrib2: property tests for parse_lon_val_segment (23→23%)
- test/support/data_case: File.rm_rf -> rm_rf to fix concurrent race
2026-05-07 13:17:40 -05:00
9ef10f5aae
test: expand coverage across rover, pskr, valkey, skewt, scores
- Rover.Compute: road/prominence/clearance/clutter/canopy tests (90%)
- Rover.Prominence: full unit test suite with mock elev_lookup (100%)
- Rover.Elevation: dedup + multi-tile tests (44%)
- Rover.LinkMargin: edge cases, negative scores, all modes (57%)
- Rover.Location: changeset validation + statuses/0 (100%)
- RoverPlanning.Path: changeset validation + statuses/0 (67%)
- Pskr.FeatureBin: changeset validation (100%)
- Pskr.Mqtt: QoS reject, unknown type, varint overflow, ping/disconnect (97%)
- Valkey: not-configured error paths, empty guards (55%)
- ScoresController: bad params, time format, missing band tests (81%)
- SkewtLive: info/no-profiles state, loading states (29%)
2026-05-07 12:51:58 -05:00
a0065e2f44
refactor: deduplicate hrrr download, simplify radio/mqtt, fix credo/perf issues
- hrrr_client: extract shared fetch_grib_ranges/2 to eliminate ~80% duplicated code
- mqtt: encode_varint/1 uses IO list accumulator instead of O(n^2) binary concat
- commercial: single-pass reduce for aggregate_degradation, remove unused average/1
- scorer: single-pass reduce for safe_avg and build_path_conditions
- terrain_analysis: split 76-line do_analyse/4 into 3 focused functions
- radio: build_position_changes/3 simplified from then-chains to Enum.reduce
- recalibrator_test: fix credo warning (length > 0 -> != [])
2026-05-07 11:50:29 -05:00
fe2ad2df15
fix(pskr): self-healing follow-up sampler runs for hours with missing HRRR
The producer-only path left older hours stuck at 0% HRRR coverage
forever once their fetch tasks drained: the rolling-window cron only
re-passes the prior hour, so manual lookback runs (or any
out-of-window hour) wrote NULL samples + enqueued tasks, then never
re-ran to UPSERT the now-landed HRRR data.

Fix: split the responsibility cleanly.

* CalibrationSampler.build_for_hour/1 now returns
  %{upserted: int, missing_hrrr_cells: int} so callers know whether
  the producer enqueued anything (i.e. whether a follow-up makes
  sense). Spec-tightened with a new @type result.
* PskrCalibrationWorker, after each hour's sampler pass, schedules a
  follow-up of itself for that exact hour 10 min later when missing
  > 0. The follow-up carries args %{"hour_utc" => ..., "_follow_up"
  => true}; the sentinel prevents follow-ups from chaining further
  follow-ups (rolling-window cron is the safety net).

Tests:
* Updated 4 existing sampler tests to the new map return shape.
* Moved the follow-up assertions from the sampler suite into the
  worker suite where they belong (sampler is now scheduling-free).
* Added 3 worker tests: schedules-when-missing, no-schedule-when-
  covered, follow-up-doesn't-chain.

3313 tests + 228 properties, 0 failures.
2026-05-05 18:00:10 -05:00
7ee19c5b6a
perf+test: cache nearest_hrrr per cell, batch pg_inherits, +property tests
Two performance fixes the property tests pinned down:

* CalibrationSampler.build_for_hour/1 walked every cell through
  nearest_hrrr/3 twice — once in the producer and again in
  build_sample. Now computed once into a (band, lat, lon) → hrrr_or_nil
  map, halving the O(cells × profiles) scan (~10M comparisons saved
  per fire at typical sizes).
* PartitionManager.ensure_quarterly_partitions/2 issued one
  pg_inherits scan per (parent × lookahead). Refactored to one scan
  per parent with in-memory coverage check across all quarters.

Property tests for both modules:

* PartitionManager: tile-coverage invariant (no gaps, no overlaps),
  exact 3-month bounds, name format, multi-call idempotence,
  multi-parent independence — caught a real NaiveDateTime sort bug
  in the original test helpers. Default Enum.sort_by/2 falls back to
  term comparison on NaiveDateTime; now uses NaiveDateTime as the
  comparator module.
* CalibrationSampler: enqueued points = unique missing midpoints,
  empty enqueue when fully covered, sample row count invariant under
  HRRR availability.

Saved operational gotchas to CLAUDE.md (Oban leader election spans
all app=prop pods, kubectl exec deploy/prop ambiguity, half-year
partition coexistence, HRRR f000 publish lag).

3310 tests + 228 properties, 0 failures.
2026-05-05 17:40:13 -05:00
f78d4ccb9f
test(pskr): two-pass HRRR pipeline integration test + tighter specs
Adds the test that verifies the full producer→drain→re-pass loop:
sample written with NULL HRRR fields + fetch task enqueued, then once
an HRRR profile lands, the next sampler pass UPSERTs the same
calibration_samples row (id preserved) with non-NULL weather data.

Also tightens dialyzer surface:

* PartitionManager defines a `result()` typedoc and uses it on both
  public functions instead of inlined tuple types.
* PartitionMaintenanceWorker.perform/1 + the lookahead/1 helper get
  explicit specs.
* CalibrationSampler.enqueue_missing_hrrr/3 gets a spec covering its
  group_by-shape input map and HRRR profile list.

`mix precommit` clean (3310 tests, 0 failures). Local `mix dialyzer`
hits an OTP 28.4 vs 28.5 toolchain mismatch unrelated to this change.
2026-05-05 17:31:17 -05:00
413cc92cab
feat(infra): auto-extend quarterly partitions for hrrr/hrdps tables
Partition list for hrrr_profiles + hrdps_profiles was hand-edited in
priv/repo/structure.sql; if no human extended it, writes for the next
quarter would silently fail at the worker level (Rust hrrr-point-worker
marks tasks failed; the calibration pipeline silently joins NULLs).

Adds:

* Microwaveprop.PartitionManager — runtime DDL helper. Quarter math via
  a single integer index (year*4 + quarter) for rollover safety.
  Coverage check via pg_inherits before CREATE so a quarterly target
  inside an existing wider partition (some 2027 ones are half-year)
  is treated as :exists rather than colliding.
* Microwaveprop.Workers.PartitionMaintenanceWorker — daily cron at
  03:15 UTC, 4-quarter lookahead. Idempotent; cheap when nothing's
  missing. lookback_quarters arg lets ops widen for catch-up.

Tests cover the create + idempotent re-run + name format + year
rollover paths via a synthetic parent created inside the sandbox
transaction (no DDL leaks into the shared test DB).
2026-05-05 16:22:16 -05:00
f668b457cb
feat(pskr): enqueue hrrr_fetch_tasks for cells missing HRRR data
The CalibrationSampler joined PSKR midpoints against hrrr_profiles via
nearest-within-window, but post-Phase-3 Stream C nothing bulk-populates
the grid — hrrr_profiles only sees per-QSO point fetches from the Rust
hrrr-point-worker. Result: 0 / 16,042 prod samples had HRRR data.

Producer pattern: when a cell has no profile in the lookahead window,
enqueue a row into hrrr_fetch_tasks at the cell's HRRR-grid-rounded
midpoint. The Rust worker drains it; the next 5-min rolling-window
pass UPSERTs the same sample with non-NULL weather fields. Idempotent
with the existing on-conflict design — no extra worker, no churn on
cells already covered, multiple bands sharing a midpoint dedupe to a
single fetch via Enum.uniq + jsonb point-union in HrrrPointEnqueuer.
2026-05-05 16:14:43 -05:00
1a3dc4fa0a
fix(pskr): drop spots with <6-char grids on either end
A 4-char locator covers ~70 km × 100 km, which dwarfs HRRR's 3 km
native cell and any midpoint we'd derive for the calibration corpus.
Storing those samples gives the recalibrator geometry that's
indistinguishable from noise.

fetch_grid/2 now returns {:error, {:short_grid, key, grid}} for any
locator under 6 chars after Maidenhead validation. The spot is
dropped before it reaches the aggregator, so neither pskr_spots_hourly
nor pskr_calibration_samples ever see it.
2026-05-05 09:29:25 -05:00
b08fc5e449
feat(pskr): rolling-window calibration sampling, every 5 min
Old design fired hourly at HH:25 and built one whole hour of cells
in a single batch. During PSKR peaks (50k+ spots/hour) that batch
held the DB pool long enough to look like a backlog, and a pod
restart between fires lost a full hour because the next cron only
caught the most-recent-prev hour.

- Default window is now current hour + 1 prior. Every 5-min fire
  spreads work across the in-flight hour and always covers the
  boundary without waiting for the next cron tick.
- lookback_hours arg widens the window for catch-up
  (e.g. {"lookback_hours": 24} sweeps the last day after an
  outage).
- Unique period 3600 → 240 so consecutive 5-min cron fires actually
  slot in.

UPSERT is already idempotent so the ~12 redundant passes per hour
cost only the read+merge. Latency from spot ingestion to sample
creation drops from up-to-60min to ≤5min.
2026-05-04 16:46:59 -05:00
a68027b00b
feat(scoring): per-band mechanism stack via cell_score/2
Replaces the "tropo composite + post-hoc aurora boost" model with a
max-over-mechanisms dispatcher so each band's score reflects the
dominant propagation channel rather than summing alternative paths.

- BandConfig.mechanisms/1 derives the stack from frequency:
  ≤432 MHz gets [:tropo, :aurora]; >432 MHz stays [:tropo]. Bands
  may override with an explicit :mechanisms field.
- Scorer.mechanism_score/3 returns a standalone integer score for
  one mechanism (:tropo delegates to composite_score; :aurora is a
  clean Kp/freq function, not a boost on top of tropo).
- Scorer.cell_score/2 takes max across the band's mechanisms so a
  Kp-6 storm scores 6m at 85 even when the tropo picture is poor,
  instead of conflating the two channels.

Es and F2 stay in PathCompute since they're path-distance-dependent.
The legacy aurora_boost path is unchanged so no callers shift today.
2026-05-04 16:46:48 -05:00
48000b0dca
feat(pskr): weekly recalibration analysis on the calibration corpus
`Pskr.Recalibrator.run/0` reads `pskr_calibration_samples`, bins
each sample per (band × feature), and writes spot-density stats to
`pskr_feature_bins` so an operator can read whether a feature
actually discriminates propagation at the threshold granularity
the scorer uses.

Bins, not regression: `BandConfig` already encodes scoring as
discrete thresholds, so the bin output matches that shape and an
operator can copy adjusted thresholds directly without translating
from regression coefficients.

Self-healing: corpus too thin ⇒ run row written with status
`skipped_insufficient_data` and the analysis is a no-op until next
fire. `min_total_samples = 1000` (≈ 4-5 days of CONUS PSKR
activity); per-band threshold is 100. Both surface in the run row's
`notes`.

Auto-applies nothing. Weight changes still go through human review
of `BandConfig.@band_configs` and a code commit. The recalibrator
is a read-only analyst that stays out of the production scoring
path.

Features binned (matching the scorer's discriminating fields):
  * pwat_mm — humidity U-shape candidate
  * hpbl_m — boundary layer (mechanism vs scoring re-eval)
  * min_refractivity_gradient — refractivity threshold validation
  * surface_pressure_mb — pressure-front proxy
  * kp_index — aurora boost magnitude tuning

Schema: two tables.
  * `pskr_recalibration_runs` — one row per fire with corpus
    stats, status, notes
  * `pskr_feature_bins` — one row per (run, band, feature, bin)
    with sample_count, spot_count_total/avg/p50/p90

Cron: `0 4 * * 0` (Sundays 04:00 UTC, off-peak, post-climatology).
Manual reruns enqueue with no args.

Tests cover the empty-corpus skip path, sub-threshold totals,
per-band threshold gating, the actual bin emission, nil-feature
handling, spot-count averaging, and the always-records-a-run
audit invariant. 8 new tests, 3282 total passing.

Backfill pipeline untouched.
2026-05-04 16:18:17 -05:00
7a2b1f292c
feat(pskr): hourly calibration sampler joining spots × HRRR × Kp
PSK Reporter is now an always-on data feed, so the calibration
corpus that recalibration will eventually train against can grow
hourly from when the firehose started. One row per (hour, band,
0.125° midpoint cell) joins three sources:

  * `pskr_spots_hourly` — observed spot density (truth signal)
  * `hrrr_profiles` nearest match at the cell midpoint at hour
    boundary (atmospheric features: T, Td, PWAT, P, dN/dh, HPBL,
    ducting flag)
  * `geomagnetic_observations` latest Kp at the hour (space weather)

Predicted scores are intentionally NOT stored — they're a function
of the algorithm version under evaluation. Storing only features
keeps the corpus stable across every weight refit; the recalibrator
computes predictions on demand.

Components:

  * Migration `20260504210756_create_pskr_calibration_samples` —
    new table + unique index on (hour, band, midpoint_lat, lon),
    plus a midpoint spatial index on `pskr_spots_hourly` to keep
    the join cheap.
  * `Pskr.CalibrationSample` — schema mirror of the table with
    the same `(hour, band, midpoint_lat, lon)` unique constraint.
  * `Pskr.CalibrationSampler.build_for_hour/1` — pulls all spots
    for the hour, snaps midpoints to the propagation grid (0.125°),
    builds an in-memory HRRR index over a ±0.07°/±60 min window,
    and bulk-upserts samples. Idempotent — re-runs upsert.
  * `Workers.PskrCalibrationWorker` — Oban cron entry on the
    `:backfill_enqueue` queue. Default args target the previous
    full hour; explicit `hour_utc` arg reruns any hour.
  * Cron `25 * * * *` — fires past HRRR analysis publish window
    (~HH:50→HH:05) and PSKR aggregator's 60s flush.

Forward-only: PSKR has no historical archive, so the corpus only
grows from when the feed started. Recalibration weight refits
should wait for ~30 days / ~10k samples to cover diurnal and
synoptic variability.

Tests cover cell-snapping, HRRR feature joining, Kp stamping,
median-distance aggregation, mode dedup, idempotent reruns, and
the worker's default-hour and explicit-hour args (12 new tests,
all passing).

Backfill pipeline untouched — none of these changes feed into
contact enrichment.
2026-05-04 16:12:47 -05:00
7702b9e161
feat(scoring): aurora boost on VHF/low-UHF during geomagnetic storms
`Scorer.aurora_boost(score, kp, band_config)` is a terminal boost
applied after `composite_score` in the same shape as the existing
`commercial_link_boost`. It tracks the NOAA G-scale (Kp 5+ opens
auroral E-region paths up to ~2500 km) and is freq-gated to
≤ 432 MHz — auroral propagation is observed almost exclusively on
50/144/222, occasionally on 432, and never at microwave.

Boost magnitudes:
* Kp < 4 (quiet) — pass-through
* Kp 4 — +5
* Kp 5 (G1) — +15
* Kp 6 (G2) — +25
* Kp ≥ 7 (G3+) — +35, clamped to 100

Wired into `Propagation.score_grid_point/4` via the existing
`hrrr_profile` map: callers populate `:kp_index` once per cycle so
the per-grid-point band loop never queries the DB. The UI fallback
path (`factors_from_profile/5`) sources Kp from
`SpaceWeather.latest_kp/0` directly. Other call sites
(GEFS forecast, path-compute, contact-show) can opt in by adding
`:kp_index` to their profile map.

Diagnostic factor `:kp_index` is stashed on VHF/low-UHF results so
the operator-facing factor breakdown can show why a band scored
above its quiet-conditions baseline. Microwave bands don't carry
the diagnostic — they're never affected by the boost.

Refactored `score_with_algorithm/7` to delegate band-level
finalization to `finalize_band_result/5`, keeping the parent
function under credo's cyclomatic-complexity limit.

NOTE: this only affects the Elixir scoring path — the live grid
served by `prop_grid_rs` (Rust) does its own scoring and does not
yet honor Kp. Adding aurora to the Rust side is follow-up work.
2026-05-04 16:02:49 -05:00
1086f52c85
chore: delete dead RTMA + METAR5 code paths
Both RTMA and METAR5 schemas + clients + workers were defined but
never had a consumer. `find_nearest_rtma/3` and `recent_surface_obs/3`
existed in `Microwaveprop.Weather` with zero callers cluster-wide;
RtmaFetchWorker had test coverage but was never enqueued anywhere;
no IEM 5-min ASOS fetcher was ever written. The tables sat empty in
prod and the recalibrate audit was permanently flagging them BROKEN
even though the empty state was the steady state.

Drop the lot — schemas, clients, workers, queue config, Req.Test
stubs, audit checks, and the per-table unique tests in the
observation-changesets suite. New migration drops the now-unreferenced
`rtma_observations` and `metar_5min_observations` tables (both 0
rows in prod). Trim the `:rtma` slot out of `backfill_only_queues`
in runtime.exs so Oban Pro's Smart engine stops trying to manage a
queue with no producer.

Audit thresholds reset on the surviving NARR check: drop the
"WARN < 5000 absolute rows" rule that didn't account for
NarrFetchWorker's 0.13° spatial dedup, and point remediation at
the existing BackfillEnqueueWorker cron instead of the dev-only
`mix narr.backfill` task.

scripts/recalibrate_algo.py: ROW_COUNT_SOURCES + DATA_GAP_SQL +
DATA_GAP_RULES all stop referencing the dropped tables.
2026-05-04 15:09:19 -05:00
b924cc2fc2
feat(pskr): preserve full locator precision from MQTT spots
Spots that include subsquare (EM12kl), extended-square (EM12kl37),
or extended-extended-subsquare (EM12kl37ab) precision now keep
that resolution end-to-end. Previously we truncated everything to
4 chars at parse time, which collapsed distinct paths into the same
row whenever two spotters shared a field.

The aggregation key uses the full grid as reported, so two
spotters in different subsquares of `EM12` produce two rows —
correct, since they are physically different paths. Any spot that
fails Maidenhead validation (odd length, illegal char at position
N) is still dropped via the same error path.

Storage normalizes to uppercase so the unique index doesn't split
on case. Maidenhead.valid?/1 already accepts both cases on input.
2026-05-04 12:42:46 -05:00
480cc084cf
refactor(pskr): drop emqtt, talk MQTT 3.1.1 over :gen_tcp directly
emqtt's transitive `quicer` dep needs CMake + msquic submodule
clones to build, which the slim Debian builder image doesn't ship —
the prod CI build was failing inside `mix deps.compile`. Tortoise311
is the obvious pure-Elixir alternative but it bundles a
gen_state_machine + retry/inflight stack we don't use because we're
subscribe-only at QoS 0.

Implementing the wire protocol inline is ~150 lines in
`Microwaveprop.Pskr.Mqtt` (CONNECT/SUBSCRIBE/PINGREQ/DISCONNECT
builders, CONNACK/PUBLISH/SUBACK/PINGRESP parser, varint codec).
Pskr.Client now uses :gen_tcp with `active: :once` framing,
buffers partial reads, schedules its own keepalive, and parses
inbound packets in a tight loop.

Drops `BUILD_WITHOUT_QUIC=1` from the Dockerfile — no longer
relevant since there's no native dep at all in the build chain
now.
2026-05-04 09:52:34 -05:00
cfc5f7583f
feat(pskr): ingest PSK Reporter MQTT firehose for weather correlation
Subscribes to mqtt.pskreporter.info:1883 over plain TCP and folds
every reception report into hourly aggregates per
(band, sender_grid_4, receiver_grid_4). Each spot is the empirical
"propagation actually occurred on this path right now" signal we'll
correlate against HRRR atmospheric state to recalibrate the scoring
weights. Aggregating at the path-hour bucket collapses 50-reporter
QSOs to one row instead of 50.

Topic filter anchors on USA (DXCC 291) on either sender or receiver
side so the broker pre-filters out everything outside our HRRR
domain. Bands subscribed: VHF and up (6m, 2m, 70cm, 23cm, +
microwave) — HF is dominated by ionospheric propagation, which this
project doesn't model.

Pskr.Client cluster-elects a singleton via :global.register_name —
all replicas start the GenServer but only one connects to MQTT;
the rest stay in :standby and watch for the leader's nodedown to
re-run election. Off in dev/test, on by default in prod
(PSKR_MQTT_ENABLED=false as kill-switch).

Pskr.Aggregator buffers in memory keyed by Pskr.path_key/1 and
flushes every 60s via Repo.insert_all/3 with an additive
ON CONFLICT (count summed, SNR envelope by GREATEST/LEAST,
modes unioned via unnest+DISTINCT). Idempotent across overlapping
flushes and across leader handover.

Dockerfile sets BUILD_WITHOUT_QUIC=1 — emqtt's transitive quicer
dep wants CMake + OpenSSL headers we'd otherwise have to add to
the builder image just to never use QUIC over plain MQTT. Base
image is unchanged; the new dep compiles cleanly into the existing
prop-base runtime.
2026-05-04 09:24:20 -05:00
2c88fba1df
feat(rover-planning): multi-band missions + smart reconcile
Mission now carries bands_mhz ({:array, :integer}) — operator picks
one or more bands as multi-checkboxes. enqueue_paths_for builds the
cross product (rover x station x band) and persists each tuple as its
own Path row keyed by (mission_id, rover_location_id, station_id,
band_mhz). The path-profile worker reads band_mhz from the path
itself (legacy single-band jobs without band_mhz in args still resolve
to their unique row).

replace_mission_paths/1 is now a thin alias for reconcile_mission_paths/1
which diffs desired vs actual: stale tuples (old band that the user
unchecked, station they removed, rover-site they deleted) get dropped,
new tuples become :pending and enqueue, surviving :complete rows are
left in place — no more wholesale destruction of already-computed
paths on every edit.

The show table gains a Band column, and band_label() in the mission
summary becomes bands_label() (joins the list with commas).
2026-05-03 14:08:49 -05:00
23d002e8e0
feat(rover-planning): cache link-budget loss components per path
The path-profile worker now also computes the baseline link-loss
budget — free-space path loss, oxygen absorption, baseline H2O loss
(at 7.5 g/m^3 default humidity), plus the already-cached terrain
diffraction — and stores all four numbers in Path.result. The show
page renders the per-row total in a new Loss column. /path still
recomputes against live HRRR weather; this is the offline-readable
cached snapshot.
2026-05-03 13:58:18 -05:00
c2a23d1840
feat(rover-planning): hourly backfill worker recovers stuck paths
Adds RoverPlanning.backfill_paths/0 that walks every mission and
re-enqueues RoverPathProfileWorker jobs for any (rover × station) pair
whose Path is missing or not :complete. The path-profile worker
short-circuits on :complete, so this is idempotent.

Wires a new RoverMissionBackfillWorker into both dev (config.exs) and
prod (runtime.exs) crons at HH:30 — heals missions stuck in :pending
or :failed from transient elevation-API errors or interrupted
create_mission enqueues without operator action.
2026-05-03 13:52:27 -05:00
210e941db8
feat(rover-planning): propagation score column + linked location heading
The path-profile worker now also looks up the propagation grid score for
the path midpoint at the mission's band before flipping status to
:complete, so the row never appears 'done' until both terrain and
propagation prediction have run. The show table renders that score as a
red/yellow/green badge in a new Propagation column.

Each rover-location group heading is now a link to /rover-locations/:id
so the user can jump straight to the spot's detail page from the
mission view.
2026-05-03 13:35:54 -05:00
209244f364
refactor(rover-locations): rename status atoms ideal→good, off_limits→bad
Renames the rover-location status enum and every label that referenced
it. Existing rows are migrated in place. Also touches the consumers:

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

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

Admins can edit/delete any mission; owners can edit their own.
2026-05-03 11:04:11 -05:00
65e97bec6b
feat(aprs): add Aprs context for read-only queries against aprs.me 2026-05-01 12:47:35 -05:00
4759d1313e
fix(aprs): tighten PathParser self-loop check and Q-construct regex
Three spec-compliance fixes to PathParser:

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

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

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

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

Used by upcoming 144 MHz calibration to attribute aprs.me receives to
real digipeater geometry without persisting any APRS data on our side.
2026-05-01 12:35:53 -05:00
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
1da78a80e5
feat(hrdps): activate Canadian propagation chain end-to-end
Stages 6, 7, 10 of the HRDPS plan, plus the Elixir read-side merge
that makes Rust's `.hrdps.prop` writes show up on /map.

Read side:

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

Activation:

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

Cleanup:

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

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

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

What ships:

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

What's deferred:

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

Activation path documented in the updated plan file.

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

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

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

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

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

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

Plan task 1.6 (CYYR projection sanity check vs UWYO sounding) is
covered by the live probe at lib/mix/tasks/hrdps_probe.ex which
already validated wgrib2's rotated-grid handling against five
Canadian cities; the production implementation routes through the
same Wgrib2.extract_points_from_file path that the probe used.
2026-04-29 16:58:47 -05:00