When a user types raw "lat,lon" coordinates into a station input,
LocationResolver populates only `lat` / `lon` — `callsign` and `grid`
stay blank. The path-profiles "Station" cell was falling through to
"33.189, -96.452" instead of showing a useful grid.
Now `station_label/1` (display) and `station_endpoint/1` (path-URL
destination) derive a 6-/8-char Maidenhead grid from the stored
lat/lon when no grid is on the row, matching the rest of the page's
grid-first presentation.
`Weather.nearest_native_duct_info/3` returns `{:error, :not_found}`
whenever the midpoint has no native HRRR profile within ±0.07° / ±1 h
— the documented fallback for the sparsely-populated
`hrrr_native_profiles` table (only QSO-worker-touched cells get
ingested). The empty-map fallback was already the right behavior; the
warning was just noise that fired on every /path computation outside
the populated set.
Now the `:not_found` clause silently returns the same fallback shape
without logging.
Adds a Sunday 06:00 UTC schedule trigger to build-base.yaml plus a
CACHE_BUST=<ISO year+week> build-arg keyed off `date -u +%G-W%V`. The
apt RUN steps in Dockerfile.base reference the arg in their command
line so BuildKit's layer hash changes when the week rolls over,
forcing apt-get update + install to re-execute and pull fresh
security updates.
The wgrib2-builder stage doesn't see CACHE_BUST so the ~5-min wgrib2
compile keeps hitting layer cache across weeks; only the apt layers
rebuild. Effective cost of the weekly job: ~2 min for the cdo /
gdal-bin reinstall.
- Rows in each rover-location group are now phx-click handlers that
navigate to /path with source (rover-loc as 10-char Maidenhead grid),
destination (callsign > grid > coords), band, and rover/station
heights pre-filled — one click takes you to a ready-to-compute Path
Calculator screen.
- Station label now shows "AA5C · EM12kp" when both callsign and grid
resolved, instead of falling back to lat/lon. Bare-grid and
bare-coord stations keep their existing labels.
- Group heading uses Maidenhead.from_latlon(lat, lon, 10) so the
10-char grid is shown in full instead of being truncated to 6
characters.
The wgrib2-builder stage and the runtime apt-install stages
(libstdc++6/openssl/locale + cdo/gdal-bin) now live in
git.mcintire.me/graham/prop-base, built separately by build-base.yaml.
The app Dockerfile FROMs that image and just COPYs the Elixir release
on top.
Net effect: typical pushes drop ~8 min of wgrib2 compile + ~2 min of
cdo/gdal-bin apt installs. Bumping wgrib2 or g2c is now a one-line
change to Dockerfile.base — the next push picks up :latest
automatically.
Pre-builds the runtime base (wgrib2 + g2c + cdo + gdal-bin + locale +
runtime apt deps) into git.mcintire.me/graham/prop-base. The base
workflow is path-filtered to fire only on Dockerfile.base /
build-base.yaml changes, so version bumps stay deterministic and
explicit.
Tag convention: `wgrib2-<v>-g2c-<v>-<unixts>` for traceability +
rollback, plus `:latest` for everyday consumption.
This commit only adds the new files — the app Dockerfile + build.yaml
still build wgrib2 from source. A follow-up will swap the app
Dockerfile to FROM prop-base:latest once this image lands in the
registry.
The flat path table forced you to scan column 1 to mentally group rows
when standing at a specific rover spot. Each rover location now gets
its own card with a heading (grid + lat/lon + notes) and an inner
station-by-station table, so the paths to the fixed stations from the
spot you're at are read in one place.
Sorted by latitude (north → south) so the same location keeps the same
slot across re-renders. `rover_label/1` is removed (its content now
lives in the group heading).
- add_station no longer seeds `"input" => ""` in params, so used_input?/1
treats the new row as untouched and hides the validate_required error
until the user types.
- Show page's format_distance/format_db/format_meters helpers coerce with
`* 1.0` because JSONB returns bare zeros as integers, which crashed
Float.round/2 (FunctionClauseError on /rover-planning/:id render).
- Lat/lon row uses items-start + mt-6 trash so the inputs stay aligned
even when the callsign cell grows from validation feedback.
The hidden 'false' shadow input was rendered after the checkbox, so when
both were submitted the 'false' value overwrote the checkbox 'true' in
the resulting params map. Move the hidden input before the checkbox so
the checked value wins (matching the core_components checkbox pattern).
Adds a full-screen map view rendering every Good rover-location as a
green circle marker (popup links to the detail page). Uses the standard
OSM/Satellite + Maidenhead grid overlay pattern. Bad locations are
excluded. Adds a Map button next to Add location on the index header.
Renames the rover-location status enum and every label that referenced
it. Existing rows are migrated in place. Also touches the consumers:
- Rover.Location enum + default
- RoverLocationsLive index, status filter, form, show, badges
- Rover.Compute and rover_live (only_ideal_locations → only_good_locations)
- RoverPlanning context candidate filter (status == :good)
- RoverPlanning.Show / Form copy
- All rover-related tests
Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
notes, "only check against known good locations" toggle (default on),
and a dynamic list of stationary stations entered as callsigns,
Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
matrix of computed path profiles (distance, min clearance, diffraction,
verdict) populated as the worker completes each pairing
After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.
Admins can edit/delete any mission; owners can edit their own.
Adds start/end date inputs (UTC) to the sidebar (mobile + desktop) with
Apply dates and Clear buttons. The hook keeps a sliced YYYY-MM-DD on
each line entry so the range check is a string compare during filter
re-application; combines with the callsign filter so e.g. AA5C between
2025-01-01 and 2026-01-01 is one query.
Bare-input phx-change wasn't reliably delivering the value to the
filter_callsign handler. Wrapping the input in a form with phx-submit
+ a Filter button gives explicit submission (Enter or click) on top
of the existing debounced live filter.
LocationMap hook now adds a togglable grid layer (matching the contact
map pattern) so the rover-location detail page shows the standard
Maidenhead overlay alongside the marker.
Rover.update_location/3 and delete_location/2 now resolve any record
when the actor is_admin. The index + show LiveViews surface Edit/Delete
on every row for admin users, matching the existing owner UX.
Each row now has a View link (and the location cell links too) to a
new show LiveView that renders the full record alongside a Leaflet
map centered on the marker. Owners get a Delete button there as well.
Adds a Maidenhead grid input to the rover-location form. Typing a grid
fills in lat/lon (using square center); editing lat/lon recomputes the
grid at 6-char precision. The phx-change `_target` decides direction so
edits never clobber what the user just typed.
Without the deferral the cron-driven ETS flush GenServer fires every
7.5s alongside Prometheus scrapes, calls PromEx.get_metrics in a Task,
and dies with `Task.await ... time out` (hardcoded 10s) under contention.
Mirrors upstream PromEx.Plug, which calls defer_ets_flush after a
successful render specifically to suppress the cron while scraping is
healthy.
- Extract PathParser.valid_callsign?/1 and reuse from the calibration
Mix task instead of duplicating the regex.
- Document raise behavior on Aprs.recent_packets_with_paths/1 and
Aprs.station_positions/1 (already noted in moduledoc; @doc reinforces
for callers reading the function head).
- Note in the BandConfig guard comment why it sits before the Oban pause
(so a misconfig exits without leaving queues paused).
- Reword the band-mismatched-negatives note as a v1 limitation (no TODO
tag — codebase convention is zero TODOs).
- Print '<computed> / <attempted>' for both positive (HRRR-coverage) and
negative (random-baseline → HRRR) factor compute stages so an operator
can see how much of the corpus survived each filter.
- Guard at task entry: Mix.raise if BandConfig has no 144 MHz entry.
Without it the Recalibrator silently falls back to 10 GHz physics.
- random_baseline now draws from sample_size: max(n, 5_000) so small-n
fits don't cluster geographically.
- Wrap body in try/after Oban.resume_all_queues so iex -S mix workflows
don't leave Oban paused after the task returns.
Pulls verified RF hops from aprs.me's 24h packet retention, computes
factor vectors at each hop's midpoint via the existing Recalibrator,
and runs gradient descent against random-baseline negatives.
Dry-run: prints proposed weights but does NOT mutate band_config.ex.
Refuses to fit on <50 positive or negative samples.
- decode_packet_row no longer guards Decimal.to_float / DateTime.from_naive
with passthrough clauses — Postgrex returns Decimal for numeric and
NaiveDateTime for timestamp without time zone, so the WHERE clause
filters out the only path that could have hit the alternate branches.
- recent_packets_with_paths shifts :since to UTC before to_naive so a
caller passing a non-UTC %DateTime{} doesn't silently match the wrong
window.
- moduledoc now documents that the module raises on connection/SQL
errors and is unsafe in async contexts without a catch.
Q-construct now matches the explicit APRS-IS set (qAC/qAX/qAU/qAo/qAO/
qAS/qAr/qAR/qAZ/qAI) instead of any qA[A-Za-z]. WIDE/TRACE n-N now allows
n and N up to 7 per the APRS New-N paradigm (was [12]).
Three spec-compliance fixes to PathParser:
1. Q-construct regex now accepts lowercase letters (qAo, qAr, etc.).
The moduledoc lists qAo as a Q-construct to skip but the previous
~r/^qA[A-Z]$/ rejected it, leaving it to be misclassified.
2. self_loop?/2 now compares callsigns only; the spurious
src_pos == dst_pos clause would falsely suppress two distinct
stations that happen to share coarse coordinates (e.g. same building).
3. Strengthen the "lookup returned nil → drop hop, do NOT advance source"
test: extend the path to three tokens so the assertion actually proves
the third hop chains from the previous good source rather than from
the dropped one. The two-token version passed regardless of behavior.
Self-loop fixture updated to trigger via callsign equality (the only
remaining mechanism) instead of position equality.
Pure-Elixir parser that walks an APRS digipeater path left-to-right and
emits verified hops for callsigns carrying the used flag (`*`). Filters
Q-constructs, TCPIP/TCPXX, and routing aliases (WIDE/TRACE/RELAY/etc.);
handles anonymous-digi cases, missing station-position lookups, and
self-loops without advancing source past unattributable hops.
Used by upcoming 144 MHz calibration to attribute aprs.me receives to
real digipeater geometry without persisting any APRS data on our side.
- Empty-string env var (k8s secret resolves to '') was passing the nil
check and handing Postgrex an empty URL — now treated the same as unset.
- Drop runtime.exs warning; keep the single warning in application.ex
where the supervisor decision is made.
Wire up Microwaveprop.AprsRepo as an optional secondary Ecto repo that
points at the sister aprs.me Postgres for read-only access. This is the
foundation for upcoming 144 MHz calibration work that uses verified APRS
RF hops as ground truth.
* lib/microwaveprop/aprs_repo.ex declares a read_only repo; no schemas,
no migrations, no writes — Microwaveprop never persists APRS data.
* Application supervisor starts the repo only when configured (dev/test
via config blocks, prod via APRS_DATABASE_URL). A missing config logs
a warning and skips startup so the main app still boots.
* config/dev.exs + config/test.exs add local aprsme_dev / aprsme_test
endpoints. Test pool uses Ecto SQL Sandbox; tests that don't touch
APRS data don't need to check the connection out.
* config/runtime.exs guards the prod block on APRS_DATABASE_URL —
unset env logs a warning and leaves the repo unconfigured.
/map's PropagationMap hook pushes `map_bounds` on every moveend/zoomend.
When a user live-navigates from /map to /weather, an in-flight bounds
push can land on the new WeatherMapLive (the same LV channel survives
the navigation), and WeatherMapLive's `handle_event/3` had no clause
for it — the LV process crashed with FunctionClauseError, taking the
user's session down.
WeatherMapLive doesn't need viewport bounds — the /weather/cells HTTP
endpoint reads them off the URL the client builds — so the safe fix is
a no-op handler. Tested.
HRDPS at 0.5° step previously dropped its lat=50 row inside HRRR's lon
range, so its first painted cell sat at lat=50.5 (halfStep=0.25 →
50.25°N). HRRR's top row at lat=50 paints to 50.0625°N. The ~0.19°
(~21 km) blank strip between them was visible on /weather wherever the
viewport crossed the US-Canada border.
Make in_conus_bbox exclusive on the north edge (lat < LAT_MAX) so the
boundary row stays in HRDPS. The merge_prefer_hrrr layer dedupes by
exact (lat, lon) on the merged read path, and the canvas overlay paints
HRRR above HRDPS, so the new overlap is invisible everywhere HRRR has
data.
Won't take effect on prod until the next HRDPS chain run produces fresh
chunk files.
Past minute 30 of the wall clock the next forecast hour is closer to
utc_now() than the current hour, so the picker would land on (e.g.)
19:00Z when the user is at 18:43Z. HRRR has 19:00Z, but the HRDPS
chain only seeds the *current* hour (18:00Z), so the dual-source map
showed an empty Canadian half whenever we crossed the half-hour mark.
Switch to "most-recent valid_time <= now", with a fallback to the
closest future when nothing on disk is yet at-or-before now (cold
start). Hoist the picker into a public function so it can be unit-
tested deterministically — anchoring the integration test on
DateTime.utc_now() truncated to the hour would have only verified the
new behavior past minute 30.
/weather-ca delegates to the same picker so the two maps agree on
"current."
The /weather dual-source timeline picks times from HRRR's hourly
cadence, but HRDPS publishes 4×/day with multi-hour latency, so the
requested time almost never has an exact HRDPS dir on disk. The
controller was returning empty rows for HRDPS, leaving the Canadian
half of the map blank.
Snap to the closest HRDPS valid_time within a 6h window. /weather-ca
keeps exact-time semantics in practice because its timeline is built
from HRDPS-only listings, so the nearest is always the same time.
/weather now fetches both grids and paints each as its own canvas
overlay at its native cell size (HRRR 0.125°, HRDPS 0.5°). Server-side
chunk filtering means a US-only viewport gets an empty HRDPS pack and
vice versa — no wasted payload. /weather-ca's HRDPS-only mode is
unchanged.
Hook state moves from single (pack/overlay/abort) to per-source maps
keyed by "hrrr"|"hrdps". Render order is fixed (hrdps first, hrrr
second) so HRRR's higher-res canvas stacks on top along the border
where both sources cover the viewport.
The canvas overlay hard-coded a 0.0625° half-step when projecting each
cell to canvas pixels — correct for HRRR (0.125° native grid) but wrong
for HRDPS, which we downsample to 0.5° (0.25° half-step). At HRRR's
half-step every HRDPS cell got painted at 1/16th its actual area, so
the overlay over Canada looked like a sparse pinstripe of vertical
streaks instead of contiguous coverage.
Plumb halfStep through the WeatherCanvasOverlay constructor and pick
0.25 vs 0.0625 in the hook based on data-source.
The hourly :35 fire was reliably being missed in production: the prop
deployment is pinned to a single replica, and frequent ArgoCD deploys
restart the pod often enough that some fires land in the swap window.
Between 13:35Z and 17:25Z today the cron fired exactly zero times.
Switch to :05/:25/:45. Each fire calls seed_current_hour which is
idempotent on the (run_time, forecast_hour, kind, source) unique
index, so duplicate inserts in the same hour are no-ops at the DB
level and don't add cost on the happy path. Three shots per hour
gives two retry windows when a deploy kills the pod mid-cron-tick.
The 1-hour valid_time filter inherited from /weather is wrong for
HRDPS: the model publishes 4x/day with 3-4h publication latency and
the chain step adds ~13 min on top, so the freshest data on disk is
routinely 4-6h old. With the 1h cutoff in place, /weather-ca's
timeline was empty whenever there wasn't an active chain step running,
which left the JS hook with nothing to fetch. The sidebar already
shows the selected valid_time prominently — users can see for
themselves how stale the data is and don't need the page to hide it.
Recalibrator and FrontalAnalysis use Nx for tensor math, but Nx is
declared `only: [:dev, :test]` in mix.exs because neither module has a
prod entry point — Recalibrator is invoked by hand from IEx and
FrontalAnalysis is research scaffolding. The prod compile would emit
"Nx.to_number/1 is undefined" warnings on every Nx call. Add a
`@compile {:no_warn_undefined, Nx}` directive to both modules so the
prod build is clean without dragging Nx/Axon/EXLA (~hundreds of MB) into
the runtime image.
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.
Also drops "— Unified" from the algo.md H1.
The HRDPS pipeline as written was unusable in production: each chain
step took ~5 hours (not 30-90s as the dev-bench claim) because every
`wgrib2 -lon` batch redundantly JPEG2000-decodes ~150 records. With 57
batches × 18 forecast hours, a single cycle would take days to drain.
The /weather Canadian view only needs the current hour, so the cheapest
fix is to stop seeding the rest of the chain entirely:
* Rust grid: HRDPS_STEP=0.5 (was 0.125) drops point count 16× to ~3.5k.
Coarser cells than HRRR, but /weather gets visible Canadian coverage
inside one chain step (~20 min) instead of never.
* GridTaskEnqueuer.seed_current_hour/2 inserts exactly one forecast row
whose valid_time is closest to `now`. fh is clamped to 1..18 so the
Rust F00Reserved branch never fires.
* HrdpsGridWorker switches to seed_current_hour and drops the
6-hour-cycle cron in favor of HH:35 hourly fires. Reseeds are
idempotent on the 4-column unique key.
Also fixes a pre-existing credo "nested too deep" in
ScalarFile.read_point by extracting lookup_in_chunk + hit_or_false
helpers — happened to be on the read path that surfaces this work, so
folding it into the same commit.
The prior Docker build re-compiled all 546 transitive Rust deps from
scratch on every push because target/ is intentionally not cache-mounted
(Stream C disk-pressure failures). Two changes drop the cold-build cost
substantially:
* cargo-chef splits dep compilation into a separate Docker layer keyed
on recipe.json (derived from Cargo.toml/Cargo.lock). Source-only
pushes — the common case — now reuse the cooked deps as a normal
layer instead of re-running cargo for every crate.
* clippy now runs in --release profile so it shares artifacts with
`cargo test --release` and `cargo build --release`. Previously clippy
ran in dev profile, forcing a full second compile of every dep before
the release build could start.
Verified `cargo clippy --release --all-targets -- -D warnings` is clean
locally — no new lints surface under the release profile.
wgrib2 3.8.0's CMakeLists.txt:93 calls find_package(g2c 2.3.0), so the
previous 2.1.0 pin makes both Dockerfiles error out with "Could not find
a configuration file for package g2c that is compatible with requested
version 2.3.0". Bumping to 2.3.0 across both the Elixir and Rust images
since their wgrib2-builder stages stay in lockstep.
Latest-version sweep across the Oban stack. Final state:
oban 2.22.1 (hexpm)
oban_pro 1.7.0 (vendored, latest in licensed repo)
oban_met 1.1.0 (vendored, latest in licensed repo and hexpm)
oban_web 2.12.3 (vendored from public hexpm)
oban_web was re-vendored via `mix hex.package fetch oban_web 2.12.3
--unpack` and dropped into vendor/oban_web. No local mods to
preserve — vendor commit history shows the dir was a vanilla
import.
Aligning vendoring style with the sibling towerops-web repo. They
have the same vendor/oban_pro + oban_met + oban_web setup but no
`override: true` on oban_web — because they don't have live_table.
We do (live_table requires `oban_web ~> 2.11` from hexpm), so the
override is load-bearing here. Comment captures the reason so a
future deps cleanup doesn't assume it's vestigial and remove it.
Previous commit (3c988a5f) switched oban_pro to the licensed hex
repo at deploy time. Reverting that — keep all three Pro packages
vendored so prod image builds don't depend on oban.pro reachability
or auth on every CI run.
oban_pro 1.7.0 dropped into vendor/oban_pro via
`mix hex.package fetch oban_pro 1.7.0 --repo=oban --unpack`. mix.exs
goes back to `path: "vendor/oban_pro"` (matching oban_met / oban_web,
which were already vendored — and stay vendored since the licensed
repo only has older versions of those: 0.1.11 / 2.10.6 vs the
1.1.0 / 2.12.1 we vendor).
Schema migration from 3c988a5f stays — 1.7.0 tables/indexes are
already applied. No code changes.
Oban Pro 1.7.0 ships database-backed workflow tracking, replaces the
generated `uniq_key` / `partition_key` columns with expression
indexes, and adds new partial indexes for query performance.
Switches oban_pro from the vendor/ tree to the licensed `oban` hex
repo (auth already configured via `mix hex.repo`). vendor/oban_pro
deleted — the vendoring was a 1.6.x stopgap, 1.7 is the first
version we land directly from hex.
oban_met (1.1.0) and oban_web (2.12.1) stay vendored — they're
not published to the licensed `oban` repo (verified by 404 on
`mix hex.package fetch`).
Migration 20260430142341 runs `Oban.Pro.Migration.up(version: "1.7.0")`
which creates the workflow tables and the new indexes. Verified on
dev + test DBs. Production is well below the size threshold the v1.7
guide flags for split-migration handling, so the inline default
suffices.
No code changes for the breaking-change list — the codebase only
references Oban.Pro.Engines.Smart and Oban.Pro.Plugins.DynamicLifeline,
both unchanged in 1.7. We don't use Workflow.after_cancelled/2 or
Oban.Pro.Workers.Chunk.
Test suite stays at 3132/3132.