Commit graph

553 commits

Author SHA1 Message Date
6c652ef2d4
feat(contacts): private flag with scope-aware visibility
Contacts can be marked private at submit time (single form, CSV
import, ADIF import) and edit time. Private contacts are hidden
from the public map, the public /u/callsign profile, and the
browse table for anonymous and non-owning viewers. The original
submitter and admins see them inline on the browse table with a
"Yes" badge, and the detail page shows a lock icon. Non-authorized
viewers get a 404 on the detail page.
2026-04-18 17:49:01 -05:00
4e6c87eca2
feat(telemetry): broaden Instrument span coverage
Adds spans to 15 previously-unmeasured hot paths so every question we
might ask while tuning has a histogram to answer it:

External I/O:
- iem.fetch_iemre (gridded weather reanalysis)
- mrms.list_latest / mrms.download (precip radar)
- rtma.fetch_observation
- ncei.fetch_metar (historical 5-min METAR backfill)
- solar.fetch_indices (GFZ solar indices)
- swpc.fetch (SWPC Kp/F10.7/X-ray)
- giro.fetch (ionosonde)
- qrz.request, geocoder.geocode (callsign enrichment)
- srtm.download_tile (terrain tile download + gunzip)
- hrrr.download_grib_ranges (parallel byte-range fetch phase)

Subprocess:
- wgrib2.extract_grid / extract_grid_from_file / extract_grid_from_file_mapped

LiveView hot paths:
- propagation.scores_at (map score fetch + cache hit/miss counter)
- propagation.point_forecast (sparkline)
- propagation.point_detail (click-to-inspect)
- propagation.daily_outlook_at (/map outlook strip)

Worker-level end-to-end:
- worker.terrain_profile
- worker.mechanism_classify
- worker.mrms_fetch

Each event is registered in Microwaveprop.PromEx.InstrumentPlugin as
a Prometheus histogram (default / long buckets as appropriate) plus
a counter for the scores_at cache hit/miss ratio. Prometheus at
10.0.15.25 will start seeing the new series on the next scrape after
deploy.
2026-04-18 17:25:33 -05:00
9af50a9e34
fix(metrics): serve scrape body via PromEx.get_metrics
PromEx.Plug's internal path check doesn't match when forwarded under
/metrics (conn.path_info is [] after the forward strips the prefix),
so it silently returned without sending a response. Call
PromEx.get_metrics/1 directly and send the text body ourselves —
tested, returns 200 with all 23 registered event handlers producing
proper Prometheus histogram output.
2026-04-18 16:50:37 -05:00
c7bc3ed5d0
feat(telemetry): PromEx Prometheus exporter at /metrics
Wires PromEx with its built-in Application / BEAM / Phoenix / Ecto /
Oban plugins plus a custom `Microwaveprop.PromEx.InstrumentPlugin`
that registers Prometheus histograms for every
`Microwaveprop.Instrument` span (HRRR/NEXRAD/IEM/GEFS/NARR/UWYO
/Elevation fetches, DB batch upserts, terrain analysis, radar
aggregation, propagation score band). Queue-depth gauges from the
10-second poller land at `microwaveprop_oban_queue_count{queue,state}`.

Router forwards `/metrics` to MicrowavepropWeb.MetricsPlug which
optionally requires a bearer token (`PROMETHEUS_AUTH_TOKEN` env var in
runtime.exs); leave it unset to expose metrics publicly and restrict
at the ingress / Cloudflare Access layer instead.

Example external Prometheus scrape config:

    scrape_configs:
      - job_name: microwaveprop
        scrape_interval: 30s
        metrics_path: /metrics
        scheme: https
        authorization:
          type: Bearer
          credentials: <token>
        static_configs:
          - targets: ['prop.w5isp.com:443']
2026-04-18 16:39:39 -05:00
2da74c5cd8
feat(telemetry): wide instrumentation + bump hrrr to 2 per pod
Config:
- runtime.exs hrrr queue 1 → 2 (6 concurrent HRRR jobs across 3 pods)

New helper Microwaveprop.Instrument.span/3 wraps :telemetry.span with
a [:microwaveprop | event_suffix] prefix so metrics can key off it.

Spans added (each emits duration + result tag):
- HrrrClient.fetch_grid / fetch_profile / fetch_idx
- NexradClient.fetch_frame + decode_png
- IemClient.fetch_asos / fetch_raob
- GefsClient.fetch_grid_profiles
- NarrClient.fetch_profile_at
- UwyoSoundingClient.fetch_sounding
- ElevationClient.fetch_elevation_profile
- Weather.upsert_hrrr_profiles_batch / upsert_gefs_profiles_batch
- Propagation.replace_scores
- PropagationGridWorker.compute_scores_algorithm
- TerrainAnalysis.analyse
- CommonVolumeRadarWorker.aggregate_stats

