Commit graph

1008 commits

Author SHA1 Message Date
ef0c73cb4d
Fix NARR accounting on status page and stop pre-2014 hrrr_status churn
Status page NARR progress counted `hrrr_status = :unavailable` as the
candidate set, which swept in ~13.6K post-2014 HRRR retry rows and
showed "0 / 13,652". Candidate eligibility is qso_timestamp-based, not
hrrr_status-based — switch both the numerator and denominator to
`qso_timestamp < NarrClient.coverage_end()` so pre-2014 rows are
counted whether their hrrr_status is :queued, :unavailable, or
:complete.

ContactWeatherEnqueueWorker: short-circuit hrrr_points_for_contact/1
for pre-coverage timestamps and mark the contact :unavailable when no
HRRR jobs are built. Was producing a ping-pong where reconcile flipped
pre-2014 :queued → :unavailable and the same cron run flipped it back
to :queued from a rebuilt HRRR job that could never land data.
2026-04-16 14:09:45 -05:00
f693cb5b56
test: override stale distance_km when filling a missing pos
Makes the 'always recompute' intent explicit by removing the dead-code
short-circuit in add_distance_change — the candidate query already
requires at least one nil pos, so any previously stored distance is
tied to a stale grid/pos pair and must be overwritten.
2026-04-16 14:09:45 -05:00
a669e60735
test: expand coverage on ionosphere + space_weather contexts
6 additional characterization tests for empty-list upsert paths, station
filter isolation on latest_observation/1, multi-band xray coexistence,
and newest-across-time/bands latest_* helpers.
2026-04-16 14:09:45 -05:00
c0281e6c55
test: cover accounts user + user_token
80 characterization tests: registration/admin/email/password changesets,
password hashing, admin-flag grant logic, and the full token lifecycle
(session, magic link, confirm, change-email) including expiry boundaries
and cross-context rejection.
2026-04-16 14:09:45 -05:00
391a162bcd
test: cover beacons/beacon schema
46 characterization tests: changeset validation, Maidenhead derivation
(both directions), bearing normalization, keying options, mW/frequency
formatting helpers.
2026-04-16 14:09:45 -05:00
4826cdb2c8
test: cover propagation/rain_scatter
24 characterization tests for bistatic radar geometry: find_scatter_cells
filtering (dBZ threshold, distance floor/ceiling), bearing math, frequency
scaling, and classify/1 buckets.
2026-04-16 14:09:44 -05:00
cb2937f12c
test: cover geo, qrz record, callsign client
34 characterization tests for pure/wrapper modules. Documents existing
behavior — no production code changes.
2026-04-16 14:09:44 -05:00
FluxCD
b148e37c8e chore: update prop image to git.mcintire.me/graham/prop:main-1776361644-ac4e43c [skip ci] 2026-04-16 17:48:38 +00:00
ac4e43c726
Remove 500-limit on BackfillEnqueueWorker cron
10K+ HRRR-pending contacts were starving the 200-ish NARR candidates
out of the 500 per-run slot each 30 min because NARR candidates sort
last by the :hrrr_status priority. Drop the cap so every eligible
contact gets enqueued on each cron run; queue concurrency still paces
the actual fetches. Worker keeps supporting an explicit "limit" arg
for ad-hoc runs and tests.
2026-04-16 12:46:56 -05:00
FluxCD
2871f63cca chore: update prop image to git.mcintire.me/graham/prop:main-1776361263-f93f5c9 [skip ci] 2026-04-16 17:42:38 +00:00
f93f5c9a57
Rename 9cm band 3456 → 3400
The US 9cm amateur allocation was cut from 3300-3500 to 3300-3450 MHz
in 2020, so 3456 (the legacy weak-signal calling frequency) is no
longer in-band. Move the canonical key to 3400, migrate existing
contacts, and keep 3456 input resolving to 3400 via the
nearest-band snap so historical ADIF/CSV logs still import cleanly.
2026-04-16 12:40:38 -05:00
FluxCD
bc7f3f4137 chore: update prop image to git.mcintire.me/graham/prop:main-1776360998-36adf87 [skip ci] 2026-04-16 17:37:37 +00:00
36adf875b7
Add 50/144/222 MHz bands, rename 440→432
Expands submittable-contact bands to include 6m, 2m, 1.25m, and 70cm
(as 432 rather than the old 440 placeholder). Each new band gets an
explicit allocation window in BandResolver.nearest_band so ADIF FREQ
fields near the amateur allocations resolve correctly while 60-900 MHz
frequencies outside those windows are still rejected. Microwave
(>= 900 MHz) snapping is unchanged — nearest-band match across the
full @allowed_bands list.

