Skip to content

Commit fd34eed

Browse files
sbryngelsonclaude
andcommitted
Address code review feedback from CodeRabbit
- Extract shared assembly helper (assemble_from_proc_data) to deduplicate multi-processor assembly logic between reader.py and silo_reader.py - Remove dead code: unused read_record() and _read_silo_object() - Remove duplicate discover_timesteps_silo (reader.discover_timesteps handles both) - Fix renderer.py: use try/finally for writer.close(), report missing imageio - Fix viz.py: validate single-int --step against available timesteps, report error when MP4 generation fails - Fix silo_reader.py: defensive check for "silo" attribute, clarify Fortran-order reinterpretation assumption in comment - Document m/n/p convention difference in ProcessorData docstring - Pin lower-bound versions for imageio>=2.33, imageio-ffmpeg>=0.5.0 - Use integer arithmetic for precision auto-detection - Add warnings for skipped processor files during assembly Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0ea6360 commit fd34eed

6 files changed

Lines changed: 113 additions & 184 deletions

File tree

docs/documentation/visualization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ These can be visualized using MFC's built-in CLI tool or external tools like Par
99

1010
## Quick visualization with `./mfc.sh viz`
1111

12-
MFC includes a built-in visualization command that renders PNG images and MP4 videos directly from post-processed output — no external GUI tools needed.
12+
MFC includes a built-in visualization command that renders images and videos directly from post-processed output — no external GUI tools needed.
1313

1414
### Basic usage
1515

toolchain/mfc/viz/reader.py

Lines changed: 83 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424

2525
@dataclass
2626
class ProcessorData:
27-
"""Data from a single processor file."""
27+
"""Data from a single processor file.
28+
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.
34+
"""
2835
m: int
2936
n: int
3037
p: int
@@ -44,23 +51,6 @@ class AssembledData:
4451
variables: Dict[str, np.ndarray] = field(default_factory=dict)
4552

4653

47-
def read_record(f) -> bytes:
48-
"""Read one Fortran unformatted record, returning the payload bytes."""
49-
raw = f.read(4)
50-
if len(raw) < 4:
51-
raise EOFError("Unexpected end of file reading record marker")
52-
rec_len = struct.unpack('<i', raw)[0]
53-
if rec_len < 0:
54-
# Try big-endian
55-
rec_len = struct.unpack('>i', raw)[0]
56-
if rec_len < 0:
57-
raise ValueError(f"Invalid record length: {rec_len}")
58-
payload = f.read(rec_len)
59-
if len(payload) < rec_len:
60-
raise EOFError("Unexpected end of file reading record payload")
61-
f.read(4) # trailing marker
62-
return payload
63-
6454

6555
def _detect_endianness(path: str) -> str:
6656
"""Detect endianness from the first record marker (should be 16 for header)."""
@@ -121,12 +111,12 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa
121111
n_vals = m + 2
122112

123113
# Auto-detect grid precision from record size
124-
bytes_per_val = grid_bytes / n_vals
125-
if abs(bytes_per_val - 8.0) < 0.5:
114+
if grid_bytes == n_vals * 8:
126115
grid_dtype = np.dtype(f'{endian}f8')
127-
elif abs(bytes_per_val - 4.0) < 0.5:
116+
elif grid_bytes == n_vals * 4:
128117
grid_dtype = np.dtype(f'{endian}f4')
129118
else:
119+
bytes_per_val = grid_bytes / n_vals if n_vals else 0
130120
raise ValueError(
131121
f"Cannot determine grid precision: {grid_bytes} bytes for {n_vals} values "
132122
f"({bytes_per_val:.1f} bytes/value)"
@@ -161,12 +151,12 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa
161151

162152
# Auto-detect variable data precision from record size
163153
data_bytes = len(var_raw) - NAME_LEN
164-
var_bpv = data_bytes / data_size
165-
if abs(var_bpv - 8.0) < 0.5:
154+
if data_bytes == data_size * 8:
166155
var_dtype = np.dtype(f'{endian}f8')
167-
elif abs(var_bpv - 4.0) < 0.5:
156+
elif data_bytes == data_size * 4:
168157
var_dtype = np.dtype(f'{endian}f4')
169158
else:
159+
var_bpv = data_bytes / data_size if data_size else 0
170160
raise ValueError(
171161
f"Cannot determine variable precision for '{varname}': "
172162
f"{data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)"
@@ -261,55 +251,34 @@ def _is_1d(case_dir: str) -> bool:
261251
return os.path.isdir(os.path.join(case_dir, 'binary', 'root'))
262252

263253

264-
def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals,too-many-statements
265-
var: Optional[str] = None) -> AssembledData:
254+
def assemble_from_proc_data( # pylint: disable=too-many-locals
255+
proc_data: List[Tuple[int, ProcessorData]],
256+
) -> AssembledData:
266257
"""
267-
Read and assemble multi-processor data for a given timestep.
258+
Assemble multi-processor data into a single global grid.
268259
269-
For 1D, reads the root file directly.
270-
For 2D/3D, reads all processor files and assembles into global arrays.
260+
Shared helper used by both binary and silo assembly paths.
261+
Handles ghost/buffer cell overlap between processors by using
262+
per-cell coordinate lookup (np.unique + np.searchsorted + np.ix_).
271263
"""
272-
if fmt != 'binary':
273-
raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.")
264+
if not proc_data:
265+
raise ValueError("No processor data to assemble")
274266

275-
# 1D case: read root file directly
276-
if _is_1d(case_dir):
277-
root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat')
278-
if not os.path.isfile(root_path):
279-
raise FileNotFoundError(f"Root file not found: {root_path}")
280-
pdata = read_binary_file(root_path, var_filter=var)
281-
x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0
267+
# Single processor — fast path
268+
if len(proc_data) == 1:
269+
_, pd = proc_data[0]
270+
x_cc = (pd.x_cb[:-1] + pd.x_cb[1:]) / 2.0
271+
y_cc = (pd.y_cb[:-1] + pd.y_cb[1:]) / 2.0 if pd.n > 0 else np.array([0.0])
272+
z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0])
273+
ndim = 1 + (pd.n > 0) + (pd.p > 0)
282274
return AssembledData(
283-
ndim=1, x_cc=x_cc,
284-
y_cc=np.array([0.0]), z_cc=np.array([0.0]),
285-
variables=pdata.variables,
275+
ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc,
276+
variables=pd.variables,
286277
)
287278

