Skip to content

Commit 6dd3e18

Browse files
authored
Improve cell tracking robustness, fix bugs, enhance visualization, and add test suite (FlexTRKR#134)
This PR improves cell tracking robustness, fixes several bugs, enhances visualization scripts, and adds an automated test suite. BUG FIXES - idcells_reflectivity.py: Fix assign_coords error when height coordinate is 3D (z, y, x) by preserving original dimension names on reassignment. - idcells_reflectivity.py: Add squeeze for single-time input files; fix terrain-relative low-level reflectivity filtering to use sfc_elev + sfc_dz offsets instead of absolute heights. - idcells_reflectivity.py: Make rangemask_varname optional; use domain- center z-profile for vertical coordinate direction detection to avoid edge artifacts in regridded data. - run_celltracking.py: Set driftfile to None when run_advection is False. - trackstats_func.py: Handle cases where rangemask_varname is not set. - preprocess_wrf_tb_rainrate.py: Fix np.nan handling for modern NumPy. - MCS visualization scripts: Fix NaN check before plotting PF diameter circle in plot_subset_tbpf_mcs_tracks_demo.py and plot_subset_tbze_mcs_tracks_demo.py. NEW FEATURES - Vertical coordinate unit standardization: Added standardize_vertical_coordinate() to auto-detect and convert height units (km to m) and pressure units (Pa to hPa) before filtering. Supports z_coord_scale_factor, sfc_elev_scale_factor, and units_override config parameters for manual control. - Automatic maxnclouds detection: Added find_maxnclouds() in ft_utilities.py and auto-update logic in gettracks.py to scan track files and raise maxnclouds if the actual cloud count exceeds the configured value. Prevents IndexError on large datasets. Controlled by auto_update_maxnclouds config flag (default: True). - run_celltracking.py: Use config.get() with defaults for all pipeline step flags so they are optional in config files. Added Dask allowed_failures configuration. VISUALIZATION - plot_subset_cell_tracks_demo.py: Reduced required inputs to dates and config file only (radar lat/lon auto-calculated from domain center if not provided). Added 20+ customization options for map features, radar elements, track display, font sizes, map resolution, and title prefix. Added run_parallel flag. Mask reflectivity below radar_sensitivity before plotting. - make_cell_animation.py: New wrapper script that generates a PNG series and assembles an MP4 animation using FFmpeg. - MCS visualization scripts (5 files): Comprehensive docstring updates documenting all pixel_dict, plot_info, map_info, and track_dict keys. TESTING - Added 49 CI-safe unit/integration tests and 6 local regression tests, all passing. Test files cover Steiner classification (test_steiner_func.py), echo-top height (test_echotop_func.py), vertical coordinate standardization (test_vertical_coordinate.py), and a full end-to-end pipeline using synthetic data (test_idcells_synthetic.py). Local regression tests against the NEXRAD demo dataset are in test_regression_local.py and are auto-skipped on CI when PYFLEXTRKR_TEST_DATA is not set. - Added pytest.ini, conftest.py, requirements-ci.txt, updated GitHub Actions workflow to use the minimal CI dependency list, and added tests/README.md with usage instructions.
1 parent 2ec2627 commit 6dd3e18

25 files changed

Lines changed: 2668 additions & 334 deletions

.github/workflows/test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@ jobs:
2828
- name: Install dependencies
2929
run: |
3030
python -m pip install --upgrade pip
31-
pip install -r requirements.txt
31+
pip install -r requirements-ci.txt
3232
pip install -e .
33-
pip install pytest
3433
3534
- name: Run Tests
3635
run: |

Analysis/make_cell_animation.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
#!/usr/bin/env python
2+
"""
3+
Make cell tracking animation
4+
- Call plotting script to generate PNG files
5+
- Create a video animation using FFmpeg
6+
7+
Author: Zhe Feng | zhe.feng@pnnl.gov
8+
"""
9+
10+
import os
11+
import glob
12+
import subprocess
13+
import pandas as pd
14+
import tempfile
15+
16+
###############################################################################################
17+
# Script parameters
18+
###############################################################################################
19+
20+
# Script parameters
21+
start_date = "2020-06-13T00:00"
22+
end_date = "2020-06-16T00:00"
23+
24+
# Radar location (optional - if not provided, defaults to domain center)
25+
radar_lat = 34.723
26+
radar_lon = 273.428
27+
28+
# Define domain map extent (optional - if not provided, defaults to full domain)
29+
lon_min = radar_lon - 6.0
30+
lon_max = radar_lon + 6.0
31+
lat_min = radar_lat - 4
32+
lat_max = radar_lat + 4
33+
34+
# Get start year from start_date
35+
start_year = str(start_date.split('-')[0])
36+
37+
# Tracking config file
38+
config_file = f"/global/homes/f/feng045/program/scream/config/config_celltracking_3km_SCREAMv1-Cess2_CONUS.yaml"
39+
40+
# Execution parameters
41+
parallel_mode = 1
42+
n_workers = 128
43+
figsize_x = 10 # Width in inches (height is auto-calculated to maintain aspect ratio)
44+
45+
# Tracking pixel-level file time format
46+
time_format = 'yyyymodd_hhmmss'
47+
48+
# Variable name for plotting in pixel files
49+
varname = 'dbz_comp'
50+
51+
# Title prefix for plots (added before date/time, can be an empty string)
52+
title_prefix = 'SCREAM Cess2'
53+
54+
# Control features to draw
55+
draw_land = True # Draw land features
56+
draw_border = True # Draw country borders
57+
draw_state = True # Draw state/province borders
58+
show_rangecircle = False # Draw radar range circles
59+
show_azimuth = False # Draw azimuth lines
60+
show_tracks = True # Show tracks
61+
show_paths = True # Show track path lines (None = auto from show_tracks)
62+
show_symbols = False # Show track centroid symbols (None = auto from show_tracks)
63+
# Font sizes
64+
fontsize = 13 # Main font size for labels and text
65+
fontsize_tracks = 6 # Font size for track numbers (None = auto-calculate as fontsize*0.8)
66+
# Map resolution for Natural Earth features (Cartopy options: '10m', '50m', '110m')
67+
# '10m' = highest detail (slowest), '50m' = medium detail, '110m' = lowest detail (fastest)
68+
map_resolution = '50m'
69+
# Figure parameters
70+
subset = 1 # Subset data before plotting (0:no, 1:yes)
71+
fig_basename = "cell_tracks_"
72+
73+
# Output directories
74+
figdir = f"/global/cfs/cdirs/m1657/zfeng/SCREAMv1-cess2/cell_conus/quicklooks/"
75+
animation_dir = f"/global/cfs/cdirs/m1657/zfeng/SCREAMv1-cess2/cell_conus/animations/"
76+
77+
# Execution control options
78+
run_plotting = True # Set to False to skip plotting and use existing PNG files
79+
run_ffmpeg = True # Set to False to skip animation creation (plotting only)
80+
81+
# FFmpeg animation parameters
82+
input_framerate = 2 # (frames per second) - how fast to transition between frames
83+
output_framerate = 10 # (frames per second) - video playback speed (lower values = smaller file size, e.g., 24 fps is cinema)
84+
video_quality = 20 # CRF value (lower = better quality, range 0-51, 18-28 is good)
85+
output_width = 1920 # Output video width in pixels (height auto-calculated to maintain aspect ratio, set to None to keep original size)
86+
87+
# Animation parameters
88+
start_date_str = start_date[:13] # Extract YYYY-MM-DDTHH
89+
end_date_str = end_date[:13] # Extract YYYY-MM-DDTHH
90+
animation_filename = f"{animation_dir}{fig_basename}{start_date_str}_{end_date_str}.mp4"
91+
92+
###############################################################################################
93+
# Main execution
94+
###############################################################################################
95+
96+
print("Make cell tracking animation")
97+
print(f"Plotting script: plot_subset_cell_tracks_demo.py")
98+
print(f"Date range: {start_date} to {end_date}")
99+
print(f"Radar location: ({radar_lat}, {radar_lon})")
100+
print(f"Execution mode: Plotting={'✅' if run_plotting else '❌'}, FFmpeg={'✅' if run_ffmpeg else '❌'}")
101+
102+
# Create directories if they don't exist
103+
if run_ffmpeg:
104+
os.makedirs(animation_dir, exist_ok=True)
105+
if run_plotting:
106+
os.makedirs(figdir, exist_ok=True)
107+
108+
#########################################################
109+
# Run plotting script
110+
#########################################################
111+
if run_plotting:
112+
print(f"📊 Running plotting script with {n_workers} workers...")
113+
cmd = [
114+
'python', 'plot_subset_cell_tracks_demo.py',
115+
'--start', start_date,
116+
'--end', end_date,
117+
'--config', config_file,
118+
]
119+
120+
# Add radar location if provided
121+
if radar_lat is not None:
122+
cmd.extend(['--radar_lat', str(radar_lat)])
123+
if radar_lon is not None:
124+
cmd.extend(['--radar_lon', str(radar_lon)])
125+
126+
# Add extent if all values are provided
127+
if all(v is not None for v in [lon_min, lon_max, lat_min, lat_max]):
128+
cmd.extend(['--extent', str(lon_min), str(lon_max), str(lat_min), str(lat_max)])
129+
130+
cmd.extend([
131+
'--subset', str(subset),
132+
'--time_format', str(time_format),
133+
'--parallel', str(parallel_mode),
134+
'--workers', str(n_workers),
135+
'--output', figdir,
136+
'--figsize_x', str(figsize_x),
137+
'--figbasename', fig_basename,
138+
'--varname', varname,
139+
'--draw_land', str(int(draw_land)),
140+
'--draw_border', str(int(draw_border)),
141+
'--draw_state', str(int(draw_state)),
142+
'--show_rangecircle', str(int(show_rangecircle)),
143+
'--show_azimuth', str(int(show_azimuth)),
144+
'--show_tracks', str(int(show_tracks)),
145+
'--fontsize', str(fontsize),
146+
'--map_resolution', map_resolution,
147+
'--title_prefix', title_prefix,
148+
])
149+
150+
# Add optional show_paths and show_symbols if specified (not None)
151+
if show_paths is not None:
152+
cmd.extend(['--show_paths', str(int(show_paths))])
153+
if show_symbols is not None:
154+
cmd.extend(['--show_symbols', str(int(show_symbols))])
155+
if fontsize_tracks is not None:
156+
cmd.extend(['--fontsize_tracks', str(fontsize_tracks)])
157+
158+
print(f"Command: {' '.join(cmd)}")
159+
result = subprocess.run(cmd)
160+
161+
if result.returncode != 0:
162+
print(f"❌ Error: Plotting script failed with exit code {result.returncode}")
163+
exit(1)
164+
165+
print("✅ Plotting completed successfully!")
166+
else:
167+
print("⏭️ Skipping plotting - using existing PNG files")
168+
169+
#########################################################
170+
# Make animation using ffmpeg
171+
#########################################################
172+
if run_ffmpeg:
173+
print("🎬 Creating animation from PNG files...")
174+
175+
# Get all PNG files matching the basename pattern
176+
all_png_files = sorted(glob.glob(f'{figdir}{fig_basename}*.png'))
177+
178+
# Parse datetime from actual filenames to filter by date range
179+
start_dt = pd.to_datetime(start_date)
180+
end_dt = pd.to_datetime(end_date)
181+
182+
expected_files = []
183+
for png_file in all_png_files:
184+
# Extract the datetime string from filename (assumes format: basename_YYYYMMDD_HHMMSS.png)
185+
try:
186+
basename_len = len(fig_basename)
187+
datetime_str = os.path.basename(png_file)[basename_len:basename_len+15] # YYYYMMDD_HHMMSS
188+
file_dt = pd.to_datetime(datetime_str, format='%Y%m%d_%H%M%S')
189+
190+
# Check if file is within the specified date range
191+
if start_dt <= file_dt <= end_dt:
192+
expected_files.append(png_file)
193+
except (ValueError, IndexError):
194+
# Skip files that don't match the expected datetime format
195+
continue
196+
197+
print(f"Found {len(expected_files)} PNG files within date range")
198+
print(f"Time range: {start_date} to {end_date}")
199+
200+
if len(expected_files) == 0:
201+
print("❌ No PNG files found within the specified date range!")
202+
print(f" Check directory: {figdir}")
203+
print(f" Expected pattern: {fig_basename}YYYYMMDD_HHMMSS.png")
204+
exit(1)
205+
206+
# Create a temporary file list for FFmpeg
207+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
208+
temp_filelist = f.name
209+
for png_file in sorted(expected_files):
210+
f.write(f"file '{png_file}'\n")
211+
212+
try:
213+
# Build video filter string based on output_width parameter
214+
if output_width is not None:
215+
# Scale to specified width, maintaining aspect ratio, ensuring dimensions divisible by 2
216+
vf_scale = f'scale={output_width}:-2'
217+
else:
218+
# Just ensure dimensions are divisible by 2 (no scaling)
219+
vf_scale = 'scale=trunc(iw/2)*2:trunc(ih/2)*2'
220+
221+
# Use FFmpeg concat demuxer with file list
222+
ffmpeg_cmd = [
223+
'ffmpeg',
224+
'-f', 'concat',
225+
'-safe', '0',
226+
'-r', str(input_framerate), # Input framerate
227+
'-i', temp_filelist,
228+
'-vf', vf_scale,
229+
'-c:v', 'libx264',
230+
'-r', str(output_framerate), # Output framerate
231+
'-crf', str(video_quality),
232+
'-pix_fmt', 'yuv420p',
233+
'-y', animation_filename
234+
]
235+
236+
print(f"🎬 Animation settings:")
237+
print(f" Input framerate: {input_framerate} fps (PNG reading speed)")
238+
print(f" Output framerate: {output_framerate} fps (video playback speed)")
239+
print(f" Video quality (CRF): {video_quality} (lower=better)")
240+
print(f" Output width: {output_width if output_width else 'original'} pixels (height auto-scaled)")
241+
print(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
242+
print(f"Using {len(expected_files)} PNG files from {expected_files[0]} to {expected_files[-1]}")
243+
244+
result = subprocess.run(ffmpeg_cmd)
245+
246+
finally:
247+
# Clean up temporary file
248+
os.unlink(temp_filelist)
249+
250+
if result.returncode == 0:
251+
print(f"✅ Animation created successfully!")
252+
print(f"🎬 View animation here: {animation_filename}")
253+
else:
254+
print(f"❌ Error: FFmpeg failed with exit code {result.returncode}")
255+
256+
else:
257+
print("⏭️ Skipping animation creation - PNG files ready for manual processing")

0 commit comments

Comments
 (0)