Telemetry catalog (MicrowavepropWeb.Telemetry):
- Oban job duration / queue_time / count / exception by (worker, queue, state)
- Per-span summary metrics for every instrumented phase above
- Periodic (10s) poller emits oban queue depth by (queue, state) — drops
  into the /admin/dashboard Metrics tab immediately

Also drops the now-redundant "fetching n0q frame" and "fetching <url>"
info lines from CommonVolumeRadarWorker / NexradClient; the span events
cover that and the worker's "ingested" line stays for per-job signal.
2026-04-18 16:33:34 -05:00
1a62e51f82
chore(radar): drop per-fetch ok-bytes log line 2026-04-18 16:22:04 -05:00
ced5e3d1a6
feat(radar): log NEXRAD fetches + worker progress
Previously the CommonVolumeRadarWorker ran silently — no URL logged,
no sign in the logs that it was doing any work. Added info-level
fetch/ok/error logs in NexradClient (same shape as WeatherFetchWorker
RAOB logs) plus start-of-work + ingestion lines in the worker itself.
Unchanged: the existing "no frame" warning and all control flow.
2026-04-18 16:20:59 -05:00
af6c676df5
chore(branding): rename site to "Microwave Propagation"
Nav bar and browser tab now show "Microwave Propagation" instead of
"NTMS Propagation Prediction". The About and Privacy pages still
reference NTMS since those describe the organization running the
service, which is unchanged.
2026-04-18 16:18:21 -05:00
be62e272e9
feat(contacts): widen sounding search, backfill on demand
Contact detail now searches for soundings in progressively wider radii
(150 → 300 → 600 → 1000 km) before declaring "No soundings found".

When even 1000 km turns up nothing, enqueue WeatherFetchWorker RAOB
jobs for every station at the widest non-empty radius. The worker
passes the contact_id through so it can PubSub back to
contact_enrichment:<id> as each sounding lands, and the LiveView
rehydrates without a refresh.

Also adds Weather.soundings_with_widening_radius/1 and
ContactWeatherEnqueueWorker.enqueue_raob_fetch_with_widening/1 so the
same logic can be reused from other callers.
2026-04-18 16:04:50 -05:00
a9a355be2a
feat(format): miles-primary distance formatting sitewide
Add Microwaveprop.Format.distance_km/1 and a matching formatDistanceKm
helper for the JS side. Both emit "X mi (Y km)" with one decimal under
10 mi and whole numbers above.

Replace the ad-hoc format_dist/format_km_mi helpers with the shared
formatter and update every user-facing distance render: contact
detail, contacts index column, user profile contact/involving tables,
path finder summary + sounding list, contacts map line popups, beacon
coverage tooltip, and propagation map range estimate + rain scatter
cell popup.
2026-04-18 15:53:40 -05:00
ca07c0288d
feat(contacts): antenna height fields feed terrain analysis
Add optional height1_ft/height2_ft to contact schema, submit form, and
edit form (direct + suggest-edit paths). Heights flow through to the
elevation-profile terrain analysis so clearance and diffraction are
computed from the actual antenna height instead of a 10-ft default.
Editing heights resets terrain_status and re-enqueues
TerrainProfileWorker so the stored path analysis picks up the new
geometry.

Also fix mark_likely_duct/3: the pattern was grabbing the element
instead of the index, so no duct ever got flagged as the likely
propagation path even when extract_ducts returned a valid layer.
2026-04-18 15:48:14 -05:00
a6af6b115a
feat(map): 7-day outlook strip
Adds a compact seven-card horizontal strip to both mobile and desktop
map sidebars showing the per-day peak score at the viewport center
for the selected band. Cards are color-coded (emerald for 80+,
rose for bad) so the weekend verdict reads at a glance.

- Propagation.daily_outlook_at/3: groups point_forecast entries by
  UTC date, picks each day's peak, returns ascending order
- MapLive mounts with today's outlook for the initial center; the
  select_band handler refreshes it from the viewport midpoint so the
  strip tracks the user's current band and rough location
- Empty-state message covers the case where no extended-horizon
  scores have landed yet (fresh deploy, or before the first GEFS
  run completes)

This is the consumer of the GEFS pipeline — once the cron starts
running and f024-f168 scores accumulate, days 2-7 of the strip
populate automatically.
2026-04-18 14:46:44 -05:00
4d0c15e3b8
feat(gefs): score extended-horizon grid and seed cron
Wires the GEFS fetch pipeline into the existing scoring machinery:

- GefsFetchWorker runs Propagation.score_grid_point over every
  fetched grid cell and persists the result via replace_scores/2, so
  Day 2-7 valid_times show up on the map through the same
  Propagation.scores_at/3 path HRRR uses
- Empty-args perform/1 seeds the chain by enqueueing f024-f168 (25
  jobs at 6-hour cadence) for the most recent GEFS run that should
  be published given NOMADS' ~3-4h publication lag
- Cron: seeds at 05:30, 11:30, 17:30, 23:30 UTC — 5 hours after each
  00/06/12/18Z run
- GefsClient.build_profile now emits :wind_u / :wind_v atom keys to
  match the scorer's input shape; DB columns keep the *_mps suffix
  for unit clarity, remapped at write time

GEFS pgrb2a lacks HPBL, native-level duct data, NEXRAD, and
commercial-link degradation — the extended-horizon score is a
rougher signal than the HRRR-driven one but covers the window HRRR
can't reach.
2026-04-18 14:41:41 -05:00
0537a1831b
feat(gefs): ingest worker and grid-profile extraction
- GefsClient.build_profile/1: converts raw wgrib2 output into the
  scorer-shaped profile, deriving Td from RH via Magnus at both the
  surface and each pressure level
- GefsClient.fetch_grid_profiles/3: downloads the idx sidecar from
  NOMADS, byte-ranges only the surface messages, hands the slim
  GRIB2 binary to wgrib2 -lola for bilinear regridding from 0.5°
  onto the CONUS 0.125° grid
- GefsFetchWorker: one Oban job per (run_time, forecast_hour),
  upserts into gefs_profiles and runs SoundingParams.derive so the
  same refractivity/ducting metrics flow through

Oban gets a new :gefs queue (2 slots dev/config, 1 slot per prod
pod). Test env gets a gefs_req_options Req.Test stub slot so future
integration tests can drive the full fetch path. No cron wiring or
scorer integration yet — that's Step 3.
2026-04-18 14:36:03 -05:00
a5c7dce147
feat(gefs): scaffold extended-horizon forecast ingestion
Lays the groundwork for a Day 2-7 propagation outlook driven by NCEP
GEFS ensemble-mean output, picking up where HRRR's 18-hour horizon
leaves off.

- GefsClient: NOMADS URL builder, forecast-hour list (3h to +240,
  6h to +384), ensemble-mean message inventory, and a Magnus
  dewpoint derivation (pgrb2a publishes RH rather than Td)
- gefs_profiles table + GefsProfile schema mirroring hrrr_profiles
  so the scorer can run over rows from either source
- Weather.upsert_gefs_profile/1 and upsert_gefs_profiles_batch/1

No worker or UI yet — those land in follow-up commits.
2026-04-18 14:28:44 -05:00
cf6812f154
fix(contact): stop loading-flash on already-enriched contacts
Two related fixes:

- Drop the global contact_enrichment:{hrrr,weather,solar} subscriptions.
  These topics fire for every QSO's enrichment completion, and the
  handlers blindly re-queried data for the currently-displayed contact,
  causing spurious re-renders whenever any enrichment worker finished
  anywhere in the system. The per-contact terrain subscription stays —
  that one is correctly scoped.

- Only add a slot to `hydration_pending` when the contact's status
  column says the work is genuinely in flight (:queued or :processing).
  Previously every connected mount flashed spinners over all eight
  enrichment sections, even for contacts whose data was already cached
  and about to return instantly from the async task.

Also drop the Donate-to-NTMS footer link.
2026-04-18 13:56:01 -05:00
e3248e335b
revert(map): remove antenna-height control
The antenna-height input only scaled the displayed range labels and
the viewshed max-range — it never fed into the propagation score
itself. Showing the control implied the colored overlay responded to
it, which was misleading. Viewshed now uses a fixed 10 m baseline,
matching the previous 33 ft default.
2026-04-18 13:50:56 -05:00
5488744f44
feat(contact): loading spinners, callsign-aware summaries, axis callsigns
Three improvements to the contact detail page:

* Track async hydration per source in `hydration_pending` so the
  Terrain / Soundings / Atmospheric Profile sections render a loading
  spinner while their task is in-flight instead of briefly flashing
  "no data available" before the payload arrives.
* Propagation analysis summary now names the origin station by
  callsign ("Path is terrain-obstructed at N mi from W5XD") instead
  of the generic "station 1" placeholder.
* Elevation profile chart labels the 0-mi and max-mi ticks with the
  matching callsign so it's obvious which end of the profile is
  which station.