Also adds BandConfig entries for 50 and 222 (tropo-only config, same
pattern as 144/432). Sporadic-E / F2 / meteor scatter modeling is not
yet in scope — ionosphere data is only used to compute an Es readout
on /path for 50/144/222/432.
2026-04-16 12:36:10 -05:00
FluxCD
90df646c94 chore: update prop image to git.mcintire.me/graham/prop:main-1776360352-11a50bf [skip ci] 2026-04-16 17:27:34 +00:00
11a50bf2c2
Disable PDF export on live_table pages
PDF generation was failing in production. CSV export still works.
2026-04-16 12:25:25 -05:00
FluxCD
5e5663fbb1 chore: update prop image to git.mcintire.me/graham/prop:main-1776360188-01d554f [skip ci] 2026-04-16 17:24:33 +00:00
01d554f2e7
Hourly ContactPositionBackfillWorker fills null pos1/pos2
Safety net for rows that land via direct DB writes (manual fixes, bulk
imports) and bypass Radio.create_contact's grid-resolution requirement.
Runs every hour on the :admin queue with a 500-row per-invocation cap.
Fills whichever side resolves — if one grid is invalid, the other still
gets populated; distance_km is only written when both positions end up
set.
2026-04-16 12:22:34 -05:00
FluxCD
1a8e55dfb0 chore: update prop image to git.mcintire.me/graham/prop:main-1776355088-bbeb95f [skip ci] 2026-04-16 15:59:59 +00:00
bbeb95ff5d
CSV/ADIF import: upsert existing contacts on grid/mode refinement
Widen dedup match to 4-char grid prefix so an upload with a longer grid
(e.g. FN42aa25) finds the existing FN42 contact. Within a match, classify
as a refinement when the upload strictly extends the existing grid or
fills a missing mode; as a contradiction (still skipped) when grids or
modes genuinely disagree. Refinements flow through a new preview bucket,
update the existing row in place, and re-enqueue enrichment when the
position changed.
2026-04-16 10:57:37 -05:00
FluxCD
824143ad64 chore: update prop image to git.mcintire.me/graham/prop:main-1776351221-be6f1a1 [skip ci] 2026-04-16 14:55:32 +00:00
be6f1a1d28
CsvImport: header-driven column mapping
The /contacts LiveTable export produces a 12-column CSV with label-style
headers (Station 1 / Grid 1 / QSO (UTC) / ...) in a different column
order plus extras (id, distance_km, hrrr_status, flagged_invalid,
inserted_at). The old positional import rejected it with "expected 6 or
7 columns, got 12".

Replace the positional parser with header-driven mapping:
- Parse the header row, normalize each cell (downcase + trim), and look
  it up in an alias map that covers both the raw field names and the
  export labels.
- Required columns: station1, station2, grid1, grid2, band, qso_timestamp
  (mode stays optional). If any are missing the header, return
  {:error, {:missing_required_columns, [...]}}.
- Unknown columns are silently ignored so the export's extras don't break
  round-trip.
- Per-row validation stays the same (changeset errors still reported by
  row number).

