Commit graph

740 commits

Author SHA1 Message Date
2f060b4371
feat(rover): penalize cells surrounded by tall buildings (clutter)
Adds a per-cell building-clutter penalty so the algorithm down-ranks
spots in built environments where buildings on multiple sides
scatter/block signal regardless of which station you're aiming at.

Penalty = max_height_within_75m / 5 dB, capped at 6 dB. Path-clearance
already accounts for buildings ON the link path; this is the
"surrounded by stuff" signal.
2026-04-26 11:13:31 -05:00
a290c99184
fix(buildings): use explicit step in descending range in encode_quadkey
zoom..1 without a step raises a runtime warning in Elixir 1.19 and was
causing the BulkFetch Task to crash immediately. Changed to zoom..1//-1.
2026-04-26 10:54:44 -05:00
0f15e99fb9
feat(buildings): integrate MS footprints into path clearance + map render
Phase 2/3/5 of the building-blockage support:

* Parser streams csv.gz tiles into compact records (centroid, radius,
  height) — keeps RAM ~32 B per polygon, drops -1.0-height entries.
* Index buckets records into 0.01° (~1 km) grid cells in a public ETS
  table for concurrent reads; max_height_near/3 and records_near/3
  scan only the 9 nearest buckets.
* Loader lazily parses any cached quadkey before a Calculate so the
  scorer is terrain-only when tiles aren't on disk yet.
* Rover.PathTerrain now treats each path-sample obstacle as
  terrain_elev + max_local_building_height, so blocked LOS through
  recent construction actually reduces clearance.
* Selecting a candidate pushes a candidate_buildings event with the
  buildings near each rover→station path; the hook renders them as
  height-coloured circles (yellow <10 m, orange 10-30 m, red >=30 m)
  with a tooltip showing height in meters.
2026-04-26 10:44:46 -05:00
021608dc90
feat(buildings): bulk-prefetch tiles via BulkFetch.run/4 (works in prod)
Mix tasks don't run in production, so the orchestration lives in
Microwaveprop.Buildings.BulkFetch — callable from an attached iex on
the prod pod via:

    iex> Microwaveprop.Buildings.BulkFetch.run(32.8, -97.0, 500)

Default 500 mi radius around DFW yields ~440 candidate quadkeys at
zoom 9; only those present in the MS dataset index (typically a few
hundred over CONUS) get downloaded. Concurrency 4, per-task timeout
3 min, results logged with downloaded/cached/failed/missing counts.
The mix.task is kept as a dev-only thin wrapper.
2026-04-26 10:37:13 -05:00
5a97776fd5
feat(buildings): MS Global ML Building Footprints quadkey + tile fetcher
Phase 1 of building-blockage support: implements quadkey math (Bing/MS
tile encoding), looks up the MS dataset-links index for UnitedStates,
and downloads csv.gz quadkey tiles to /data/buildings (overridable via
:buildings_cache_dir). Index is cached 24h; tiles are written once and
reused. No path-analysis wiring yet — that's the next slice.
2026-04-26 10:33:25 -05:00
2f46d49016
feat(rover): clear selected candidate + path lines on recalculate 2026-04-26 09:56:55 -05:00
3b1a13b0f7
feat(rover): make candidate strip scroll horizontally + wheel-to-h-scroll 2026-04-26 09:31:10 -05:00
12e0106b7b
feat(rover): drop drive-time from candidate cards and detail header 2026-04-26 09:29:27 -05:00
af2ab3c969
fix(rover): bound Overpass with hard 8s deadline + 6h ETS cache by bbox
Prod logs showed road_proximity took 186 s on a single Calculate.
Req's receive_timeout only catches TCP idle, not slow streaming, so a
trickling Overpass response could not be cancelled. Wrap the call in a
Task.yield/shutdown deadline and cache successful results by 0.05°-rounded
bbox so adjacent Calculates skip Overpass entirely.
2026-04-26 09:25:08 -05:00
7d80babb18
feat(rover): snap top candidates to SRTM hilltops + add hillshade overlay 2026-04-26 09:12:24 -05:00
2f3500515a
feat(rover): overlay LOS line + label profile rover→station endpoints 2026-04-26 09:02:07 -05:00
0d2cfb68f1
feat(rover): show aim heading + path lines for each fixed station
- Detail panel now reads as an aiming card: "Aim 220° (SW) · clearance +12 ft"
  with the precise bearing in degrees, plus rover/obstacle elev on a second line
- Elevation profile labels swapped from min/max-of-profile to start (rover) and
  end (station) elevations, since their positions in the chart now match
- Map draws a dashed indigo polyline from the candidate to each selected
  station when a candidate is opened; cleared when the panel closes
2026-04-25 18:10:15 -05:00
4bfcff4e32
perf(rover): tighten Overpass timeout and log per-step compute timing
Calculate occasionally hung in production for ~30s. Adds per-phase
timing logs to Compute.run so the slow step is visible in pod logs,
and trims the Overpass road-proximity request to a 10s server-side
timeout / 12s receive timeout (no Req retries) so a slow road API
fails soft to no road penalty instead of stalling Calculate.
2026-04-25 18:05:46 -05:00
10cbd8dd48
fix(remote_ip): log IPv6 in canonical hex via :inet.ntoa
IPv6 segments were being printed in decimal (e.g. 10772:1985:1024:45::1
for 2a14:7c1:400:2d::1), which made request_id logs unreadable.
2026-04-25 18:05:46 -05:00
eac817cd57
feat(rover): prefer broad hilltops near roads as ideal candidates
- Local prominence: each cell's elevation vs. its 8-neighbor ring
  (~1.3 km radius) feeds a small additive bonus so broad hilltops
  rank above isolated SRTM voxels
- Road proximity: one Overpass call per Calculate fetches drivable
  ways inside the bbox; cells beyond ~0.5 km of any road get a
  light dB penalty, gated off in tests
2026-04-25 17:45:48 -05:00
4611926b8d
feat(rover): per-station detail panel with elevation profile + 'Ideal locations' label 2026-04-25 17:41:48 -05:00
6462acffc6
feat(rover): default 50mi max distance and frame map to that radius 2026-04-25 17:37:17 -05:00
40cbb40cb1
feat(rover): score per-station terrain clearance from SRTM path samples
For each (rover cell, fixed station) pair, sample SRTM along the
great-circle path and add a per-link dB bonus proportional to the
rover's elevation above the highest intermediate terrain. Rover spots
on hilltops with clear sight to multiple stations now rise to the top.

Also vendor Leaflet's layers control PNGs and copy them under
priv/static/assets/css/images/ on build so /rover stops 404'ing on
layers-2x.png.
2026-04-25 17:29:29 -05:00
9178751bac
fix(rover): push stations_updated to map hook on add/edit/toggle/delete 2026-04-25 17:22:51 -05:00
915cd1f5a0
feat(rover): replace max-drive-time slider with max-distance in miles 2026-04-25 17:21:57 -05:00
5631444abd
feat(rover): auto-resolve grid from callsign on add; inline-edit grid per row; mi/ft units 2026-04-25 17:14:32 -05:00
9bfd345e8d
feat(accounts): derive 10-char home_grid from explicit lat/lon 2026-04-25 17:04:45 -05:00
9796f06ba2
feat(accounts): auto-prefill home QTH from QRZ on register + backfill on boot 2026-04-25 17:03:01 -05:00
b1fe077863
fix(path): single grid decode + zero-downtime rust-worker rollouts 2026-04-25 16:57:00 -05:00
771299c951
feat(accounts): add Home QTH section to user settings page 2026-04-25 16:51:54 -05:00
e2dfc05939
fix(rover): keep cells without elevation; surface diagnostic warnings as flash 2026-04-25 16:41:31 -05:00
9ebb134a03
feat(rover): home accepts grid/callsign/address; station markers labeled; imperial scale 2026-04-25 16:40:03 -05:00
c5d6633beb
feat(rover): URL station persistence + 10-digit grids on calculated values 2026-04-25 16:36:34 -05:00
6f25993998
fix(rover): wire delete X button to actually remove station rows 2026-04-25 16:32:10 -05:00
10fcc764f6
feat(rover): live slider labels via RoverSlider hook for forecast hour + drive time 2026-04-25 16:30:38 -05:00
db1539086b
feat(rover): drop Min Elev Gain section, link 'Sign in to save' to log-in 2026-04-25 16:28:32 -05:00
c13a5f53be
feat(rover): full-bleed layout matching /map and /weather; remove Mode selector 2026-04-25 16:26:33 -05:00
7c506a6453
feat(rover): redesigned LiveView with right-docked sidebar, Calculate flow, smoothed contours 2026-04-25 16:26:33 -05:00
448d3636a1
feat(rover): Oban worker enriches station elevation post-insert 2026-04-25 16:26:33 -05:00
2e462b0697
feat(rover): end-to-end Calculate pipeline 2026-04-25 16:26:33 -05:00
d80ca24e2e
feat(rover): bulk elevation lookup wrapper 2026-04-25 16:26:33 -05:00
25d07c0d42
feat(rover): pure scoring math (link margin, aggregator, drive time) 2026-04-25 16:26:33 -05:00
18f04a4345
feat(rover): user-scoped FixedStation context with ownership guard 2026-04-25 16:26:33 -05:00
21d3adfd2b
feat(rover): FixedStation schema with grid-derived lat/lon 2026-04-25 16:26:33 -05:00
ce9319d43b
feat(user-settings): mark beacon monitors as not yet working
The monitor client and ingestion pipeline aren't shipped, so warn
users that registering a monitor here doesn't do anything yet.
2026-04-25 16:03:34 -05:00
5932a5e4f9
feat(accounts): add home QTH fields to User 2026-04-25 16:02:43 -05:00
cf7dec79cf
feat(rover): add ModeThresholds for SSB/CW/Q65 SNR table 2026-04-25 15:59:52 -05:00
418f6426e6
fix(path): handle ProfilesFile cell shape correctly + log async exits
The /path calculator showed "0 / 9 HRRR points" in production despite
the on-disk profile store being current. Root cause: profile_from_cell/2
treated the cell's :profile key as a wrapper sub-map and called
Map.put_new on it — but the :profile key actually holds the vertical
pressure-level LIST. Every point sample crashed with BadMapError, the
crash propagated as {:exit, _} through Task.async_stream, and the
consumer silently dropped all 9 results.

