Skip to content

feat: /radar — NEXRAD loops with storm-scale zoom, rotation/TVS detection, and PTDS debris-signature scoring#615

Merged
full-bars merged 14 commits into
mainfrom
feat/radar-cli
Jul 22, 2026
Merged

feat: /radar — NEXRAD loops with storm-scale zoom, rotation/TVS detection, and PTDS debris-signature scoring#615
full-bars merged 14 commits into
mainfrom
feat/radar-cli

Conversation

@full-bars

@full-bars full-bars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Summary

  • New /radar command renders animated NEXRAD Level II loops (reflectivity, velocity, ZDR,
    CC, KDP, PHIDP, spectrum width) via BowEcho's Rust crates, with a zoom option
    (Storm-Scale ~75km through Full Range ~460km) using proper viewport rendering instead of
    a fixed full-range disk — makes couplets and debris signatures actually resolvable.
  • Adds always-on mesocyclone/TVS rotation detection: colored markers on the image, a
    loop-wide peak Vrot/ΔV summary (not just the last frame), and site autocomplete matching
    on both ICAO code and city name.
  • Adds PTDS (Probability of Tornadic Debris Signature), a new product combining
    reflectivity/CC/ZDR into a 0–100% debris-signature score, sampled at each scan's
    strongest couplet and reported scan-by-scan. Explicitly marked experimental in the
    Discord output itself (footer + inline disclaimer) — not validated against a case archive.
  • Fixes along the way: GIF frames were playing out of chronological order; TDWR/SAILS
    volumes' repeated low-tilt rescans were starving the rotation detector of the vertical
    diversity it needs; markers/geography were ~1% misaligned from the actual radar pixels;
    several legibility bugs (unlabeled compass direction, no backing panel behind labels).
  • Credits BowEcho (Fahrenheit Research, MIT/Apache-2.0) in CREDITS.md and README.

Test plan

  • cargo fmt --all -- --check and cargo clippy --all-targets --all-features -- -D warnings clean across the workspace
  • ruff check/ruff format --check clean on cogs/radar/
  • Full test suite passes (560 tests)
  • Existing radar tests pass (test_radar_views.py, test_radar_cleanup.py, test_radar_downloads.py)
  • Manually verified against live NEXRAD/TDWR data across multiple sites (KTLX, KPBZ, TPIT,
    KLWX, KRLX), including real-time testing against an actual tornado near KPBZ/TPIT
  • Deployed and running on 3cape throughout development
  • No automated test coverage yet for the new Rust rendering/detection code or the new
    Python cog paths (autocomplete, PTDS formatting, rotation embed) — flagging honestly,
    not blocking

Summary by CodeRabbit

  • New Features
    • Added animated NEXRAD radar GIF generation for /radar, including site autocomplete, selectable products/moments, zoom-based range control, and configurable frame counts.
    • Added optional rotation & debris signature overlays with experimental PTDS indicators and improved embed details.
    • Added thread-based autoposting for sounding and Mesoscale Discussion AI summaries, with graceful fallback when threads can’t be created.
  • Documentation
    • Added a full Code of Conduct and updated credits/tech references, including BowEcho attribution.
  • Bug Fixes
    • Pinned beautifulsoup4 to prevent import errors during ACARS-related functionality.
  • Chores
    • Added radar map assets, improved radar GIF cache handling/cleanup, and updated ignore rules.

full-bars added 10 commits July 21, 2026 02:48
Creates a new rust crate (radar_gif) that fetches NEXRAD Level II volumes
from the public AWS S3 archive, decodes and renders them using BowEcho's
nexrad_io and render2d crates, and outputs animated GIFs. The Python
cog (cogs/radar.py) exposes a /radar slash command with site, product,
and frames parameters.
The radar renderer was hardcoded to a fixed 460km range mapped onto an
800x800 canvas, making mesocyclone couplets and tornadic debris
signatures impossible to resolve. Switched to render2d's viewport
rendering API with a configurable --range-km flag and a Discord zoom
choice (Storm-Scale/Regional/Wide/Full Range), and applied
smooth_moment_grid() to fix the streaky/gapped look at closer zoom.

Also fixes a compass rose typo (W was showing as F) and adds a
backing panel behind the timestamp/site label so it stays legible
over storm echoes. State outlines, city labels, and range rings now
scale correctly with the selected zoom level.
…oring for /radar

Adds BowEcho's operational rotation-detection algorithm as an always-on overlay
on /radar loops, with per-frame markers and a loop-wide peak Vrot/ΔV summary
(fixes a bug where only the last frame's detections were ever reported, hiding
real rotation that had already weakened by the end of a loop).

Also adds PTDS, a new /radar product combining reflectivity, CC, and ZDR into a
single debris-signature confidence score, sampled at each scan's strongest
couplet and reported scan-by-scan. PTDS is explicitly marked experimental in
the Discord output itself.