Existing 6- or 7-column positional CSVs that already have a header still
work — the header parser recognizes `station1`, `station2`, etc.
directly.
2026-04-16 09:53:14 -05:00
c7f9aac3f9
NarrClient: parse cdo numeric codes for cdo-version independence
Prod cdo 2.5.1 (Debian) emits named shortnames (tpag10, 2tag2, saip, code11…)
where cdo 2.6.0 (Mac) emits var11/var33/etc. The parser only recognized
varNN, so every prod NARR fetch failed with "no parseable values in cdo
output" and all 350 in-flight jobs discarded.

Switch cdo args from `-outputtab,name,lev,value` to `-outputtab,code,...`.
cdo then outputs the raw numeric NCEP GRIB1 parameter code regardless of
version, and @cdo_code_to_name maps that integer to the semantic name we
already use downstream (TMP/HGT/SPFH/…).

Verified by reproducing on both a 2010 NARR file (fixture) and a 2007 file
fetched live — both now emit identical numeric code rows.
2026-04-16 09:45:23 -05:00
FluxCD
c1cac4f394 chore: update prop image to git.mcintire.me/graham/prop:main-1776350332-f005ddc [skip ci] 2026-04-16 14:40:29 +00:00
f005ddcaac
NarrClient: include cdo output snippet in parse-failure errors
Prod hit "no parseable values in cdo output" for a 2007 record.
Without the actual cdo output it's guesswork whether it's a different
parameter-table format, an empty result, or something else. Capture
stderr (stderr_to_stdout: true) and include first 500 chars of output
so the error message has enough signal to diagnose.
2026-04-16 09:38:27 -05:00
FluxCD
a2cdee7a31 chore: update prop image to git.mcintire.me/graham/prop:main-1776349903-55d4f28 [skip ci] 2026-04-16 14:33:27 +00:00
55d4f289c2
NarrClient.in_coverage?/1 + callsite guards (1979 → 2014-10-02)
Post-2014 contacts with hrrr_status=:unavailable were being dispatched to
NARR, which 404s because NCEI's archive ends 2014-10-02. 14K of the 14.3K
candidates in prod are post-2014 — these are HRRR's responsibility.

- NarrClient.in_coverage?/1 returns true only inside 1979-01-01 → 2014-10-02
- narr_jobs_for_contact and maybe_enqueue_narr (contact_live) bail out of
  coverage returning []
- BackfillEnqueueWorker.type_filter scopes :narr to qso_timestamp <
  coverage_end so the cron doesn't keep picking post-2014 candidates
2026-04-16 09:31:19 -05:00
FluxCD
f5132a991d chore: update prop image to git.mcintire.me/graham/prop:main-1776349371-3604896 [skip ci] 2026-04-16 14:24:23 +00:00
3604896726
Rename ERA5 → NARR across the codebase
- Schema: Era5Profile → NarrProfile; table renamed via migration
  rename_era5_profiles_to_narr_profiles (ALTER TABLE + rename indexes,
  atomic and instant, no row rewrite)
- Weather helpers: find_nearest_era5/era5_for_contact/era5_profiles_for_path
  → find_nearest_narr/narr_for_contact/narr_profiles_for_path
- BackfillEnqueueWorker: :era5 → :narr type (virtual, no narr_status column);
  reconcile_stale_queued_to_unavailable and status_priority_order now skip
  virtual types via @virtual_types. Fixes the prod crash where the cron tried
  to update a non-existent era5_status column.
- ContactWeatherEnqueueWorker: build_era5_jobs → build_narr_jobs,
  era5_jobs_for_contact → narr_jobs_for_contact
- ContactLive: @era5 → @narr, maybe_enqueue_era5 → maybe_enqueue_narr;
  UI label "ERA5 (0.25°)" → "NARR (32 km)"
- Cron (runtime.exs): args types list now "narr" instead of "era5"
- /status: progress row, status-by-type card, totals.narr_profiles, and
  table-count lookup all target narr_profiles
- Drop obsolete Era5CdsJob schema + era5_cds_jobs table (inspection
  artifact from the retired CDS pipeline; 34 orphan rows in prod)
- Misc docstring/comment cleanups (skew_t, about, wgrib2, propagation_train)