2026-04-18 13:43:35 -05:00
7330d64c5a
feat(contacts): surface tropo duct count and suggest-edit hint
Propagation analysis panel now adds a "Tropo duct detected at N/M HRRR
samples along the path" line whenever any HRRR sample flags ducting,
even if the overall mechanism classification doesn't end up pointing at
the duct (e.g. short paths, clear-LoS contacts). Also rewords the map
caveat to invite users with better coordinates to file a suggested
edit — clickable when logged in, otherwise a login link.
2026-04-18 13:37:54 -05:00
e827cacc48
feat(map): scale propagation ranges with antenna height
Antenna height now drives typical/extended/exceptional range estimates
and viewshed max range via a sqrt(h_m/10) factor clamped to [0.6, 2.0].
Baseline 33 ft matches the existing defaults. Changing the height input
now pushes update_band_info and refreshes the clicked point's detail
panel plus viewshed so the range-estimate line and coverage shape track
the user's actual station height.
2026-04-18 13:32:24 -05:00
32ce43f04a
feat(map): show last-deployed stamp in map sidebar
Deploy stamp was in Layouts.app's nav but the map page uses a custom
full-screen layout that skips the standard nav, so the info wasn't
visible on the page most users land on. Add a compact
"Deployed 2h ago" under the sidebar's data-timestamp/pipeline-status
block with the full UTC time as a tooltip, matching the shape of
Layouts.deploy_stamp.
2026-04-18 12:53:37 -05:00
d3b0420457
feat(scoring): Richardson-gated native-duct boost
Added 5-arity Scorer.score_refractivity/5 that takes bulk_richardson
alongside best_duct_band_ghz. The existing 1.15× boost now only
applies when Richardson is in the stable regime (< 25) — native-
profile duct cells average 8.7-18.4 for ducting vs 38.3 for
non-ducting, so a duct-band reading under turbulent conditions is
more likely to be torn up by mechanical mixing than to carry a
beyond-LOS signal.

Wiring:
- Weather.nearest_native_duct_info/3 returns {ghz, richardson}; the
  bare nearest_native_duct_ghz/3 now delegates.
- PathLive and Propagation.score_with_algorithm/7 both pull the
  Richardson value into the conditions map alongside best_duct_band.
- Scorer.composite_score/2 reads conditions[:bulk_richardson] and
  passes it into score_refractivity/5.
- Backward compat: 4-arity variant and nil Richardson keep the old
  unconditional-boost behaviour so existing callers don't regress.
2026-04-18 12:40:57 -05:00
06feda7110
perf: finish audit follow-ups (path conditions, recalibrator parallelism)
Path calculator conditions map was missing :latitude and
:best_duct_band_ghz. Scorer read them with bracket-notation so the
behaviour was defensively correct but we were leaving signal on the
floor:

- latitude: midpoint of src/dst so `score_season` picks up the right
  latitude-based seasonal shift for northern vs southern paths
- best_duct_band_ghz: queried via new Weather.nearest_native_duct_ghz/3
  at the midpoint so score_refractivity/4 applies the 1.15× boost
  when a native-resolution duct supports the target band

Recalibrator.fit/1 was serialising ~10k per-contact HRRR lookups
(5000 positives + 5000 negatives, one Weather.find_nearest_hrrr each).
Parallelised both sides with Task.async_stream at 20 concurrency —
well under the prod db pool of 30. Cuts a band recalibration from
~minutes of query round-trips to seconds.
2026-04-18 12:23:18 -05:00
2874e6a93b
perf+fix: parallelize path HRRR queries; fix meteor-shower year boundary
Audit pass found two real issues (most agent-flagged items were false
positives — the hrrr_native_profiles composite index exists,
pwat_mm/bl_depth_m are already in the path conditions map, and the
/1.0 in MechanismClassifier is the standard Elixir int→float coerce).

1. Path calculator's 9-point HRRR sampling was running sequentially,
   ~9× the latency of a single query. Task.async_stream with
   max_concurrency: 9 makes them concurrent so path calc pays roughly
   one query's worth of time instead of nine.

2. MechanismClassifyWorker's meteor-shower window used hardcoded 2024
   peak dates with `%{date | year: peak.year}` projection. That broke
   across year boundaries — a Dec 30 contact couldn't match a Jan 4
   Quadrantids peak even in-range of the ±3 day window. Switched to
   {month, day} tuples and check the peak projected onto year N-1, N,
   N+1 so the window works correctly near Dec 31 / Jan 1.
2026-04-18 12:08:42 -05:00
5dea91d43f
feat(beacons): plot-path link seeds TX chain to match beacon EIRP
The "Plot path to beacon" action now includes `tx_power_dbm` and
`src_gain_dbi` in the /path query string so the path calculator's TX
side emits the same EIRP as the beacon out of the box. The beacon
schema stores `power_mw` as EIRP already (per Beacons.RangeEstimate),
so we take 10·log10(power_mw), split against a default 30 dBi dish,
and clamp the TX power to a 0 dBm floor so very-low-EIRP beacons
produce a sane gain/power split instead of -10 dBm outputs.

