Skip to content

Commit b1f49d5

Browse files
sbryngelsonclaude
andcommitted
Harden viz: record validation, error handling, and robustness
- Validate Fortran trailing record markers to detect file corruption - Add ndim else clauses to catch unsupported dimensionality - Narrow broad except Exception to specific types in MP4 writer - Exit with code 1 on MP4 generation failure - Clean stale frames from _frames/ before rendering - Validate --format argument against supported formats - Respect log-scale in MP4 auto-range sampling - Validate slice_axis parameter in render_3d_slice - Show available timestep range on --step mismatch - Handle per-step exceptions in PNG rendering loop - Improve docstrings for ProcessorData, _detect_endianness, render_mp4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fce1211 commit b1f49d5

3 files changed

Lines changed: 81 additions & 14 deletions

File tree

toolchain/mfc/viz/reader.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
class ProcessorData:
2727
"""Data from a single processor file.
2828
29-
m, n, p follow the Fortran header convention: x_cb has m+2 elements,
30-
data arrays have (m+1) cells per dimension. The silo reader uses
31-
m = len(x_cb) - 1 (= number of cells) which differs by one, but
32-
assembly code only uses x_cb lengths and n > 0 / p > 0 for
33-
dimensionality, so both conventions work correctly.
29+
m, n, p store dimension metadata but their exact semantics differ
30+
between readers:
31+
- Binary: m = Fortran header value, x_cb has m+2 elements,
32+
cell count is m+1.
33+
- Silo: m = cell count = len(x_cb) - 1.
34+
Assembly code intentionally avoids using m/n/p for array sizing —
35+
it derives everything from x_cb/y_cb/z_cb lengths. If future code
36+
needs m directly, this discrepancy must be resolved.
3437
"""
3538
m: int
3639
n: int
@@ -53,7 +56,11 @@ class AssembledData:
5356

5457

5558
def _detect_endianness(path: str) -> str:
56-
"""Detect endianness from the first record marker (should be 16 for header)."""
59+
"""Detect endianness from the first record marker.
60+
61+
The header record contains 4 int32s (m, n, p, dbvars) = 16 bytes,
62+
so the leading Fortran record marker must be 16.
63+
"""
5764
with open(path, 'rb') as f:
5865
raw = f.read(4)
5966
if len(raw) < 4:
@@ -80,7 +87,15 @@ def _read_record_endian(f, endian: str) -> bytes:
8087
payload = f.read(rec_len)
8188
if len(payload) < rec_len:
8289
raise EOFError("Unexpected end of file reading record payload")
83-
f.read(4) # trailing marker
90+
trail = f.read(4)
91+
if len(trail) < 4:
92+
raise EOFError("Unexpected end of file reading trailing record marker")
93+
trail_len = struct.unpack(f'{endian}i', trail)[0]
94+
if trail_len != rec_len:
95+
raise ValueError(
96+
f"Fortran record marker mismatch: leading={rec_len}, trailing={trail_len}. "
97+
"File may be corrupted."
98+
)
8499
return payload
85100

86101

@@ -197,6 +212,9 @@ def discover_format(case_dir: str) -> str:
197212

198213
def discover_timesteps(case_dir: str, fmt: str) -> List[int]:
199214
"""Return sorted list of available timesteps."""
215+
if fmt not in ('binary', 'silo'):
216+
raise ValueError(f"Unknown format '{fmt}'. Supported: 'binary', 'silo'.")
217+
200218
if fmt == 'binary':
201219
# Check root/ first (1D), then p0/
202220
root_dir = os.path.join(case_dir, 'binary', 'root')
@@ -235,7 +253,7 @@ def discover_timesteps(case_dir: str, fmt: str) -> List[int]:
235253
pass
236254
return sorted(steps)
237255

238-
return []
256+
return [] # no timestep files found in expected directories
239257

240258

241259
def _discover_processors(case_dir: str, fmt: str) -> List[int]:

toolchain/mfc/viz/renderer.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint:
7575
data_3d = assembled.variables[varname]
7676

7777
axis_map = {'x': 0, 'y': 1, 'z': 2}
78+
if slice_axis not in axis_map:
79+
raise ValueError(
80+
f"Invalid slice_axis '{slice_axis}'. Must be one of: 'x', 'y', 'z'."
81+
)
7882
axis_idx = axis_map[slice_axis]
7983