Includes a regression test for the virtual-type crash.
2026-04-16 09:22:23 -05:00
FluxCD
9e94759f7a chore: update prop image to git.mcintire.me/graham/prop:main-1776347847-7857d3b [skip ci] 2026-04-16 13:59:17 +00:00
7857d3bc5a
Status page: relabel ERA5 → NARR, include NARR in backfill cron
- /status progress bar, status-by-type card, and internal map keys now
  read "NARR" (data-progress-key="narr", narr_candidates/narr_done).
  era5_profiles table stays as the DB landing spot (historical artifact).
- BackfillEnqueueWorker cron now passes "era5" in types so pre-2014
  contacts whose hrrr_status is :unavailable actually get NarrFetchWorker
  jobs enqueued every 30 min. The :era5 type key still maps internally
  to NarrFetchWorker via era5_jobs_for_contact/1.
2026-04-16 08:57:01 -05:00
FluxCD
17271b264a chore: update prop image to git.mcintire.me/graham/prop:main-1776347392-1d740ca [skip ci] 2026-04-16 13:50:15 +00:00
1d740caf8f
Fix credo warnings: length/1 on list and nested module aliases
- skew_t_test: replace length(list) > 0 with list != []
- conn_case: alias DataCase, GridCache, ScoreCache at top
2026-04-16 08:49:25 -05:00
bcce4b4de9
Add :exports Oban queue for LiveTable CSV/PDF workers
LiveTable.Workers.CsvExportWorker and PdfExportWorker enqueue on :exports.
Prod had 2 pending jobs stuck in `available` with no queue to execute them.
2026-04-16 08:48:36 -05:00
FluxCD
437eeb1c47 chore: update prop image to git.mcintire.me/graham/prop:main-1776346020-1f2a729 [skip ci] 2026-04-16 13:31:10 +00:00
1f2a729d40
Drop dead Era5*/CDS code paths
After the NARR backfill landed, nothing reachable from the app calls
any of these modules. Net removal: ~2700 lines.

Deleted:
- Microwaveprop.Workers.{Era5Fetch,Era5Submit,Era5Poll,Era5MonthBatch}Worker
- Microwaveprop.Weather.{Era5BatchClient,Era5Client}
- Mix.Tasks.Era5Backfill (superseded by BackfillEnqueueWorker cron)
- Matching test files (~1100 LOC of test)
- era5/era5_submit/era5_poll/era5_batch queue blocks from runtime.exs
- era5_req_options Req.Test stub config from test.exs

Kept (actively used or intentionally preserved):
- Era5Profile schema (the era5_profiles table is the NARR target)
- Era5CdsJob schema + its test (inspection handle on the era5_cds_jobs
  table, which retains rows from the failed CDS runs)
- :era5 type key in ContactWeatherEnqueueWorker / BackfillEnqueueWorker
  (the symbol still means "historical backfill", just routed to NARR now)

Swapped the one remaining live Era5FetchWorker call site in
contact_live/show.ex's maybe_enqueue_era5 path to NarrFetchWorker
(with NarrClient.snap_to_analysis_hour for the 3-hourly slot).

Test suite: 1513 tests, 0 failures (-50 from the deleted era5 test
files). No compile warnings. Credo clean.
2026-04-16 08:26:29 -05:00
FluxCD
d70444dd06 chore: update prop image to git.mcintire.me/graham/prop:main-1776297271-6169b31 [skip ci] 2026-04-15 23:58:22 +00:00
6169b31726
Mark Era5* queues as DEPRECATED (replaced by NARR) 2026-04-15 18:54:02 -05:00
4aa23ae67f
ContactWeatherEnqueueWorker: dispatch NarrFetchWorker for historical backfill
Swap the body of era5_jobs_for_contact/1 to enqueue NarrFetchWorker
instead of Era5FetchWorker, and snap qso_timestamp via
NarrClient.snap_to_analysis_hour/1 so it lands on a 3-hourly NARR
analysis slot (00/03/06/09/12/15/18/21 UTC) — NarrFetchWorker raises
on anything else.