Examples:
  10 W EIRP (40 dBm)  → 30 dBi dish + 10 dBm TX
  100 W EIRP (50 dBm) → 30 dBi dish + 20 dBm TX
  100 mW EIRP (20 dBm) → 20 dBi + 0 dBm (clamped)
2026-04-18 10:54:38 -05:00
fbec454917
feat(path): 9-point HRRR sampling + nearest sounding + km/mi display
Path calculator now samples nine HRRR grid cells evenly along the
source→destination line (was three: Source/Midpoint/Destination) so
mid-path ducts that endpoint-only queries missed show up in the duct
count. The UI reports "Duct at N / 9 HRRR points" instead of a binary
flag, and the table lists every sample with its percent-of-path label.

Independent RAOB signal: uses the new `Weather.nearest_sounding_to/3`
to find the closest sounding within 300 km / ±3 h of the midpoint. If
the sounding's `ducting_detected` is true we surface a "RAOB <code>
confirms duct" badge — HRRR under-reads thin surface ducts that
soundings resolve, so the two signals together are the right
cross-check for live duct prediction.

Distance formatting: all km values on the path results (top Distance
summary, sounding "km from midpoint") now render as
`123 km (76 mi)`; under 10 km we keep one decimal so near-LOS paths
don't round to zero.
2026-04-18 10:50:24 -05:00
18d16b0ad6
feat(weather): nearest_sounding_to helper for path calculator
Spatial + temporal nearest-RAOB lookup (defaults to 300 km / ±3 h)
joining weather_stations → soundings. Returns the full sounding row
so callers can read the derived ducting_detected / min_refractivity_
gradient fields that HRRR's pressure-level product systematically
under-reads for thin surface ducts.

Prep for the path-calculator enrichment: a sounding within range at
the path midpoint gives an independent duct signal to cross-check
the nine HRRR samples already taken along the path.
2026-04-18 10:46:35 -05:00
622edee180
feat(propagation): per-contact mechanism classification
Classifies every contact's likely non-LOS propagation mechanism and
persists the result on contacts.propagation_mechanism. Mechanism is
determined in priority order:

  1. user_declared_prop_mode (ADIF PROP_MODE from the operator log)
  2. EME — moon-ephemeris check, ≥2m band, >1800 km path
  3. aurora — Kp≥5 + high-lat path, 50-432 MHz
  4. sporadic-E — foEs × 5 ≥ band_mhz, 400-2500 km path
  5. meteor_scatter — ±3 days of a shower peak, VHF/UHF
  6. rain_scatter — common-volume radar heavy rain, 5-11 GHz ≤800 km
  7. tropo_duct — HRRR native_best_duct ≥ band or ducting_detected
  8. line_of_sight — ≤50 km path
  9. troposcatter — default

Persisted via MechanismClassifyWorker (queue: :mechanism, unique on
contact_id). Submit-time enqueue path includes :mechanism by default;
BackfillEnqueueWorker cron now handles :mechanism alongside existing
types so prod continuously backfills any contact with
propagation_mechanism_status in (:pending, :queued, :failed). Also
added :radar to the cron's type list so common-volume radar backfill
runs automatically rather than only via `mix radar_backfill`.

New modules:
- Microwaveprop.Propagation.MoonEphemeris — Meeus low-precision moon
  position, accuracy ±1° — enough for the mutual-visibility EME test
- Microwaveprop.Propagation.MechanismClassifier — plug-in priority
  chain over the evidence map
- Microwaveprop.Workers.MechanismClassifyWorker — assembles inputs
  from HRRR / native profiles / common-volume radar / solar_indices /
  ionosonde + calls the classifier

ADIF importer now reads PROP_MODE into user_declared_prop_mode so
operator-tagged mechanisms (EME/ES/MS/RS/AS/AUR) become ground truth.
2026-04-18 10:42:08 -05:00
f9af347485
chore(logs): skip HEAD requests in endpoint telemetry log 2026-04-18 10:22:48 -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
fc91f204fd
feat(propagation): record per-forecast-hour chain step timings
Add a propagation_run_timings table so the wall-clock duration of each
(run_time, forecast_hour) step is queryable long after the run is over.
Keyed by (run_time, forecast_hour) with a status column that captures
whether the step succeeded or bailed out, and an error string on
failure.