288-
# Multi-dimensional: read all processor files
289-
ranks = _discover_processors(case_dir, fmt)
290-
if not ranks:
291-
raise FileNotFoundError(f"No processor directories found in {case_dir}/binary/")
292-
293-
# Read all processor data
294-
proc_data: List[Tuple[int, ProcessorData]] = []
295-
for rank in ranks:
296-
fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat')
297-
if not os.path.isfile(fpath):
298-
continue
299-
pdata = read_binary_file(fpath, var_filter=var)
300-
if pdata.m == 0 and pdata.n == 0 and pdata.p == 0:
301-
continue
302-
proc_data.append((rank, pdata))
303-
304-
if not proc_data:
305-
raise FileNotFoundError(f"No valid processor data found for step {step}")
306-
307-
ndim = 1
279+
# Multi-processor assembly
308280
sample = proc_data[0][1]
309-
if sample.n > 0:
310-
ndim = 2
311-
if sample.p > 0:
312-
ndim = 3
281+
ndim = 1 + (sample.n > 0) + (sample.p > 0)
313282

314283
# Compute cell centers for each processor
315284
proc_centers = []
@@ -346,7 +315,6 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t
346315
global_vars[vn] = np.zeros(nx)
347316

348317
# Place each processor's data using per-cell coordinate lookup
349-
# (handles ghost/buffer cell overlap between processors)
350318
for _rank, pd, x_cc, y_cc, z_cc in proc_centers:
351319
xi = np.searchsorted(global_x, np.round(x_cc, 12))
352320
yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0])
@@ -366,3 +334,52 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t
366334
ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z,
367335
variables=global_vars,
368336
)
337+
338+
339+
def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals
340+
var: Optional[str] = None) -> AssembledData:
341+
"""
342+
Read and assemble multi-processor data for a given timestep.
343+
344+
For 1D, reads the root file directly.
345+
For 2D/3D, reads all processor files and assembles into global arrays.
346+
"""
347+
if fmt != 'binary':
348+
raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.")
349+
350+
# 1D case: read root file directly
351+
if _is_1d(case_dir):
352+
root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat')
353+
if not os.path.isfile(root_path):
354+
raise FileNotFoundError(f"Root file not found: {root_path}")
355+
pdata = read_binary_file(root_path, var_filter=var)
356+
x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0
357+
return AssembledData(
358+
ndim=1, x_cc=x_cc,
359+
y_cc=np.array([0.0]), z_cc=np.array([0.0]),
360+
variables=pdata.variables,
361+
)
362+
363+
# Multi-dimensional: read all processor files
364+
ranks = _discover_processors(case_dir, fmt)
365+
if not ranks:
366+
raise FileNotFoundError(f"No processor directories found in {case_dir}/binary/")
367+
368+
proc_data: List[Tuple[int, ProcessorData]] = []
369+
for rank in ranks:
370+
fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat')
371+
if not os.path.isfile(fpath):
372+
import warnings # pylint: disable=import-outside-toplevel
373+
warnings.warn(f"Processor file not found, skipping: {fpath}")
374+
continue
375+
pdata = read_binary_file(fpath, var_filter=var)
376+
if pdata.m == 0 and pdata.n == 0 and pdata.p == 0:
377+
import warnings # pylint: disable=import-outside-toplevel
378+
warnings.warn(f"Processor p{rank} has zero dimensions, skipping")
379+
continue
380+
proc_data.append((rank, pdata))
381+
382+
if not proc_data:
383+
raise FileNotFoundError(f"No valid processor data found for step {step}")
384+
385+
return assemble_from_proc_data(proc_data)

toolchain/mfc/viz/renderer.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,23 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum
203203
success = False
204204
try:
205205
import imageio # pylint: disable=import-outside-toplevel
206+
except ImportError:
207+
print("imageio is not installed. Install it with: pip install imageio imageio-ffmpeg")
208+
print(f"Frames saved to {viz_dir}/")
209+
return False
210+
211+
writer = None
212+
try:
206213
writer = imageio.get_writer(output, fps=fps, codec='libx264',
207214
pixelformat='yuv420p', macro_block_size=2)
208215
for fname in frame_files:
209216
writer.append_data(imageio.imread(os.path.join(viz_dir, fname)))
210-
writer.close()
211217
success = True
212-
except ImportError:
213-
pass
214218
except Exception as exc: # pylint: disable=broad-except
215219
print(f"imageio MP4 write failed: {exc}")
220+
finally:
221+
if writer is not None:
222+
writer.close()
216223

217224
# Clean up frames
218225
if success:

0 commit comments

Comments
 (0)