8084
coords = [assembled.x_cc, assembled.y_cc, assembled.z_cc]
@@ -149,6 +153,10 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
149153
read_func: Callable(step) -> AssembledData for loading each frame.
150154
**opts: Rendering options (cmap, vmin, vmax, dpi, log_scale, figsize,
151155
slice_axis, slice_index, slice_value).
156+
157+
Returns:
158+
True if the MP4 was successfully written, False on failure
159+
(e.g., missing imageio dependency or encoding error).
152160
"""
153161
if read_func is None:
154162
raise ValueError("read_func must be provided for MP4 rendering")
@@ -170,12 +178,19 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
170178
sample_steps.append(steps[len(steps) // 2])
171179

172180
all_mins, all_maxs = [], []
181+
log_scale = opts.get('log_scale', False)
173182
for s in sample_steps:
174183
ad = read_func(s)
175184
d = ad.variables.get(varname)
176185
if d is not None:
177-
all_mins.append(np.nanmin(d))
178-
all_maxs.append(np.nanmax(d))
186+
if log_scale:
187+
pos = d[d > 0]
188+
if pos.size > 0:
189+
all_mins.append(np.nanmin(pos))
190+
all_maxs.append(np.nanmax(pos))
191+
else:
192+
all_mins.append(np.nanmin(d))
193+
all_maxs.append(np.nanmax(d))
179194

180195
if auto_vmin is None and all_mins:
181196
opts['vmin'] = min(all_mins)
@@ -185,6 +200,11 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
185200
# Write frames as images to a temp directory next to the output file
186201
output_dir = os.path.dirname(os.path.abspath(output))
187202
viz_dir = os.path.join(output_dir, '_frames')
203+
# Clean stale frames from any interrupted previous run
204+
if os.path.isdir(viz_dir):
205+
for stale in os.listdir(viz_dir):
206+
if stale.endswith('.png'):
207+
os.remove(os.path.join(viz_dir, stale))
188208
os.makedirs(viz_dir, exist_ok=True)
189209

190210
try:
@@ -206,6 +226,11 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
206226
varname, step, frame_path, **opts)
207227
elif assembled.ndim == 3:
208228
render_3d_slice(assembled, varname, step, frame_path, **opts)
229+
else:
230+
raise ValueError(
231+
f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. "
232+
"Expected 1, 2, or 3."
233+
)
209234

210235
# Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg)
211236
frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png'))
@@ -225,7 +250,7 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
225250
for fname in frame_files:
226251
writer.append_data(imageio.imread(os.path.join(viz_dir, fname)))
227252
success = True
228-
except Exception as exc: # pylint: disable=broad-except
253+
except (OSError, ValueError, RuntimeError) as exc:
229254
print(f"imageio MP4 write failed: {exc}")
230255
finally:
231256
if writer is not None:

toolchain/mfc/viz/viz.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc
6161
# Auto-detect or use specified format
6262
fmt_arg = ARG('format')
6363
if fmt_arg:
64+
if fmt_arg not in ('binary', 'silo'):
65+
cons.print(f"[bold red]Error:[/bold red] Unknown format '{fmt_arg}'. "
66+
"Supported formats: 'binary', 'silo'.")
67+
sys.exit(1)
6468
fmt = fmt_arg
6569
else:
6670
try:
@@ -138,6 +142,9 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc
138142
requested_steps = _parse_steps(step_arg, steps)
139143
if not requested_steps:
140144
cons.print(f"[bold red]Error:[/bold red] No matching timesteps for --step {step_arg}")
145+
if steps:
146+
cons.print(f"[bold]Available range:[/bold] {steps[0]} to {steps[-1]} "
147+
f"({len(steps)} timesteps)")
141148
sys.exit(1)
142149

143150
# Collect rendering options
@@ -206,6 +213,7 @@ def read_step_all_vars(step):
206213
else:
207214
cons.print(f"[bold red]Error:[/bold red] Failed to generate {mp4_path}. "
208215
"Ensure imageio and imageio-ffmpeg are installed.")
216+
sys.exit(1)
209217
return
210218

211219
# Single or multiple PNG frames
@@ -215,8 +223,15 @@ def read_step_all_vars(step):
215223
except ImportError:
216224
step_iter = requested_steps
217225

226+
failures = []
218227
for step in step_iter:
219-
assembled = read_step(step)
228+
try:
229+
assembled = read_step(step)
230+
except (FileNotFoundError, EOFError, ValueError) as exc:
231+
cons.print(f"[yellow]Warning:[/yellow] Skipping step {step}: {exc}")
232+
failures.append(step)
233+
continue
234+
220235
output_path = os.path.join(output_base, f'{varname}_{step}.png')
221236

222237
if assembled.ndim == 1:
@@ -228,9 +243,18 @@ def read_step_all_vars(step):
228243
varname, step, output_path, **render_opts)
229244
elif assembled.ndim == 3:
230245
render_3d_slice(assembled, varname, step, output_path, **render_opts)
246+
else:
247+
cons.print(f"[yellow]Warning:[/yellow] Unsupported ndim={assembled.ndim} "
248+
f"for step {step}, skipping.")
249+
failures.append(step)
250+
continue
231251

232252
if len(requested_steps) == 1:
233253
cons.print(f"[bold green]Saved:[/bold green] {output_path}")
234254

235-
if len(requested_steps) > 1:
236-
cons.print(f"[bold green]Saved {len(requested_steps)} frames to:[/bold green] {output_base}/")
255+
rendered = len(requested_steps) - len(failures)
256+
if failures:
257+
cons.print(f"[yellow]Warning:[/yellow] {len(failures)} step(s) failed: "
258+
f"{failures[:10]}{'...' if len(failures) > 10 else ''}")
259+
if rendered > 1:
260+
cons.print(f"[bold green]Saved {rendered} frames to:[/bold green] {output_base}/")

0 commit comments

Comments
 (0)