PropagationGridWorker stamps every step (ok and failed) via
Propagation.record_run_timing/1. Timing inserts are wrapped in rescue +
changeset-error handling so the instrumentation can never brick the
chain.
2026-04-18 09:36:52 -05:00
00aad3cb98
feat(auth): self-service password reset
Add a "Forgot your password?" flow off the login page. A 24-hour
reset_password token is emailed on request, the landing page lets the
user pick a new password, and all other tokens for the user are
revoked on success.

The request endpoint returns the same flash regardless of whether the
email matches a user so that attackers can't enumerate accounts.
2026-04-18 08:42:35 -05:00
b8b78f7e57
feat(design): apply NTMS design system tokens
Adopt the handoff design system: navy-slate surfaces with indigo-violet
accent (#6366F1 dark / #5458E3 light), Inter as the UI font, hairline
1px borders, softer corner radii. Add propagation-scale and amateur-band
CSS vars so JS hooks can pull theme-aware colors via getComputedStyle.

Polish: drop table zebra striping for hover-on-base-200 rows with
uppercase small-caps headers, bump .header to text-2xl, tighten nav
spacing with a hairline divider, fix broken text-brand -> text-primary
on the auth screens.

Wire --band-* into contacts_map_hook: resolve once at mount, re-resolve
on data-theme mutation so polylines recolor when the theme toggles.
2026-04-17 17:34:15 -05:00
33f5d4edbe
feat(rainscatter): classify QSO propagation mechanism from common-volume radar
Adds a per-contact enrichment pipeline that determines whether a QSO was
most likely carried by rain scatter, tropospheric ducting, or ordinary
troposcatter — using IEM n0q composite reflectivity sampled inside the
lens-shaped intersection of 400 km-radius disks around each endpoint.

Pieces:
  * Microwaveprop.Propagation.CommonVolume — lens geometry (haversine,
    in-CV test, bbox, area).
  * contact_common_volume_radar table (1:1 per contact) storing
    aggregate dBZ stats inside the CV + radar_status column on contacts.
  * Microwaveprop.Workers.CommonVolumeRadarWorker — Oban :radar queue,
    fetches the n0q frame at QSO time, iterates pixels inside the CV
    bbox, aggregates rain/heavy/core-pixel counts, max/mean dBZ, and
    coverage percentage.
  * Microwaveprop.Propagation.RainScatterClassifier — rule-based mapper
    from (band, distance, radar stats, duct flags) to one of
    :likely_rainscatter | :rainscatter_possible | :tropo_duct |
    :troposcatter | :unknown.
  * ContactWeatherEnqueueWorker learns a :radar enrichment type and
    enqueues the CV worker on contact submission; pre-2014 contacts
    (outside IEM n0q coverage) are pinned to :unavailable.
  * `mix radar_backfill` bulk-enqueues historical contacts with
    --year / --limit / --dry-run.
  * Contact detail page renders a mechanism badge with supporting
    stats (common-volume area, max dBZ, heavy-rain pixel count,
    coverage %).
2026-04-17 15:57:59 -05:00
c09446a517
fix(weather-map): default timeline cursor to the hour closest to now
Previously the map loaded the latest cached valid_time from GridCache,
which can be any +Nh forecast hour the worker has written while walking
f00-f18. Users expect to see current conditions on load, not a forecast
that's ~11 hours out.
2026-04-17 15:57:59 -05:00
b6caffea3e
feat(nav): show last-deployed timestamp
Docker final stage bakes BUILD_TIMESTAMP into /app/BUILD_TIMESTAMP via
build-arg (only consumed after mix compile, so earlier layer caching is
preserved). Application.build_timestamp/0 reads the file, falls back to
DEPLOY_TIMESTAMP env, then to compile time. Navbar renders a compact
"Deployed Xm ago" with the full UTC time in the tooltip.
2026-04-17 13:08:51 -05:00
48cbf40b9d
feat(contacts): show total contact count in list header 2026-04-17 13:08:51 -05:00
fc9d0dc977
feat(agent-discovery): Link/markdown/content-signal/api-catalog/skills
Implements the subset of the isitagentready.com checklist that maps to
real capabilities of this site:

- RFC 8288 Link headers on every browser response advertising
  service-doc (/algo), about, privacy-policy, and sitemap.
- Markdown-for-Agents content negotiation: requests with
  Accept: text/markdown for / or /algo return real markdown
  (curated summary + verbatim algo.md) with x-markdown-tokens hint.
  Other paths still 406 — no synthesizing markdown from LiveView HTML.
- Content-Signal directive in robots.txt declaring
  search=yes, ai-train=no, ai-input=no.
- RFC 9727 API catalog at /.well-known/api-catalog with the sole
  public endpoint (/api/contacts/map) linking status -> /health and
  service-desc -> /openapi.json (a new minimal OpenAPI 3.0 spec).
- Agent Skills Discovery index at /.well-known/agent-skills/index.json
  listing two skill documents (fetch-contacts, read-algorithm) with
  sha256 digests computed at compile time; documents served at
  /.well-known/agent-skills/{name}/SKILL.md.

Skipped (don't match actual site capabilities): Web Bot Auth JWKS,
OAuth/OIDC discovery, MCP server card.
2026-04-17 12:09:05 -05:00
6972feb2c2
perf(contact-map): client-side band filtering via per-band layer groups
Band toggles previously roundtripped through LiveView and rebuilt every
polyline + popup + hover handler on each click. Now the hook builds one
L.LayerGroup per band once after the initial fetch; toggling is just
addLayer/removeLayer on the map (O(bands), not O(contacts)). Checkbox
and All/None events are handled directly in the hook via delegated
document listeners — no LV roundtrip. Callsign filter still roundtrips
(debounced) since it's cross-cutting, and now iterates pre-built
polylines without recreating them.
2026-04-17 12:08:46 -05:00
2a57074a47
feat(contact-detail): use 10 GHz threshold for MHz/GHz band display
Anything below 10 GHz now renders in MHz so 2304/3456/5760 etc. match
amateur microwave convention. 10 GHz and above still render in GHz.
2026-04-17 12:08:38 -05:00
1a8ca44de3
feat(contact-detail): render band in MHz below 1 GHz, GHz above
The detail page was hard-coding GHz formatting, so 50/144/222/432/902
rendered as 0.1/0.4/0.9 GHz instead of the MHz labels every amateur
operator actually uses. Switch to MHz when band < 1000, keep GHz for
microwave bands.

Also surface band and mode in the header subtitle so the key QSO
metadata is visible before SRTM/terrain enrichment completes (the
elevation-profile block that previously rendered them is gated on
that enrichment).
2026-04-17 10:51:54 -05:00
847f2001dd
fix(workers): make weather/hrrr job uniqueness horizon indefinite
Both workers had unique: [period: 300] which only dedupes within a
5-minute window. Re-running the enrichment-enqueue script hours or
days later stacked identical jobs in the available queue — we found
~60k redundant weather + 3k redundant hrrr jobs in prod from the
March and April enqueue passes.

:infinity means Oban dedupes against every live job in the states
list regardless of age, so a second enqueue pass is a no-op as long
as the first pass's jobs haven't completed yet.
2026-04-17 10:15:30 -05:00
b4a36890da
feat(import): fire confetti when the complete banner appears
Vendors canvas-confetti 1.9.3 and wires an ImportConfetti hook on the
alert-success banner that /imports/:id already renders when
run.status == "complete". The hook uses the "realistic look" preset
from confetti.js.org and runs on mount, so it fires exactly once —
when the banner first appears.
2026-04-17 10:01:18 -05:00
3cb367c898
fix(import): handle Radio.create_contact :duplicate return in worker
apply_valid_rows only matched {:ok, _} and {:error, %Changeset{}}, so when
Radio.create_contact returned {:error, :duplicate, existing} — the race
window where preview flagged a row as new but another writer inserts the
same contact before the worker picks up the chunk — the reduce callback
crashed with FunctionClauseError. Oban retried the chunk, rolling back
the Postgrex transaction and surfacing the DBConnection errors the
operator was seeing.

Treat :duplicate as a silent skip: the row is already in the DB, there
is nothing to insert, and the counter should not double-count the existing
contact as an error either.
2026-04-17 09:54:17 -05:00
247d066ec5
feat(import): live /imports/:id progress page + gate upload bars on submit
SubmitLive now hands off to the async pipeline:
- confirm_csv/confirm_adif → CsvImport.enqueue/2 → push_navigate to
  /imports/:id. ADIF preview shape matches CSV, so one enqueue call
  serves both.
- allow_upload(auto_upload: false) so bytes only stream when the user
  clicks submit. Progress bar + percentage now gated on
  entry.progress > 0 and < 100 — invisible during file selection,
  only rendered while the actual upload is in-flight.
- Old in-page csv_results / @csv_result / reset_csv removed; the
  /imports/:id page replaces them.

New ImportLive at /imports/🆔 subscribes to 'csv_import:<id>' PubSub,
shows total/processed/imported/refined/error counters in a daisyUI
stats grid, progress bar, status badge, and a collapsed errors table
when error_count > 0. Missing or malformed run_id redirects to /submit
with a flash.

7 new frontend tests (5 ImportLive, 2 updated SubmitLive), 1902 total,
credo clean.
2026-04-17 09:31:22 -05:00
48833f15be
feat(import): async CSV import with progress-tracking schema
For large CSVs (e.g. the 34k-row ARRL dump), the synchronous commit
path blocked for minutes and a crash mid-commit left no audit trail.
Refactor to run inserts + enrichment enqueues via Oban.

- New import_runs table: stores preview rows + running counters (total,
  processed, imported, refined, error) + status + errors map + timing.
- New :contact_import Oban queue (limit 4) + ContactImportWorker.
  Args: {import_run_id, offset, limit}. Each chunk (100 rows) inserts
  its slice via CsvImport.commit_rows/1, atomically increments counters
  in one update_all, flips status, and broadcasts progress on
  'csv_import:<id>' PubSub topic.
- CsvImport.commit_rows/1 extracted from commit/1 (back-compat preserved).
  New CsvImport.enqueue/2 persists the run + dispatches N chunk jobs.

Also: submit page copy updated from '902 MHz and up' to '50 MHz and up'
matching the band allowlist expansion done earlier.

LiveView UI for /imports/:id is a separate commit.
2026-04-17 09:31:22 -05:00
8a969e315c
refactor: normalize pos1/pos2 JSONB key to 'lon' everywhere
57,186 prod contacts stored pos1/pos2 with 'lng'; 1,133 used 'lon'.
Every Elixir caller carried a `pos["lon"] || pos["lng"]` fallback
— which just caused a SQL widget to silently miscount 98% of contacts
(count_narr_done used `pos1->>'lon'` directly, no fallback, so every
lng-keyed row returned NULL and failed the coverage check).

- Migration rewrites every pos1/pos2 JSONB in place, renaming 'lng' to
  'lon' and dropping 'lng'.
- Removes all 20+ `|| pos["lng"]` fallbacks across lib/, workers,
  scorer, weather, radio.ex, contact show view, and recalibrator.
- lib_ml/propagation_analyze.ex SQL now reads pos1->>'lon' directly
  (was reading 'lng' only, which would have broken after migration).
- priv/repo/import_contacts.exs one-time seed script now emits 'lon'
  with string keys, matching production shape.
- Test fixtures in 4 test files normalized to 'lon'.
- Two lng-characterization tests deleted — nonsensical post-normalize.
- Updated notebook + old import_weather script to match.
- JS hook contact_map_hook.ts TypeScript type narrowed to 'lon'.
2026-04-17 09:10:32 -05:00
6f452e7139
feat(map): show CONUS-only banner for visitors outside continental US
Visitor geolocation already flows via the Cloudflare CF-IPLatitude /
CF-IPLongitude headers into the session; MapLive centers the map on
the visitor's location (or DFW as fallback). Add a banner that appears
above the map when those coords are outside the CONUS bbox
(lat 24.5-49.5, lon -125 to -66.5), letting overseas visitors know
propagation data won't be available at their location yet.

Map still centers on the visitor's actual location — this is a note,
not a re-center. Banner is fully absent from the DOM when not needed
(:if conditional).
2026-04-17 08:39:51 -05:00
8f0548b31e
fix(narr): handle cdo 2.5.1 mixed name/code output
parse_cdo_row/1 assumed cdo -outputtab,code,lev,value would always emit
numeric codes in the first column, but Debian's cdo 2.5.1 (the version
in the prod container) emits mixed output:

- 'codeNN' with a literal 'code' prefix for most GRIB1 parameters
- Short GRIB1 names for a few: 2tag2 (PRES sfc), saip (DPT 2m),
  tpag10 (HGT)

Result: every row fell through to :error, yielding a 'no parseable
values' error and 986 discarded NarrFetchWorker jobs.

Fix: normalize the code token by stripping 'code' prefix when present
and mapping known 2.5.1 short names. UGRD/VGRD intentionally omitted
(no discard evidence yet) — comment documents the add-when-they-surface
plan.

Also exposed parse_cdo_outputtab/1 as @doc false for testability.
2026-04-16 15:14:51 -05:00
fbbfe037bc
fix(status): align NARR done-count tolerance with fetcher snap
count_narr_done/0 checked narr_profiles.valid_time within qso_timestamp
± 30 min — but NARR analyses live at 3-hour marks (00Z/03Z/.../21Z) and
NarrClient.snap_to_analysis_hour/1 floors qso_timestamp to the previous
mark before fetching. A QSO at 18:50Z snaps to 18:00Z on the fetcher
side; the profile stored at 18:00Z fell outside the widget's [18:20,
19:20] window, so the status page showed 6/208 done when actual
coverage was 208/208.

Fix: use equality against the Postgres equivalent of the snap
(date_trunc('hour', ts) - make_interval(hours => hour % 3)).
Comment cites NarrClient.snap_to_analysis_hour/1 so future changes keep
them in sync.
2026-04-16 15:14:51 -05:00