Commit graph

1434 commits

Author SHA1 Message Date
dc8353a9e9
test: coverage round 4 (84.39% → 84.44%) + 6 new property tests
30 unit tests + 6 property tests across two parallel agents.

- ContactLive.Show + HrrrNativeClient + NexradClient: sort_observations
  + sort_soundings actual ordering per field, closest_observations
  capped-at-5 proximity, nil-pos2 half_dist=0 path, HrrrNativeClient
  scrambled-level density + surface finiteness, NexradClient cache
  population + zero-byte + year-boundary URL rounding. Properties:
  sort_observations preservation, haversine symmetry, build_native
  surface_temp_k finiteness.

- PathLive + Viewshed + GefsFetchWorker + SnmpClient: GPS source
  URL preservation, QRZ 404 surfacing, propagation_updated same-midpoint
  no-op; Viewshed effective_reach_km BLOCKED boundaries + find_reach_km
  zero max_range; GefsFetchWorker 502/400/410 + wind_u/wind_v aliases
  + nil profile; SnmpClient fully-qualified OID + double-dot drop +
  empty poll + unknown radio type. Properties: destination_point
  round-trip < 0.5 km, valid_time = run_time + fh*3600 invariant,
  parse_snmpget_output totality.
2026-04-24 10:32:05 -05:00
FluxCD
35a064b1a4 chore: update prop image to git.mcintire.me/graham/prop:main-1777043748-d88bdd7 [skip ci] 2026-04-24 15:17:56 +00:00
d88bdd70a8
fix(map): harden :preload_forecast against slow NFS + LiveStash crash
Two user-visible crashes in MapLive seen on prod pods today:

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

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

Drop the guard module once the upstream match-spec fix lands.
2026-04-24 10:15:37 -05:00
c514626a62
test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents.

- ContactLive.Show round 3 (72% → ~80%): enrichment failure
  surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR
  fallback), radar-only mechanism, load_solar + enqueue_missing_solar
  paths, blocked_message nil obs_dist, band_summary for 144M/1296M/
  2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts
  with seeded Oban jobs. Properties: propagation_mechanism summary is
  always a binary, obs_sort_key totality over arbitrary field keys.

- Weather clients: iem_client transport timeouts + CSV edge cases,
  rtma_client idx url property, swpc_client HTTP 503/404 + empty-
  array parses + JSON/CSV totality properties, ncei_metar_client
  parse edge cases, snmp_client OID-roundtrip property + parse
  edge cases.

- Mid-coverage LiveViews: UserProfileLive avatar/render branches,
  EmeLive invalid-grid/unknown-callsign + form-validation property,
  PathLive zero-distance + 3000km + height/power round-trip property,
  BeaconLive numeric bearing + non-admin approve denied, MapLive
  select_band/toggle_radar/toggle_grid events + band-URL property.

Fix: propagation_mechanism property test uses System.unique_integer
for lat + valid_time nonces instead of StreamData-shrinkable jitter;
the shrink toward 0 was tripping the hrrr_profiles unique index.

205 → 215 properties, 2743 → 2812 tests, 0 failures.
2026-04-24 10:15:37 -05:00
bbf51544e1
test: coverage round 2 (82.05% → 83.96%) + 23 new property tests
77 unit tests + 23 property tests across four parallel agents.

- ContactLive.Show (72.01% → ~75%): every factor-note branch
  (humidity/time/td/refractivity/pressure/season/pwat), all
  propagation_mechanism classifications, apply_admin_edit/owner_edit/
  submit_user_edit error paths, internal_network? IP shapes, expanded
  sounding skew-T, composite-score [0,100] property.

- PathLive + BeaconLive.Show + Weather: coordinate-pair resolver,
  invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*,
  nearest_native_duct_*, find_nearest_rtma, recent_surface_obs;
  properties for round_to_hrrr_grid/round_to_iemre_grid/band
  round-trip/beacon-id URL round-trip.

- Viewshed + Duct + SoundingParams: find_reach_km edge cases,
  destination_point, effective_reach_km fractions, detect_ducts
  trailing-duct + guard branches; 13 properties including flat-
  terrain-fully-visible, thicker-duct-lower-freq, M-profile
  monotonicity, feature-vector deterministic length.

- Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed
  ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB,
  IonosphereFetchWorker 404 + non-tabular body, RadarBackfill
  --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest
  --feature + --all; year-filter property covering 2015..2026.

170 → 205 properties, 2666 → 2743 tests, 0 failures.
2026-04-24 10:15:37 -05:00
e11ebc9af8
test: coverage round 1 (80.45% → 82.05%) + 12 new property tests
Adds 67 test cases + 12 property tests across four surfaces — parallel
agents targeted the lowest-coverage files in the tree.

- ContactLive.Show (48.56% → 72.01%): 16 unit tests + 2 properties
  covering IEMRE/radar/ducting/propagation-analysis render branches,
  every terrain-verdict class, expanded HRRR/terrain sections, pending-
  edit banner, internal-network conn, and total-over-fields properties
  for obs + sounding sort handlers.

- HrrrNativeClient (33% → 58%): 8 unit tests + 4 properties covering
  partial-surface fallback, empty input, optional-var nils, bogus
  binary dispatch, and level-count + monotonic-height + URL-encoding
  + duct-subset invariants.

- NexradClient / HrrrClient / GefsClient: 13 unit tests + 4 properties
  covering 500/404/timeout paths, malformed idx bodies, parse_idx
  totality, byte-range invariants, and URL-builder round-trips.

- HrrrBackfill / HrrrNativeDeriveFields / ImportContestLogs: 7 unit
  tests + 1 property. Seeds contacts + native profiles, stubs HRRR
  with Req.Test, exercises the `--limit 0` and `filter_points_needing_
  backfill` paths, CSV import happy + malformed paths, and a
  band-string round-trip property.

2599 → 2666 tests, 170 → 182 properties, 0 failures.
2026-04-24 10:15:37 -05:00
FluxCD
825d2baa10 chore: update prop image to git.mcintire.me/graham/prop:main-1777041055-2900b9a [skip ci] 2026-04-24 14:33:44 +00:00
2900b9a959
fix(eme): destructure :timer.send_interval return, silence dialyzer
EmeLive.mount/3 dropped the {:ok, tref()} return from send_interval
on the floor. Elixir 1.19 + :unmatched_returns dialyzer flag catches
the discarded tuple; the if/else with explicit :ok arms keeps both
branches typed as :ok.
2026-04-24 09:30:42 -05:00
1e8dd7036d
test: fix async-hydration timeout flake + type warning
- Bump render_async/2 timeout to 2_000ms across contact_live_test.
  Default 100ms races when the suite runs on a loaded box — ContactLive.
  Show fans out 8 hydration tasks at mount and sporadically missed the
  window even on a quiet DB.