Fix: stop wrapping. Cells are already flat HrrrProfile-shaped maps;
just stamp lat/lon (from the caller, since cells don't carry their
own coords — those are the map key) and valid_time onto the cell.

Audit + log every other async error path so the next silent failure
isn't invisible:
- PathLive HRRR point lookup
- Propagation.point_forecast per-hour reads
- Viewshed ray crashes
- IemClient ASOS network fetches
- RtmaClient range-download tasks
- Recalibrator factor-vector batches (positive + negative samples)
- MapLive forecast preload tasks
- RoverLive station resolution

LiveViews already had handle_async/3 exit clauses with logging. The
gap was always in Task.async_stream consumers that wrote {:exit, _} -> []
without surfacing the reason.

Add the rule to CLAUDE.md and project memory so this never repeats.

Also fix a pre-existing skewt_svg.ex compiler warning where
@critical_label_min_dy was used before being defined.
2026-04-25 15:57:20 -05:00
6967614dfb
fix(skewt): stack overlapping critical-level labels + widen rail
LCL/LFC and 0 °C/WBZ landed within ~5 px of each other on the right-
edge label rail and stomped on each other's text. Sort by y, bump
each subsequent label down by at least 14 px from the previous one,
and draw a thin leader from the true tick to the bumped baseline so
nothing reads as misaligned.

Also widened the SVG from 720 → 820 user units so longer entries
like `LCL 925 mb` no longer get clipped by the right edge.
2026-04-25 15:33:19 -05:00
2fc434df56
feat(path): hover-on-dot factor breakdown + tag fallback factors
The 18-hour forecast chart on /path now shows the same weighted-
criteria panel the /map popup builds when the user clicks a grid
cell. Hovering a dot fires a server roundtrip that fetches
Propagation.point_detail at the path's midpoint for that valid_time;
results are cached client-side so re-hovering is instant.

`Propagation.point_detail/4` now returns a `:profile_source` tag —
`:exact`, `{:fallback, fallback_valid_time}`, or `:unavailable` —
distinguishing real per-hour breakdowns (Rust pipeline writes a
profile file for every f00..f18 step) from rare cycle-miss fallbacks
where we approximate from a recent analysis profile. Stale comment
about "only f00 persists profiles" updated.

Map utilities (FACTOR_META, FACTOR_ORDER, factorBar,
factorExplanation, scoreTier, FactorMeta, ScoreTier) are now exported
from propagation_map_hook so the new path hook reuses the exact same
look + copy.
2026-04-25 15:33:19 -05:00
617022668e
chore(layout): move deploy stamp from navbar to footer
Free up navbar space and let every viewport see the timestamp (the
old `hidden md:inline` hid it on phones). Now shows relative + full
UTC inline.
2026-04-25 15:20:55 -05:00
2e60e65e2f
fix(path): read HRRR points from on-disk profile store
The summary card's "0 / 9 points" came from `Weather.find_nearest_hrrr`
only checking the `hrrr_profiles` DB table, which is populated solely
by per-QSO enrichment within ±0.07° / ±1 h. Arbitrary paths off any
recently-enriched contact got nothing.

Switch to ProfilesFile.read_point as the primary source — the hourly
chain publishes full-CONUS f00..f18 cells there — and keep the
DB-table query as the fallback when the on-disk store is empty or
doesn't cover a point. Cell shape is flattened so the template and
build_scoring/6 stay shape-agnostic.
2026-04-25 15:19:34 -05:00
332308b9c3
feat(skewt): fetch full 25-level profile and drop valid_time URL param
The grid-cached profile is trimmed to 13 levels (1000→700 mb) for
memory reasons, so the skew-T plot showed nothing above 700 mb.
HrrrClient.fetch_profile/4 now accepts a forecast_hour opt; SkewtLive
issues a per-point fetch against the same cycle the cached row came
from to get the full 1000→100 mb set, falling back to the cached
truncated cell on any failure.

Time selection no longer round-trips through the URL — `select_time`
updates state in-process and triggers a focused :load_skewt_time
async, so refreshing or sharing a /skewt?q=... link always lands on
the most recent analysis hour.
2026-04-25 15:19:34 -05:00
3a81d05b4e
perf(skewt): load profile + sounding asynchronously via start_async
URL-parameter loads (`/skewt?q=…`) were blocking the initial mount
on three serial steps: geocoder/grid resolve, ProfilesFile or
HrrrProfileLookup read, and the SkewtParams/SoundingParams/SkewtSvg
derivation. The address path in particular hits an external API,
which made the page take "forever" to load on first paint.

Restructure SkewtLive so the page chrome (header, search bar) renders
synchronously on mount, and the heavy work runs in a single
`start_async(:load_skewt, …)` task that calls SkewtLocationResolver,
walks the on-disk profile store with a DB fallback, and produces the
SVG. A new `loading?` assign drives a small spinner + "Resolving
location and loading HRRR profile…" message that shows while the task
is in flight; previous data stays on screen so navigation between
forecast hours doesn't blank out the diagram.

handle_async/3 covers three landings:

  * `{:ok, {:ok, result}}` — apply assigns from the resolver
  * `{:ok, {:error, reason}}` — surface as the existing error alert
    (geocoder failure, unknown callsign, etc.)
  * `{:exit, reason}` — log a warning and show a generic retry message

start_async/3 cancels any prior task with the same name on each call,
so a rapid-fire URL patch (e.g. clicking through forecast-hour
buttons before the first response returns) never lands stale data.

Tests: extended SkewtLiveTest from 3 to 4 cases — the URL-param
initial render now asserts the chrome + spinner appear pre-async,
and a separate case `render_async/2`s the view to confirm the
location label arrives once the task completes. 4/4 green.
2026-04-25 14:36:30 -05:00
77bc894f56
feat(skewt): add wet-bulb / virtual-T traces and right-edge level rail
Three additions that bring the diagram closer to the SPC sounding
viewer's full-information chart:

  * Wet-bulb temperature trace, plotted as a thinner blue line under
    the dewpoint trace. Comes from a Stipanuk-style bracket between
    the level's dewpoint and dry-bulb temperatures (already used by
    `SkewtParams` for the wet-bulb-zero level — now public so the
    renderer can call it per level).

  * Environment virtual-temperature trace and parcel virtual-T
    trajectory, both as thin pinkish dashed lines. The parcel
    virtual-T is the actual buoyancy curve CAPE integrates against,
    so showing it next to the dry-bulb parcel ascent makes the
    moisture correction visible. New `SkewtParams.virtual_temp_c/2`
    and `/3` helpers, plus `SkewtParams.mixing_ratio/2`.

  * Critical-level rail on the right edge. Each of LCL / LFC / EL /
    0 °C / WBZ that resolves on the current profile gets a short
    horizontal tick at the right of the plot and a `<label> <p> mb`
    tag just outside it — matches the SPC chart's `143 mb (mix)` /
    `158 mb (mix)` annotation column. SkewtLive computes the
    pressures (and converts the height-keyed 0 °C and WBZ from
    SkewtParams back to pressure via `pressure_at_height/2`) before
    handing them to `SkewtSvg.render/2`.

Wind-derived overlays (hodograph, wind barbs) remain off — HRRR
profile data is wind-free above 10 m AGL.

mix test: 27 / 27 across the four /skewt-related test files,
2,902 / 2,902 + 221 properties on the full suite. mix credo --strict
clean (one cyclomatic-complexity refactor extracted along the way).
2026-04-25 14:24:28 -05:00