The :era5 type key, the era5_jobs_for_contact function name, and
the era5_status field are kept as historical artifacts — they
still refer to the era5_profiles table, which is reused 1:1 by
NARR. A schema rename is a follow-up PR.

The CDS Era5* workers (Era5FetchWorker, Era5SubmitWorker,
Era5PollWorker, Era5MonthBatchWorker, Era5BatchClient) are now
unreachable from the live submission flow. They stay in tree until
the deletion follow-up PR.
2026-04-15 18:54:02 -05:00
e44d7043e5
Add :narr Oban queue to dev and runtime configs
Concurrency 6 in both dev and prod. NARR fetches are network-bound
(byte-range GETs to NCEI) so high concurrency is fine — same shape
as the iemre queue in dev. Required for NarrFetchWorker (added in
the previous commit) to actually pick up jobs.
2026-04-15 18:54:02 -05:00
7c33af7278
Add Microwaveprop.Workers.NarrFetchWorker
Per-contact Oban worker that fetches one NARR profile via
NarrClient.fetch_profile_at/2 and inserts it into era5_profiles.

Replaces Era5FetchWorker for pre-2014 contacts. Unlike the ERA5
router, NARR fetches are per-point so this worker does the fetch
+ insert directly — no downstream batch worker, no routing.

Args shape matches Era5FetchWorker: %{"lat", "lon", "valid_time"}.
Lat/lon get snapped to 0.25° (same dedup granularity as ERA5).
valid_time MUST already be on a 3-hourly NARR analysis slot
(00/03/06/09/12/15/18/21 UTC on the hour) — the worker raises
ArgumentError via NarrClient.url_for/1 otherwise. Caller-side
snapping (in ContactWeatherEnqueueWorker) lands in the next task.

Existence check tightened to ±15 minutes (vs ERA5's ±30) since NARR
is exact 3-hourly. On error, logs a warning and returns {:error,
reason} so Oban retries with backoff (max_attempts: 3).

The :narr Oban queue config and the ContactWeatherEnqueueWorker
swap are deliberately NOT touched in this commit — those are the
next two tasks of the plan.
2026-04-15 18:54:02 -05:00
e5528d0135
NarrClient.extract_profile_from_file/3 + fetch_profile_at/2
extract_profile_from_file shells out to cdo:

    cdo -outputtab,name,lev,value -remapnn,lon=X_lat=Y <merged.grb>

parses the text output (varNNN ↔ NCEP GRIB1 param number lookup),
maps surface records to era5_profiles fields, builds the pressure
profile from each (TMP, HGT, SPFH) triple, and merges in the derived
fields from SoundingParams.derive/1. Returns the same attrs shape as
Era5BatchClient.build_profile_attrs/5 so the existing
bulk_insert_profiles path can ingest NARR rows unchanged.

fetch_profile_at picks records from a fetched inventory, byte-range
fetches each one into a per-record temp file, runs cdo -merge to build
a composite, calls extract_profile_from_file, and cleans up via
try/after.

Drive-by fix: ThetaE.dewpoint_from_spfh/2 returns Kelvin (its existing
test asserts that explicitly). Era5BatchClient was passing that
Kelvin value straight into "dwpc" (Celsius), which would then crash
SoundingParams.compute_refractivity_profile when it called Buck's
saturation-vapor-pressure equation with t=294 instead of t=21. The
bug never surfaced because era5_profiles is empty in production —
no ERA5 backfill has ever succeeded. Fixed in both era5_batch_client
and the new narr_client by subtracting 273.15 in the wrapper. Both
wrappers now have a comment pointing at the K→C conversion.

Test fixture test/fixtures/narr/narr_dfw_2010-06-15_12z.grb is the
spike-captured composite (1.1 MB — Lambert conformal projection
isn't sellonlatbox-subsettable so we can't shrink it further).
2026-04-15 18:54:02 -05:00
081cd47bb5
NarrClient.parse_inventory/2 + fetch_inventory/1
parse_inventory walks the wgrib1-format .inv text and returns a
{var, level} -> {byte_offset, length} index, computing each record's
length from the next record's offset (or file_size for the last one).
fetch_inventory does a GET on the .inv URL plus a HEAD on the .grb URL
to learn its size, then delegates to parse_inventory.

Both stubbable via Req.Test (config/test.exs adds narr_req_options
matching the existing era5_req_options / giro_req_options pattern).
Spike fixture at test/fixtures/narr/narr-a_221_20100615_1200_000.inv
backs the parser test — verifies the ("TMP", "2 m above gnd") and
("WVVFLX", "atmos col") records line up with the real bytes.
2026-04-15 18:54:02 -05:00
b7e19e18cc
Add Microwaveprop.Weather.NarrClient URL/time helpers
First slice of the NARR backfill client: url_for/1,
url_for_inventory/1, and snap_to_analysis_hour/1. Pure functions,
no network. Validates that NARR analyses only exist at 3-hourly
slots (00/03/06/09/12/15/18/21 UTC). The byte-range fetch and
cdo-driven point extraction land in follow-up commits per
docs/plans/2026-04-15-merra2-historical-backfill.md.
2026-04-15 18:54:02 -05:00
1faeb60e3c
Add cdo to runtime image for NARR GRIB1 decoding
NARR is GRIB1 and wgrib2 doesn't decode GRIB1 messages
(rd_grib2_seq_msg, grib1 message ignored, use wgrib). cdo handles
GRIB1 + the Lambert conformal projection + nearest-neighbor remap
that NARR's Grid 221 needs. Pulls in libnetcdf/libhdf5/libudunits2
transitively, ~30-60 MB image growth.
2026-04-15 18:54:02 -05:00
FluxCD
8320becee7 chore: update prop image to git.mcintire.me/graham/prop:main-1776291073-13180d5 [skip ci] 2026-04-15 22:12:52 +00: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
FluxCD
7c5cbcfdcd chore: update prop image to git.mcintire.me/graham/prop:main-1776290697-26015ce [skip ci] 2026-04-15 22:05:50 +00:00
26015ce8ea
ProfilesFile.read/1: drop [:safe] flag, trust our own files
The persisted grid_data files contain atoms (:base_m, :top_m,
:thickness_m, :min_freq_ghz, :native_min_gradient, :ducts, etc.) that
live in modules which aren't eagerly loaded during application start.
warm_grid_cache_from_latest_profile runs in Application.start/2 before
those modules get pulled in by their first call sites, so [:safe] was
rejecting our own legitimate files at boot — every weather page hit
crashed with "invalid or unsafe external representation of a term".

The whole point of [:safe] is to refuse atoms from untrusted external
ETF input. ProfilesFile.write!/2 in this same module is the only writer
of these files, so the input is trusted by definition — drop the flag.
The test additions round-trip the production data shape including duct
fields as a regression guard.
2026-04-15 17:04:34 -05:00
FluxCD
04c8a92db8 chore: update prop image to git.mcintire.me/graham/prop:main-1776287079-9a78f8c [skip ci] 2026-04-15 21:06:23 +00:00
9a78f8c70c
ERA5: tighten stuck threshold to 8h and pause prod queues for diagnosis
Prod has 0 era5_profiles rows after ~500 submit cycles: every CDS job
either gets marked "rejected" (likely cap mismatch) or runs for 13+h
without transitioning to successful, so the handle_both_done branch
has literally never fired and the ERA5Batch: stored log line has
never been emitted once. Tighten Era5PollWorker's @stuck_after_seconds
from 18h to 8h so dead rows recycle within a work shift instead of a
full day, and pause the era5 / era5_submit / era5_poll / era5_batch
queues in runtime.exs so existing rows stay put for inspection while
we figure out why CDS is rejecting everything. Flip paused: true back
once the root cause is identified.
2026-04-15 16:04:12 -05:00