- Telemetry metrics-shape assertion uses Enum.any?/1 instead of the
  `metrics != []` comparison Elixir 1.19's type system flags as a
  distinct-type comparison (the function's non-empty return type).
2026-04-24 09:28:52 -05:00
b2b8ddc1c4
test: silence expected warnings/errors during test runs
Passes `capture_log: true` to ExUnit.start/1 so log output emitted
during a passing test stays in the per-test capture buffer instead of
stdout. Drops ~360 lines of noise from the suite: Postgrex teardown
disconnects, expected worker failures, NexradClient 404 stubs,
NotifyListener warm-skip messages, SNMP poll failures, PromEx tag-
drop warnings, and OTel-handler boot errors. Failing tests still
surface their full logs on report, so debugging is unchanged.

Side-effect fixes:
- LoggerFormatTest opts out via @moduletag capture_log: false — it
  patches the :default :logger handler and capture_log hot-swaps the
  same handler, so the setup lookup would 404.
- TelemetryTest.start_link/1 unlinks the supervisor before the brutal
  kill so the exit signal doesn't propagate to the test process.
- Drop unused `import Ecto.Query` in a top_hours mix-task test.
- Drop unused default-arg `attrs \\ %{}` on create_contact/1 in
  contact_map_live_test (every caller passes attrs explicitly).

Remaining noise is ~3 lines from oban_pro vendored-dep @impl warnings
(require an upstream patch) plus rare intermittent async teardowns.
2026-04-24 09:27:17 -05:00
FluxCD
b057b45fe7 chore: update prop image to git.mcintire.me/graham/prop:main-1777039919-d7d865d [skip ci] 2026-04-24 14:16:37 +00:00
FluxCD
be00130b05 chore: update prop image to git.mcintire.me/graham/prop-grid-rs:main-1777039918-d7d865d [skip ci] 2026-04-24 14:16:33 +00:00
d7d865d566
chore(rust): drop OpenTelemetry stack to speed up cold builds
Removes the OTLP span exporter (tracing-opentelemetry, opentelemetry,
opentelemetry_sdk, opentelemetry-otlp) and its transitive tonic/prost/
h2 tree — 56 crates off the build graph, ~3-5 min off a cold build on
the forgejo runner.

The fmt layer (JSON to stdout via tracing-subscriber) is unchanged;
structured logs still reach the Loki collector through kubectl logs,
and /metrics on :9100 still serves Prometheus. Traces were a nice-to-
have that nothing on call was actually reading.

telemetry::init keeps its signature (service_name + TelemetryGuard)
so re-adding OTLP later is a single-module change. k8s manifests drop
the now-unused OTEL_EXPORTER_OTLP_ENDPOINT env var.
2026-04-24 09:11:43 -05:00
FluxCD
a1ccbeb3e5 chore: update prop image to git.mcintire.me/graham/prop:main-1777038907-5d9f02d [skip ci] 2026-04-24 14:07:30 +00:00
5d9f02d0e2
chore(grid-rs): retrigger Rust CI to heal ImagePullBackOff
The main-1776894678-93b526c image's index manifest references a
platform-manifest digest (sha256:8850f92b...) that's no longer in the
Forgejo registry. Both `latest` and the versioned tag point at the same
broken digest, so every `docker pull` on talos5 has been 404ing for 14h.

Bumping the crate root forces build-grid-rs.yaml's path filter to fire
and push a fresh image.
2026-04-24 08:54:49 -05:00
FluxCD
141cb6faf4 chore: update prop image to git.mcintire.me/graham/prop:main-1777037259-5564b67 [skip ci] 2026-04-24 13:30:18 +00:00
5564b6703d
test: push line coverage from 78.67% → 80.45%
Adds 30 more test cases (2594 → 2624 tests) targeting the last
low-coverage hot spots.

Notable additions:
- ContactLive.Show fully-hydrated render path (seeds HrrrProfile,
  TerrainProfile, ContactCommonVolumeRadar, Sounding, SurfaceObservation,
  IemreObservation) exercises refresh_computed, build_propagation_analysis,
  build_data_sources, compute_elevation_profile, and the downstream
  render branches they unlock.
- handle_info :terrain_ready and :sounding_fetched paths for same-contact
  and unrelated-contact dispatch.
- Flagged-invalid badge + line-through header style.
- Owner-label button path (Edit vs Suggest Edit).
- NotifyListener init/1 subscribes on schedule; handle_info(:subscribe)
  succeeds against the live Repo.
- RecalibrateScorer runs cleanly against an empty corpus via the
  Recalibrator insufficient-data fallback.
- HrrrNativeClient.extract_native_profiles/2 tolerates malformed input
  without crashing.
2026-04-24 08:27:18 -05:00
FluxCD
0f46d4361a chore: update prop image to git.mcintire.me/graham/prop:main-1776987814-f99d07b [skip ci] 2026-04-23 23:45:30 +00:00
f99d07bd29
test: push line coverage from 71.7% → 78.67%
Adds ~600 new test cases (2013 → 2613 tests; 170 properties; 0
failures) across 12 new test files plus expansions of eight existing
files. Big lifts per module:

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

Two incidental fixes made while adding tests:

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

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

Stable property-test additions cover the dewpoint-from-RH monotonicity,
nearest_run/1 idempotence, and the ionosphere station envelope.
2026-04-23 18:43:18 -05:00
86b2718916
infra(k8s): load-based HPAs, drop anti-affinity, retire talos5
Adds HorizontalPodAutoscaler resources for the three load-responsive
Deployments:

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

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

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

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

Updates runbook + project memory to drop the `talos5` hostname —
Postgres is now addressed strictly by IP (10.0.15.30).
2026-04-23 18:43:18 -05:00
FluxCD
53dc08d659 chore: update prop image to git.mcintire.me/graham/prop:main-1776981668-109c4e1 [skip ci] 2026-04-23 22:04:43 +00:00
109c4e141f
feat(eme): 3D WebGL Earth-Moon globe with lazy-loaded Three.js
Replaces the SVG schematic on /eme with a textured WebGL scene: NASA
Blue Marble Earth, real-size Moon, outbound beam, dashed return ray
to the sub-lunar point, and a red shading on the anti-moon hemisphere
that marks who can't see the Moon (outside the bounce footprint).

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

Also swaps the Elevation/SNR rows so the badge comes before the number.
2026-04-23 17:00:53 -05:00
FluxCD
731c095840 chore: update prop image to git.mcintire.me/graham/prop:main-1776979115-bd107ee [skip ci] 2026-04-23 21:21:30 +00:00
bd107ee747
feat(eme): add /eme Earth-Moon-Earth calculator
New /eme page — antenna aiming + link budget for moonbounce.

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

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

New modules:

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

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

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

Full suite: 2,460 tests + 170 properties; credo strict clean.
2026-04-23 16:18:20 -05:00
FluxCD
e33116f759 chore: update prop image to git.mcintire.me/graham/prop:main-1776977813-a7250c3 [skip ci] 2026-04-23 20:59:24 +00:00
a7250c39e7
test: pin GRIB2 Section parsing to its full contract
- identify_var: full round-trip across every known (discipline,
  category, number) triple, plus a property that any triple outside
  the 12-entry lookup falls back to the "cat:num" default.
- identify_level: hybrid-level and entire-atmosphere branches, plus
  two properties — pressure levels always render as
  `div(value_pa, 100) <> " mb"` and unknown surface types always
  render as `unknown:<type>:<value>` (generator samples only from
  types outside the 6-entry lookup set).
- sign_magnitude_16: round-trip property over [-32_767, 32_767] (±0
  both decode to 0 as intended) and a monotonicity property inside
  a single sign band.

Suite: 2,419 tests + 164 properties (was 2,416 + 159); credo strict
clean.
2026-04-23 15:56:38 -05:00
FluxCD
d66ac0b2a2 chore: update prop image to git.mcintire.me/graham/prop:main-1776971976-54cabdb [skip ci] 2026-04-23 19:22:42 +00:00
54cabdb609
fix(enrichment): mark OCONUS contacts hrrr_status :unavailable
Root cause of 14 stuck hrrr-queued contacts: hrrr_point_rs's CONUS-only
grid silently writes zero profiles for out-of-bounds points (UK,
Winnipeg, Alberta, >50° lat), but the fetch task still completes
successfully. ContactWeatherEnqueueWorker's hrrr_placeholder_jobs
always emitted a queued placeholder for any contact with a pos1,
which meant mark_hrrr_status! re-flagged :queued every backfill tick
forever.

Introduce Propagation.Grid.contains?/1 as the canonical in-CONUS
check (inclusive on all four edges, nil-safe). Use it in two places
of ContactWeatherEnqueueWorker:

- hrrr_placeholder_jobs/1 now returns [] for OCONUS contacts, same
  as pre-2014 / already-complete ones.
- mark_hrrr_status!/3 empty-list clause now branches on Grid.contains?
  first (→ :unavailable) before falling through to the existing
  NARR-coverage / :complete logic.

Next backfill tick will flip the 14 stuck OCONUS contacts in prod to
:unavailable automatically. The remaining 13 "CONUS" stuck ones are
right at the 50° edge (lat=50.1875 rounds outside the box) — same
reconciliation path.

Coverage additions:
- Grid.contains?/1 — corner-inclusive, OCONUS positive/negative
  fixtures based on the actual stuck-contact pos1 values, nil/missing-
  key/nil-value handling.
- enqueue_for_contact regression tests: pos1 in the UK and pos1 just
  north of the lat cap both land at :unavailable, not :queued.

Suite: 2,416 tests + 159 properties (was 2,409 + 159); credo strict
clean.
2026-04-23 14:19:23 -05:00
FluxCD
8435d95900 chore: update prop image to git.mcintire.me/graham/prop:main-1776971633-56e6a35 [skip ci] 2026-04-23 19:16:38 +00:00
56e6a3513c
test: cover METAR field extractors and Scorer.path_integrated_conditions
- NceiMetarClient: wind-group parsing (VVVKKKT), missing-wind-group
  fallback to nil, multi-layer sky condition concat, missing-sky token
  returns nil, missing altimeter returns nil, T-group-less fallback to
  the coarse "TT/DD" field.
- Scorer.path_integrated_conditions/2: averages surface temp/dew,
  takes MIN of pressure + refractivity gradient, takes AVG of HPBL +
  PWAT, threads contact time+longitude, and propagates -97.0 longitude
  when pos1 lacks a lon key. Returns nil when every profile is missing
  surface temp OR dewpoint; returns nil on empty-list input;
  tolerates nil pressure / gradient / HPBL / PWAT when temp + dew
  are present.

Suite: 2,409 tests + 159 properties (was 2,397 + 159); credo strict
clean.
2026-04-23 14:13:42 -05:00
FluxCD
eb0eaa2416 chore: update prop image to git.mcintire.me/graham/prop:main-1776971365-41573d4 [skip ci] 2026-04-23 19:12:16 +00:00
41573d4013
test: cover Format.number/bytes, Instrument.span, SolarClient edges
- Format.number/1: boundary cases (0, 999, 1_000), float rounding,
  nil/atom fallback to to_string, plus two properties — comma
  insertion is lossless round-trip and the comma count follows
  `div(digits - 1, 3)` for |n| ≥ 1000.
- Format.bytes/1: per-unit boundary cases plus a monotonicity
  property (parse-back preserves ordering across the B/KB/MB/GB
  transitions).
- Instrument.span/2,3 (0→100%): wraps the block in a telemetry span
  returning its value, defaults metadata to %{}, emits the
  :exception event and re-raises on a raise inside the span, and
  prefixes every event suffix with :microwaveprop.
- SolarClient: short-line / empty-line / blank-day handling for
  parse_gfz_row; blank-line + short-line tolerance in parse_gfz_file;
  filter_since boundary semantics (>= is inclusive, empty list
  passes through).

Suite: 2,397 tests + 159 properties (was 2,382 + 155); coverage
71.12% → 71.16%; Format + Instrument now at 100%; credo strict clean.
2026-04-23 14:09:13 -05:00
FluxCD
8324d019ca chore: update prop image to git.mcintire.me/graham/prop:main-1776970997-e7fb34e [skip ci] 2026-04-23 19:07:05 +00:00
e7fb34eb6f
test: add /qsos→/contacts redirect + scorer conversion properties
- PageController: cover the legacy `/qsos` → `/contacts` redirect pair
  (both the index and the id-preserving per-resource redirect), which
  had no coverage since the route rename.
- Scorer property tests:
  - f_to_c / c_to_f are inverses up to float epsilon, both propagate
    nil, and the C→F slope is exactly 5/9.
  - wind_speed_kts is non-negative, propagates nil on either axis,
    and matches sqrt(u²+v²)·1.94384 (the 5-digit kts factor the
    scorer uses).
  - precip_to_rate_mmhr is non-negative, 0 for nil/zero/negative.
  - dbz_to_rain_rate_mmhr is 0 below 5 dBZ (and for nil), and
    monotonically non-decreasing above — guards against accidental
    inversion of the Marshall-Palmer lookup.

Suite: 2,382 tests + 155 properties (was 2,380 + 148); coverage
71.08% → 71.12%; credo strict clean.
2026-04-23 14:03:02 -05:00
b3c035793a
Add incremental prod-to-local DB sync script
scripts/sync_prod_to_local.sh pulls only rows whose updated_at is
newer than the corresponding local row, versus restore_prod_to_local
which does a full pg_dump + drop + restore. For day-to-day refreshes
the incremental path is dramatically cheaper — the full dump is ~12
GB, while an hourly incremental is typically a few thousand rows.

Approach:
- Discover every public table that carries `updated_at` (skips the
  oban_* runtime tables and schema_migrations by default).
- For each, intersect the column list across local and prod so schema
  drift (a new column on prod not yet migrated locally, or vice-versa)
  doesn't break the sync — we transfer the columns that exist on
  both sides and leave the rest alone.
- Resolve the ON CONFLICT target: primary key first, else the
  shortest non-partial unique index (important for partitioned tables
  like hrrr_profiles which have no top-level PK).
- Stream the changed rows via `\COPY ... TO STDOUT` into a per-table
  temp file, then load into a LIKE-cloned temp table and UPSERT
  across `SET session_replication_role = replica` so FK ordering
  doesn't block the dev-only run.
- Partitioned parents (relkind='p', relispartition=false) are kept in
  the discovery list; partition children (relkind='r',
  relispartition=true) are excluded so INSERTs route through the
  parent.

Flags: `--dry-run` counts without writing; a positional argument
limits the run to a single table. `SKIP_TABLES` env var overrides
the exclusion list.

Also added a pointer from restore_prod_to_local.sh's header comment
to the new script.
2026-04-23 13:57:22 -05:00
e61d4c42b9
Add GPLv3 license 2026-04-23 13:57:07 -05:00
0a9058bfa0
test: broaden coverage across parsers, schemas, and pure helpers
- SwpcClient: unparseable time_tag rows drop silently, integer / string
  numerics both cast, already-decoded list bodies (Req auto-decode) are
  accepted, non-JSON-array bodies return {:error, :not_a_list}, X-ray
  rows whose energy isn't the long wavelength are filtered, and the
  misspelled-upstream electron_contaminaton key flags
  electron_contaminated.
- UwyoSoundingClient: every month abbreviation routes to its correct
  number, bogus month tokens fall through to an empty result, and
  short-of-fixed-width lines are skipped without raising.
- Grid.wgrib2_grid_spec/0: lon/lat_start anchor to the SW corner,
  steps match Grid.step/0, cell counts span CONUS inclusive, and the
  spec's cell product equals length(Grid.conus_points/0).
- PollWorker: empty-link list is a no-op, and poll_fn receives host /
  community / radio_type straight from the link row.
- Contact submission_changeset: antenna heights are bounded to
  [0, 1000] ft, whitespace-only mode normalises without firing an
  invalid-mode error, plus a pair of StreamData properties — every
  sanctioned band validates and every out-of-list band string is
  rejected.

Suite: 2,380 tests + 148 properties (was 2,370 + 146); coverage
70.38% → 71.08% total; credo strict clean.
2026-04-23 13:56:29 -05:00
75ec3f8d1e
chore(deps): upgrade live_stash 0.1.2 → 0.2.0
Two breaking API changes in 0.2:

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

Updated both callers (MapLive persists selected_band + selected_time;
SubmitLive persists active_tab). `recover_state/1` signature is
unchanged. Full LiveView suite passes (67 tests).
2026-04-23 13:56:28 -05:00
FluxCD
ed21758557 chore: update prop image to git.mcintire.me/graham/prop:main-1776968936-764643b [skip ci] 2026-04-23 18:31:46 +00:00
764643bc62
test: expand coverage and refactor to idiomatic style
Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
  + `if/else` with a classifier that pattern-matches the error reason
  directly, returning `:transient | :permanent`, and dispatch via
  `case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
  step on the row/col comprehensions so an image box whose centre
  lands outside the raster collapses to an empty iteration instead
  of firing Elixir 1.19's ambiguous-range warning. Adds a regression
  test for the right-edge box.

Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
  duct_thickness, best_duct_freq, duct_usable_for_band,
  distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
  unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
  tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
  handling; parse_snmpget_output OID normalisation and junk-line
  tolerance.
- HrrrNativeClient native_level_count, native_variables,
  native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
  SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
  Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
  monotonic in RH, finite across the operating envelope) + idempotent
  nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
  output; latlon_to_pixel round-trip for every grid cell.

Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
  current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.

Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
2026-04-23 13:28:42 -05:00
FluxCD
8c0865482c chore: update prop image to git.mcintire.me/graham/prop:main-1776967519-cfb0c51 [skip ci] 2026-04-23 18:07:33 +00:00
cfb0c51d2f
test: add property coverage for grid snaps, backoff, notes
Pure-function invariants that example tests can't express at scale:

- Weather.round_to_iemre_grid/2, HrrrClient.nearest_hrrr_hour/1,
  NexradClient.round_to_5min/1, NarrClient.snap_to_analysis_hour/1:
  each is idempotent, its output satisfies a grid/slot-membership
  predicate, and lies within a bounded distance of the input.
- NarrClient.in_coverage?/1: coverage_end is an exclusive upper
  bound; any timestamp after it is out of coverage.
- IemreFetchWorker.backoff/1: result is in [120, 21_600], monotonic
  non-decreasing in attempt, equals 120*2^(attempt-1) pre-cap.
- Contact submission_changeset notes field: every non-blank string
  up to 2000 chars validates; any longer string is rejected.

22 new properties (139 → 161 including existing); all 2,300 tests
green, credo clean.
2026-04-23 13:05:06 -05:00
FluxCD
a9d13d8eb9 chore: update prop image to git.mcintire.me/graham/prop:main-1776967071-063e9e3 [skip ci] 2026-04-23 18:00:31 +00:00
063e9e3ae4
fix(grid-tasks): reclaim orphan running rows on hourly seed
Rust workers (prop-grid-rs) that die mid-claim (SIGKILL, OOM, node
drain) leave grid_tasks rows stuck in status='running' forever,
because claim_next uses FOR UPDATE SKIP LOCKED and nothing resets the
orphan. The /status page then shows a permanent spinner with stale
f00/f10/f18 badges — currently 21 rows claimed as far back as
2026-04-20.

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

Also rename the status panel "Retrying" column to "Failed" — it was
always showing terminal `failed` rows, never retrying ones.
2026-04-23 12:57:39 -05:00
13088a6906
feat(nav): add Status link to admin dropdown 2026-04-23 12:49:48 -05:00
FluxCD
e88375e50b chore: update prop image to git.mcintire.me/graham/prop:main-1776965182-47ba8d3 [skip ci] 2026-04-23 17:28:47 +00:00
47ba8d3b6d
fix(enrichment): stub IEMRE on permanent failure + bump hot CPU
IemreFetchWorker now writes an empty stub observation when IEM returns
a permanent-failure status (404, 422), mirroring the existing {:ok, []}
path. The backfill cron's next tick sees the stub via
has_iemre_observation?/3, generates no job, and mark_status!/3 flips
the contact's iemre_status from :queued to :complete — draining the
51 contacts that have been stuck behind cancelled out-of-grid IEMRE
jobs.

Raise hot pod CPU limit 2 → 3 so BEAM gets 3 schedulers online.
Observed run queues of [2, 10, 0, 0, 0, 0] on the 2-scheduler
configuration during the :05 propagation chain, starving /live and
/health probe handlers and tripping intermittent liveness timeouts.
2026-04-23 12:26:09 -05:00
FluxCD
da66a613ef chore: update prop image to git.mcintire.me/graham/prop:main-1776960355-b2ff279 [skip ci] 2026-04-23 16:10:27 +00:00
b2ff27945d
feat(adif): carry NOTES / COMMENT into contact notes
ADIF records frequently include operator commentary in the
<NOTES> field (multi-line, detailed) or <COMMENT> (short
one-liner). Now that contacts have a notes column, pass that
value through the import pipeline instead of dropping it.

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

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

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

ADIF import and the refinement/refinement-notify paths are out of
scope — users specifically asked for CSV + single-contact. Tests
cover the changeset (accept/blank/over-length), CSV header parsing
(plain + RFC-4180 quoted), and LiveView form submit end-to-end.
2026-04-23 11:02:27 -05:00