Fixes along the way: GIF frames were not chronologically sorted; TDWR/SAILS
volumes rescan the same low elevation multiple times, which was starving the
rotation detector of the vertical tilt diversity it needs; rotation markers
and geography were misaligned from the actual radar pixel data by about 1%;
marker and corner labels had no backing panel and were unreadable over bright
echoes; the compass rose mislabeled west as "F".
Rotation markers now get full ring+label treatment only for the top 6
sites by Vrot (matching the embed's cap); weaker circulations beyond
that draw as small unlabeled dots instead of piling up overlapping
rings and labels at storm-scale zoom.

Also adds autocomplete on the site parameter, matching against both
the 4-letter code and city name from the existing site tables.
No behavior changes — reformatting plus is_none_or/lifetime-elision/
too-many-arguments fixes so the crate passes CI's fmt/clippy gates.
Dead code — superseded by the Rust imageproc-based state outline
rendering in radar_gif, never used at runtime, and the Cartopy
pre-render approach it implements was abandoned for being both too
slow on the ARM server and low quality.
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ebc1b14e-a331-41a9-9a2e-caa08b6e6943

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Rust-based NEXRAD radar GIF renderer with PTDS/TDS and rotation overlays, integrates it into /radar, adds geographic assets and caching, and updates project documentation, credits, workspace metadata, dependency declarations, and governance files.

Changes

Radar GIF rendering

Layer / File(s) Summary
Workspace and renderer inputs
Cargo.toml, crates/radar_gif/Cargo.toml, crates/radar_gif/assets/..., crates/radar_gif/src/main.rs, .gitignore
Registers the Rust workspace and renderer dependencies, embeds geographic assets, defines CLI inputs, and fetches realtime and archived radar volumes.
Radar rendering and derived products
crates/radar_gif/src/main.rs
Renders radar frames with geographic and city overlays, zoom-dependent ranges, PTDS/TDS products, and optional rotation detection.
GIF and rotation output
crates/radar_gif/src/main.rs
Encodes looping GIFs and writes rotation detections, peak values, and TDS timelines to a JSON sidecar.
Discord radar command integration
cogs/radar/__init__.py, CREDITS.md, README.md
Validates sites, invokes the renderer, formats metadata, manages cache cleanup, and handles generation, timeout, size, and upload failures.

Project metadata and governance

Layer / File(s) Summary
Release, dependency, and project documentation
CHANGELOG.md, requirements.txt, CODE_OF_CONDUCT.md
Documents radar and AI autopost changes, pins beautifulsoup4, and adds contributor conduct and reporting procedures.
Repository housekeeping
.gitignore
Adds the radar asset JSON exception and .wiki/ to ignored paths.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant DiscordUser
  participant RadarCommand
  participant radar_gif
  participant RadarData
  participant Discord
  DiscordUser->>RadarCommand: submit /radar request
  RadarCommand->>radar_gif: run site, product, frames, zoom, rotation
  radar_gif->>RadarData: fetch realtime and archived volumes
  RadarData-->>radar_gif: return radar volumes
  radar_gif-->>RadarCommand: write GIF and rotation sidecar
  RadarCommand->>Discord: upload embed and generated GIF
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.97% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main /radar feature additions: zoom controls, rotation/TVS detection, and PTDS scoring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/radar-cli

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (1)
crates/radar_gif/src/main.rs (1)

1207-1217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

encode_gif panics via .expect() instead of the file's usual graceful-exit pattern.

Every other fallible step in main (decode/download/detect failures) is handled via eprintln! + std::process::exit(1), but encode_gif uses four .expect() calls that will panic (exit code 101, stack unwind message on stderr) on file-creation or encoding failure. Functionally this still surfaces as a non-zero exit to the calling Discord cog (context snippet 2 checks proc.returncode != 0), so it's not a correctness bug, just an inconsistency in error-handling style.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/radar_gif/src/main.rs` around lines 1207 - 1217, Update encode_gif to
follow main’s graceful error-handling pattern instead of panicking through its
four expect calls: report file-creation, repeat-configuration, and
frame-encoding failures with eprintln! and terminate via std::process::exit(1),
while preserving the existing GIF encoding flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.wiki:
- Line 1: Add a .gitmodules configuration entry mapping the existing .wiki
gitlink to its repository URL, using the appropriate submodule path and matching
the repository’s expected wiki remote so git submodule update --init can
initialize it.

In `@CHANGELOG.md`:
- Line 11: Update the NEXRAD Radar Loops product list in the changelog to
include spectrum width alongside the existing supported products, using the
project’s established naming style.

In `@cogs/radar/__init__.py`:
- Around line 565-569: Update the subprocess cleanup around proc.communicate()
to handle both asyncio.TimeoutError and task cancellation: kill the renderer,
then await proc.communicate() or proc.wait() to reap it before re-raising the
original timeout or cancellation. Preserve the existing timeout error message
and ensure cleanup itself does not mask the triggering exception.
- Around line 438-445: Update the render flow around _run_radar_cli so each
request writes to a unique temporary GIF path based on an interaction ID or
UUID, rather than directly to the shared RADAR_GIF_CACHE path. After rendering
completes, atomically publish the temporary GIF and corresponding .rotation.json
metadata to the shared cache, preserving cache reuse while preventing concurrent
requests from interleaving writes.
- Line 184: Update periodic_cleanup to include RADAR_GIF_CACHE alongside
OUTPUT_DIR when removing expired files, ensuring both rendered GIFs and rotation
sidecars are cleaned up while preserving the existing cleanup behavior.

In `@crates/radar_gif/src/main.rs`:
- Around line 87-132: Deduplicate collected radar volumes by volume_time before
final playback ordering. In the volume assembly flow around volumes and the
existing sort_by_key call, sort by v.volume_time first, then remove adjacent
duplicate timestamps with dedup_by_key, preserving the current oldest-to-newest
ordering.
- Around line 169-190: Decouple the per-volume detection flow from display
rendering: in the loop around build_moment_cache and render_moment_rgba_into,
treat unavailable display moments or invalid image buffers as visual-frame skips
only, then continue into the existing rotation/PTDS block for every volume.
Ensure detection still uses the independent rotation velocity and
lowest_dual_pol_cut data and updates all_detections and tds_timeline even when
no radar_img is produced.
- Around line 76-81: Update the site coordinate lookup in the main
radar-processing flow to emit an eprintln! warning when site_coords lacks the
requested site before using the existing default coordinates. Preserve the
current fallback coordinates and all downstream geo_to_pixel calculations for
recognized and unrecognized sites.

---

Nitpick comments:
In `@crates/radar_gif/src/main.rs`:
- Around line 1207-1217: Update encode_gif to follow main’s graceful
error-handling pattern instead of panicking through its four expect calls:
report file-creation, repeat-configuration, and frame-encoding failures with
eprintln! and terminate via std::process::exit(1), while preserving the existing
GIF encoding flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8de84e26-bbc6-453d-a441-0562ad7a9911

📥 Commits

Reviewing files that changed from the base of the PR and between 6c3662e and 163d5ea.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • crates/radar_gif/assets/fonts/DejaVuSans.ttf is excluded by !**/*.ttf
  • crates/radar_gif/assets/geo/nexrad_sites.csv is excluded by !**/*.csv
📒 Files selected for processing (12)
  • .gitignore
  • .wiki
  • CHANGELOG.md
  • CODE_OF_CONDUCT.md
  • CREDITS.md
  • Cargo.toml
  • README.md
  • cogs/radar/__init__.py
  • crates/radar_gif/Cargo.toml
  • crates/radar_gif/assets/geo/us_states_110m.json
  • crates/radar_gif/assets/geo/us_states_50m.json
  • crates/radar_gif/src/main.rs

Comment thread .wiki Outdated
@@ -0,0 +1 @@
Subproject commit 869e0f950b9d4f8d6caf7f1ef2edd3e6c0de2431

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git ls-tree HEAD .wiki
git -C .wiki rev-parse HEAD
git -C .wiki cat-file -e 869e0f950b9d4f8d6caf7f1ef2edd3e6c0de2431^{commit}
git -C .wiki show --stat --oneline 869e0f950b9d4f8d6caf7f1ef2edd3e6c0de2431

Repository: full-bars/spc-bot

Length of output: 433


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- .gitmodules ---\n'
if [ -f .gitmodules ]; then
  cat -n .gitmodules
else
  echo 'missing'
fi

printf '\n--- submodule status ---\n'
git submodule status --recursive || true

printf '\n--- .wiki git metadata ---\n'
git -C .wiki rev-parse --git-dir
git -C .wiki config --get remote.origin.url || true
git -C .wiki config --get remote.origin.fetch || true
git -C .wiki status --short --branch || true

Repository: full-bars/spc-bot

Length of output: 246


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- .gitmodules ---'
if [ -f .gitmodules ]; then
  cat -n .gitmodules
else
  echo 'missing'
fi

echo
echo '--- submodule status ---'
git submodule status --recursive || true

echo
echo '--- .wiki git metadata ---'
git -C .wiki rev-parse --git-dir
git -C .wiki config --get remote.origin.url || true
git -C .wiki config --get remote.origin.fetch || true
git -C .wiki status --short --branch || true

Repository: full-bars/spc-bot

Length of output: 430


Add the .gitmodules entry for .wiki

The tree contains a gitlink at .wiki, but there’s no .gitmodules mapping for it, so git submodule update --init can’t initialize the wiki checkout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.wiki at line 1, Add a .gitmodules configuration entry mapping the existing
.wiki gitlink to its repository URL, using the appropriate submodule path and
matching the repository’s expected wiki remote so git submodule update --init
can initialize it.

Comment thread CHANGELOG.md Outdated
Comment thread cogs/radar/__init__.py
Comment thread cogs/radar/__init__.py
Comment thread cogs/radar/__init__.py
Comment thread crates/radar_gif/src/main.rs Outdated
Comment thread crates/radar_gif/src/main.rs
Comment on lines +169 to +190
for volume in &volumes {
let cache = match build_moment_cache(volume, is_ptds, &moment, &color_tables) {
Some(c) => c,
None => {
eprintln!(" Skipping volume: product unavailable");
continue;
}
};
let mut pixels = vec![0u8; viewport_rgba_buffer_len(viewport_options)];
let radar_img = match cache.render_moment_rgba_into(volume, viewport_options, &mut pixels) {
Ok((rw, rh)) => match RgbaImage::from_raw(rw, rh, pixels) {
Some(img) => img,
None => {
eprintln!(" Skipping volume: buffer size mismatch");
continue;
}
},
Err(e) => {
eprintln!(" Skipping volume: {e}");
continue;
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Rotation/PTDS detection is skipped entirely when the display product's moment is unavailable for a scan.

build_moment_cache failing (e.g. requested zdr/kdp/phidp missing on a cut) causes continue at Line 174/183/188, which bypasses the whole rest of the loop body — including the if args.rotation { ... } block (346-424) that performs mesocyclone/TVS detection and PTDS/TDS sampling. Since rotation velocity data and the TDS dual-pol cut (lowest_dual_pol_cut) are independent of the user's selected display moment, a single unavailable display-moment scan silently drops that scan from all_detections/tds_timeline, degrading the accuracy of the loop-wide peak Vrot/ΔV summary and PTDS timeline described as a core feature of this PR — with no warning to the user beyond a generic "Skipping volume" line.

Consider decoupling: run rotation/TDS detection unconditionally per volume (independent of build_moment_cache), and only skip the visual frame when the display moment is unavailable.

🐛 Sketch of decoupling rotation/TDS from moment-cache availability
     for volume in &volumes {
+        if args.rotation {
+            // rotation/TDS detection independent of the selected display moment
+            let deduped = dedup_repeated_tilts(volume);
+            let sites = detect_rotation_sites(&deduped);
+            // ... TDS sampling + all_detections/tds_timeline bookkeeping ...
+        }
         let cache = match build_moment_cache(volume, is_ptds, &moment, &color_tables) {
             Some(c) => c,
             None => {
                 eprintln!("  Skipping volume: product unavailable");
                 continue;
             }
         };
-        ...
-        if args.rotation {
-            // (moved above)
-        }

Also applies to: 346-424

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/radar_gif/src/main.rs` around lines 169 - 190, Decouple the per-volume
detection flow from display rendering: in the loop around build_moment_cache and
render_moment_rgba_into, treat unavailable display moments or invalid image
buffers as visual-frame skips only, then continue into the existing
rotation/PTDS block for every volume. Ensure detection still uses the
independent rotation velocity and lowest_dual_pol_cut data and updates
all_detections and tds_timeline even when no radar_img is produced.

…ing, dedup)

- periodic_cleanup now also prunes RADAR_GIF_CACHE, which was never
  cleaned and grew unbounded (only /download's OUTPUT_DIR was covered).
- Output paths are now unique per interaction. They were never actually
  reused as a cache (every call regenerates), so the shared filename
  was pure collision risk between concurrent requests, not a cache hit.
- The renderer subprocess is now awaited after kill() on timeout/
  cancellation instead of just signaled, avoiding leaked zombie
  processes on repeated timeouts.
- Realtime and archive-fill volumes are deduplicated by timestamp
  before sorting, since the archive's most recent object can overlap
  the already-fetched realtime volume and produce a visually "stuck"
  duplicate frame.
- An unrecognized site now logs a warning instead of silently
  substituting a default lat/lon (Python-side validation already
  blocks this in practice, but the CLI had no diagnostic of its own).
- CHANGELOG's product list was missing spectrum width.
Broken pointer left over from an unfinished attempt to embed the wiki
as a submodule -- no .gitmodules entry existed to resolve it, so it
was just dead weight in the tree. Doesn't touch the actual GitHub
wiki. Also gitignored .wiki/ so a local wiki checkout doesn't
reintroduce this.
sounderpy's acars_data module imports bs4 directly but never declares
it, relying on it being pulled in transitively. A CI image rebuild
stopped bringing it in, breaking every sounding-related test.
@full-bars
full-bars merged commit 29ff8b5 into main Jul 22, 2026
15 of 16 checks passed
@full-bars
full-bars deleted the feat/radar-cli branch July 22, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant