Skip to content

Add CLI visualization command (./mfc.sh viz)#1233

Merged
sbryngelson merged 105 commits intoMFlowCode:masterfrom
sbryngelson:viz-fast
Mar 1, 2026
Merged

Add CLI visualization command (./mfc.sh viz)#1233
sbryngelson merged 105 commits intoMFlowCode:masterfrom
sbryngelson:viz-fast

Conversation

@sbryngelson
Copy link
Member

@sbryngelson sbryngelson commented Feb 22, 2026

Summary

Adds ./mfc.sh viz — a built-in CLI visualization command for rendering post-processed MFC output directly from the terminal. No ParaView or VisIt required.

Features

  • Format support: Binary (format=2) and Silo-HDF5 (format=1) with automatic detection
  • Multi-processor assembly: Reads per-rank output files and stitches them into a global grid, handling ghost cell overlap
  • Rendering modes:
    • 1D line plots and tiled all-variable plots
    • 2D colormaps (pcolormesh) and tiled all-variable plots
    • 3D midplane slices (configurable axis, index, or coordinate value)
    • MP4 video generation from timestep ranges (streamed frame-by-frame to avoid OOM)
  • Interactive mode: --interactive launches a Dash/Plotly web UI with live controls for slice position, isosurface thresholds, volume rendering, colormap, log scale, vmin/vmax, variable switching, and timestep playback
  • Terminal TUI: --tui launches a fully keyboard-driven terminal UI (works over SSH, no browser/port-forwarding needed) with:
    • 1D line plots and 2D heatmaps rendered in-terminal
    • space autoplay, ,/. step navigation, / variable selection
    • l log scale toggle, f freeze color range, c cycle colormap
  • Rendering options: --cmap, --vmin/vmax, --dpi, --log-scale, --slice-axis/value/index, --mp4, --fps
  • Data exploration: --list-steps and --list-vars to inspect available data before plotting

Usage

# Explore available data
./mfc.sh viz case_dir/ --list-steps
./mfc.sh viz case_dir/ --list-vars --step 0

# Render a PNG
./mfc.sh viz case_dir/ --var pres --step 1000

# Generate an MP4 video
./mfc.sh viz case_dir/ --var pres --step 0:10000:500 --mp4

# 3D slice at z midplane
./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z

# Interactive web UI (use --step to limit steps for large 3D cases)
./mfc.sh viz case_dir/ --interactive
./mfc.sh viz case_dir/ --var pres --step 0:5000:100 --interactive

# Terminal TUI (SSH-friendly, no browser required)
./mfc.sh viz case_dir/ --tui

Architecture

toolchain/mfc/viz/
├── viz.py           # CLI entry point — parses args, dispatches to readers + renderers
├── reader.py        # Binary format reader + multi-processor assembly (ProcessorData/AssembledData)
├── silo_reader.py   # Silo-HDF5 reader via h5py
├── renderer.py      # matplotlib PNG rendering + imageio MP4 generation
├── interactive.py   # Dash/Plotly web UI with Catppuccin dark theme
├── tui.py           # Textual/plotext terminal TUI (1D/2D, SSH-friendly)
├── _step_cache.py   # Thread-safe bounded FIFO step cache (shared by TUI and interactive)
└── test_viz.py      # Unit tests with checked-in 1D/2D/3D fixture data

Other changes

  • CLI integration: VIZ_COMMAND in commands.py, dispatch in main.py (skips cmake check)
  • toolchain/mfc/viz.py (old mfc.viz.Case API) and its dependent example scripts (nD_perfect_reactor/analyze.py, nD_perfect_reactor/export.py, 1D_reactive_shocktube/viz.py, 1D_inert_shocktube/viz.py) removed; superseded by ./mfc.sh viz
  • Documentation: expanded visualization.md, added quick-start to getting-started.md, viz examples in running.md, troubleshooting section
  • requires-python = ">=3.10" added to pyproject.toml
  • Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run

Dependencies

  • imageio + imageio-ffmpeg for portable MP4 encoding
  • h5py for Silo-HDF5 reading
  • dash>=2.0 + plotly for interactive mode
  • textual>=0.47.0 + textual-plotext + plotext for TUI mode

Correctness notes

  • Multi-rank assembly: Ghost-cell deduplication normalizes by domain extent (12 sig digits relative to extent) — correct for both micro-scale and large-scale (extent > 1e12) domains
  • Thread safety: _step_cache uses a threading.Lock for safe concurrent access from Dash callbacks; reads happen before eviction so a failed read never discards a valid cache entry
  • Interactive/TUI step selection: An explicit --step is honoured so users can limit the loaded set for large 3D cases that would otherwise exceed the 50-step memory limit
  • MP4 encoding: Frames normalized to even pixel dimensions (yuv420p requirement), RGB-converted; KeyboardInterrupt still triggers temp-dir cleanup

Test plan

  • 1D binary case: ./mfc.sh viz <1d_case> --var pres --step 0
  • 2D multi-rank: ./mfc.sh viz <2d_case> --var pres --step 0:1000:100 --mp4
  • 3D multi-rank with slicing: ./mfc.sh viz <3d_case> --var pres --step 0 --slice-axis z
  • Silo-HDF5 format: ./mfc.sh viz <silo_case> --var pres --step 0
  • Interactive mode: ./mfc.sh viz <case> --interactive
  • TUI mode: ./mfc.sh viz <case> --tui
  • --list-vars and --list-steps info commands

@sbryngelson
Copy link
Member Author

@coderabbitai review

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (9)
toolchain/pyproject.toml (1)

40-43: Pin version lower-bounds and consider making imageio-ffmpeg an optional extra.

Two actionable concerns:

  1. No version constraints — both imageio and imageio-ffmpeg are unpinned. The latest imageio is 2.37.2 and the latest imageio-ffmpeg is 0.6.0 (released Jan 16, 2025). Add at minimum a lower-bound to prevent silent regressions if either library releases breaking changes.

  2. Mandatory ~60 MiB binaryimageio-ffmpeg's platform-specific wheels contain the ffmpeg executable, making the package around 60 MiB in size. Since imageio-ffmpeg is only needed for --mp4 video generation and not for PNG output, making it an optional extra avoids this cost for all other toolchain users.

♻️ Suggested approach
-    # Visualization (video rendering)
-    "imageio",
-    "imageio-ffmpeg",
-

Keep imageio as a required dep (lightweight) and move imageio-ffmpeg to an optional extra:

dependencies = [
    ...
    # Visualization (image I/O)
    "imageio>=2.33",
    ...
]

[project.optional-dependencies]
viz-video = [
    "imageio-ffmpeg>=0.5.0",
]

Then document that pip install mfc[viz-video] (or ./mfc.sh can detect and prompt on first --mp4 use).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/pyproject.toml` around lines 40 - 43, Update pyproject.toml to pin
lower bounds for the image I/O packages and move the heavy ffmpeg wheel into an
optional extra: add a minimum version constraint for "imageio" in the
dependencies list (e.g., "imageio>=2.33") and remove "imageio-ffmpeg" from the
main dependencies, then add an [project.optional-dependencies] (or equivalent)
section named something like "viz-video" containing "imageio-ffmpeg>=0.5.0" so
users can opt into the ~60 MiB binary only when they need MP4 generation.
toolchain/mfc/viz/viz.py (1)

14-34: Single-int step is not validated against available timesteps, unlike ranges.

When step_arg is a range (e.g., "0:10000:500"), the result is filtered against available_steps. But a single int (line 34) is returned as-is without checking membership. This means --step 999 will proceed even if step 999 doesn't exist, deferring failure to the reader (likely with an opaque file-not-found error).

Consider filtering the single-int case as well for a consistent UX:

Proposed fix
-    return [int(step_arg)]
+    single = int(step_arg)
+    return [single] if single in set(available_steps) else []
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/viz.py` around lines 14 - 34, The single-int branch in
_parse_steps doesn't validate step_arg against available_steps; update the final
return path to convert step_arg to int, check membership against available_steps
(or its set) and return [that_int] only if present, otherwise return an empty
list (matching the range filtering behavior) so callers get consistent,
pre-validated timesteps.
toolchain/mfc/viz/reader.py (5)

264-368: Assembly logic is heavily duplicated with assemble_silo in silo_reader.py.

The multi-processor assembly logic (building global coordinate arrays with np.unique/np.round, initializing global variable arrays by ndim, and placing data via searchsorted/np.ix_) is nearly identical between assemble() (lines 314–368) and assemble_silo() (silo_reader.py lines 204–269). Consider extracting a shared _assemble_from_proc_data(proc_data, ndim) helper in reader.py and calling it from both assemblers. This would keep the format-specific I/O separate while unifying the coordinate-merge and data-placement math.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 264 - 368, The multi-processor
assembly logic in assemble() duplicates assemble_silo(); extract the shared
coordinate-merge and data-placement code into a new helper (suggest name
_assemble_from_proc_data(proc_data, ndim)) that returns (ndim, global_x,
global_y, global_z, global_vars) given proc_data and use it from assemble() and
from assemble_silo() in silo_reader.py so file I/O stays format-specific while
the unique math (np.unique/np.round, initializing arrays by ndim, searchsorted +
np.ix_ placement) is centralized; update assemble() to call
_assemble_from_proc_data(proc_data, ndim) after building proc_data and remove
the duplicated assembly block.

116-133: Grid precision auto-detection relies on floating-point division — this is fine but could be simplified.

The approach of dividing grid_bytes / n_vals and checking proximity to 4.0 or 8.0 works because n_vals must evenly divide grid_bytes. You could use integer modulo for a more robust check.

♻️ Suggested simplification using integer arithmetic
-        # Auto-detect grid precision from record size
-        bytes_per_val = grid_bytes / n_vals
-        if abs(bytes_per_val - 8.0) < 0.5:
-            grid_dtype = np.dtype(f'{endian}f8')
-        elif abs(bytes_per_val - 4.0) < 0.5:
-            grid_dtype = np.dtype(f'{endian}f4')
+        # Auto-detect grid precision from record size
+        if grid_bytes == n_vals * 8:
+            grid_dtype = np.dtype(f'{endian}f8')
+        elif grid_bytes == n_vals * 4:
+            grid_dtype = np.dtype(f'{endian}f4')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 116 - 133, The current
auto-detection computes bytes_per_val = grid_bytes / n_vals and compares to
8.0/4.0 using floats; replace this with integer arithmetic to avoid FP rounding:
compute n_vals (as already done) and use integer modulo/division on grid_bytes
(e.g., grid_bytes % n_vals and grid_bytes // n_vals) to verify grid_bytes
divides evenly and that grid_bytes // n_vals equals 8 or 4, then set grid_dtype
accordingly (use the same endian f-strings); if it doesn't divide evenly or the
quotient is not 8 or 4, raise the same ValueError with the existing message.

200-240: discover_timesteps duplicates silo timestep logic already in silo_reader.discover_timesteps_silo.

Lines 228–238 duplicate the same silo timestep discovery that exists in silo_reader.py:discover_timesteps_silo. When fmt == 'silo', this function could delegate to discover_timesteps_silo to avoid maintaining two copies. Alternatively, centralize all format-aware discovery here and remove the standalone silo version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 200 - 240, The silo-specific
timestep discovery block in discover_timesteps duplicates logic in
silo_reader.discover_timesteps_silo; replace the entire fmt == 'silo' branch
with a call/delegation to silo_reader.discover_timesteps_silo(case_dir) (and add
an import for silo_reader if missing) and return its result, removing the
duplicated code so only discover_timesteps and discover_timesteps_silo remain as
single sources of truth.

296-302: Silently skipping missing or empty processor files may hide data issues.

If a processor file is missing (line 298: continue) or has all-zero dimensions (lines 300–301), the assembly proceeds without warning. For a visualization tool this is a pragmatic choice, but a logged warning would help users debug incomplete post-processing runs.

♻️ Add a warning for skipped processors
+import warnings
 ...
         if not os.path.isfile(fpath):
+            warnings.warn(f"Processor file not found, skipping: {fpath}")
             continue
         pdata = read_binary_file(fpath, var_filter=var)
         if pdata.m == 0 and pdata.n == 0 and pdata.p == 0:
+            warnings.warn(f"Processor p{rank} has zero dimensions, skipping")
             continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 296 - 302, The loop that builds
proc_data silently skips missing files and zero-dimension reads; update the
block around fpath/read_binary_file so it emits a warning whenever a processor
is skipped: when os.path.isfile(fpath) is false log a warning including rank,
fpath, step and var; after calling read_binary_file, if pdata.m == 0 and pdata.n
== 0 and pdata.p == 0 log a warning including rank, fpath and the empty
dimensions before continuing; use the module's logger or the logging.warning API
to provide clear, contextual messages referencing fpath, rank, step, var and
pdata so users can diagnose missing/empty processor files.

47-62: read_record is unused dead code; remove it or make it private.

This function is defined but never called anywhere in the codebase or module exports. It also has two legitimate issues worth fixing if retained:

  1. Endianness handling is unreliable: It tries little-endian first, falls back to big-endian, but doesn't track which succeeded. The calling code has no way to know the payload's byte order.

  2. Trailing record marker is never validated: Both read_record and _read_record_endian consume the trailing 4-byte marker without verifying it matches the leading marker. This misses Fortran format violations that indicate file corruption.

The codebase consistently uses _read_record_endian (with detected endianness), so either remove read_record or clearly mark it as private (_read_record) with a comment explaining its purpose if there's a reason to keep it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 47 - 62, The free-standing
read_record function is unused and should either be removed or renamed to
_read_record with a comment explaining it's a convenience wrapper only used for
ad-hoc reads; if you keep it, change its behavior to detect and return
endianness (or delegate to the existing _read_record_endian) and validate the
trailing 4-byte marker matches the leading marker before returning the payload
to avoid silent corruption; update references to use _read_record or remove the
function entirely and ensure no other code relies on read_record.
toolchain/mfc/viz/silo_reader.py (2)

126-132: The Fortran-order reinterpretation is correct but fragile — consider a clarifying note.

The ravel().reshape(data.shape, order='F') trick works because Silo/HDF5 preserves the Fortran dimension ordering (nx, ny, nz) as the dataset shape, so the reinterpretation produces the desired data[ix, iy, iz] indexing. If the HDF5 shape ever changes (e.g., a Silo version reverses dims), this would silently produce transposed results. A brief inline note clarifying this assumption would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/silo_reader.py` around lines 126 - 132, Add a brief inline
comment next to the Fortran-order reinterpretation in silo_reader.py (the block
that does data = np.ascontiguousarray(data).ravel().reshape(data.shape,
order='F') and assigns into variables[key]) stating the assumption that the
HDF5/Silo dataset shape preserves the Fortran dimension ordering (nx, ny, nz),
and note that if that ordering ever changes the reshape will silently transpose
data; optionally add a runtime sanity check (shape or known axis order) or a
warning log to detect unexpected dimension-order changes before assigning to
variables[key].

42-49: Remove _read_silo_object or refactor read_silo_file to use it.

This helper function is never called. read_silo_file directly accesses obj.attrs["silo"] (lines 85, 122) after checking the type inline, duplicating the logic in _read_silo_object.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/silo_reader.py` around lines 42 - 49, The helper
_read_silo_object is dead code or duplicated logic; either remove it or update
read_silo_file to call it instead of repeating type and attribute checks. Locate
the two places in read_silo_file where the code checks isinstance(obj,
h5py.Datatype) and accesses obj.attrs["silo"] (currently duplicated) and replace
that block with a call to _read_silo_object(obj) and handle a None return
(skip/raise) the same way the inline code currently does; alternatively, delete
the unused _read_silo_object function if you prefer to keep the inline checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/documentation/visualization.md`:
- Line 12: In the visualization description string that currently reads "MFC
includes a built-in visualization command that renders PNG images and MP4 videos
directly from post-processed output — no external GUI tools needed.", replace
the redundant phrase "PNG images" with "PNGs" so the sentence reads "...renders
PNGs and MP4 videos..."; update the sentence text in the visualization.md
content accordingly to match the `.typos.toml` convention.
- Line 12: Replace the phrase "PNG images" in the sentence that begins "MFC
includes a built-in visualization command that renders PNG images and MP4
videos..." with "PNGs" so the sentence reads "...renders PNGs and MP4 videos..."
(this aligns with the .typos.toml change and removes the redundancy).

In `@toolchain/mfc/viz/renderer.py`:
- Around line 203-221: The MP4 writer code should ensure writer is always closed
and surface a clear message when imageio is missing: wrap imageio.get_writer and
the for-loop that calls writer.append_data in a with-context or try/finally
around the writer object (reference the local variable writer and the
append_data calls) so writer.close() runs even if append_data raises, and change
the ImportError handler (currently swallowing the exception) to log or print a
clear error that imageio is not installed and set success=False; also ensure
other exceptions caught in the broad except log the error and keep success False
so the caller knows the MP4 was not produced.

In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 82-85: The code reads obj.attrs["silo"] without guaranteeing the
"silo" attribute exists after checking silo_type; update both places (the block
that sets mesh_name/mesh_attr and the later block around line 122) to first
fetch the attribute defensively (e.g., use obj.attrs.get("silo") or test '"silo"
in obj.attrs') and handle the missing case by skipping the object or logging and
continuing; specifically modify the logic that checks silo_type and sets
mesh_attr to verify the "silo" attribute exists before assigning mesh_attr and
avoid raising KeyError.
- Around line 102-105: ProcessorData.m/n/p currently use a different convention
in silo_reader.py (m = len(x_cb)-1) than the binary reader; normalize to the
binary/Fortran convention and document it: set m = len(x_cb) - 2 (and similarly
n = len(y_cb) - 2, p = len(z_cb) - 2 when ndims >= 2/3) so ProcessorData.m
matches the Fortran-style m where m+1 = number of cells and x_cb has m+2
elements, and add/update the ProcessorData docstring/comment to state this
semantic explicitly.

In `@toolchain/mfc/viz/viz.py`:
- Around line 180-188: The MP4 generation branch calls render_mp4(varname,
requested_steps, mp4_path, ...) but silently returns when render_mp4 returns
False; update the block that checks the return value of render_mp4 to log an
error or warning via cons.print (include context like mp4_path and a hint about
missing dependencies such as imageio) when success is False, so the user is
informed that MP4 was not created; keep the existing success path unchanged and
ensure ARG('mp4'), render_mp4, fps, mp4_path, and read_step are used to form the
diagnostic message.

In `@toolchain/pyproject.toml`:
- Around line 40-43: Add lower-bound pins for the visualization deps and move
them to optional extras: replace the bare "imageio" and "imageio-ffmpeg" entries
with version-bounded requirements (e.g., imageio>=2.33, imageio-ffmpeg>=0.4.9)
and declare them under project.optional-dependencies (e.g., a "viz" extra list)
so users only install the large imageio-ffmpeg binary when they opt into the viz
extra; update any docs or installer scripts to reference pip install mfc[viz] or
to lazily install the "viz" extra when running the viz-related command.

---

Duplicate comments:
In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 139-151: The function discover_timesteps_silo duplicates logic
already present in discover_timesteps(case_dir, fmt='silo') in reader.py; remove
the duplicate by replacing usages of discover_timesteps_silo with a call to
discover_timesteps(case_dir, fmt='silo') or move the implementation into a
shared helper and call that from both modules (update imports and references
accordingly). Ensure you keep the same behavior (filtering p0/*.silo, ignoring
"collection" files and non-integer names) and update any tests/imports to
reference discover_timesteps or the new shared helper instead of
discover_timesteps_silo.
- Around line 204-269: The multi-processor assembly block in silo_reader.py (the
loop building proc_centers, global_x/global_y/global_z, global_vars population
and final AssembledData return) duplicates logic from reader.py's
reader.assemble; extract that logic into a shared helper (e.g.,
assemble_proc_data or assemble_multi_processor) that accepts proc_data and
returns an AssembledData, then replace the duplicated block in silo_reader.py
with a call to that helper; ensure the helper handles ndim detection from
proc_data, uses unique rounding/np.searchsorted semantics, preserves variable
names (variables, x_cc/y_cc/z_cc, nx/ny/nz) and returns AssembledData so callers
(silo_reader.assemble and reader.assemble) can both call it.

---

Nitpick comments:
In `@toolchain/mfc/viz/reader.py`:
- Around line 264-368: The multi-processor assembly logic in assemble()
duplicates assemble_silo(); extract the shared coordinate-merge and
data-placement code into a new helper (suggest name
_assemble_from_proc_data(proc_data, ndim)) that returns (ndim, global_x,
global_y, global_z, global_vars) given proc_data and use it from assemble() and
from assemble_silo() in silo_reader.py so file I/O stays format-specific while
the unique math (np.unique/np.round, initializing arrays by ndim, searchsorted +
np.ix_ placement) is centralized; update assemble() to call
_assemble_from_proc_data(proc_data, ndim) after building proc_data and remove
the duplicated assembly block.
- Around line 116-133: The current auto-detection computes bytes_per_val =
grid_bytes / n_vals and compares to 8.0/4.0 using floats; replace this with
integer arithmetic to avoid FP rounding: compute n_vals (as already done) and
use integer modulo/division on grid_bytes (e.g., grid_bytes % n_vals and
grid_bytes // n_vals) to verify grid_bytes divides evenly and that grid_bytes //
n_vals equals 8 or 4, then set grid_dtype accordingly (use the same endian
f-strings); if it doesn't divide evenly or the quotient is not 8 or 4, raise the
same ValueError with the existing message.
- Around line 200-240: The silo-specific timestep discovery block in
discover_timesteps duplicates logic in silo_reader.discover_timesteps_silo;
replace the entire fmt == 'silo' branch with a call/delegation to
silo_reader.discover_timesteps_silo(case_dir) (and add an import for silo_reader
if missing) and return its result, removing the duplicated code so only
discover_timesteps and discover_timesteps_silo remain as single sources of
truth.
- Around line 296-302: The loop that builds proc_data silently skips missing
files and zero-dimension reads; update the block around fpath/read_binary_file
so it emits a warning whenever a processor is skipped: when
os.path.isfile(fpath) is false log a warning including rank, fpath, step and
var; after calling read_binary_file, if pdata.m == 0 and pdata.n == 0 and
pdata.p == 0 log a warning including rank, fpath and the empty dimensions before
continuing; use the module's logger or the logging.warning API to provide clear,
contextual messages referencing fpath, rank, step, var and pdata so users can
diagnose missing/empty processor files.
- Around line 47-62: The free-standing read_record function is unused and should
either be removed or renamed to _read_record with a comment explaining it's a
convenience wrapper only used for ad-hoc reads; if you keep it, change its
behavior to detect and return endianness (or delegate to the existing
_read_record_endian) and validate the trailing 4-byte marker matches the leading
marker before returning the payload to avoid silent corruption; update
references to use _read_record or remove the function entirely and ensure no
other code relies on read_record.

In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 126-132: Add a brief inline comment next to the Fortran-order
reinterpretation in silo_reader.py (the block that does data =
np.ascontiguousarray(data).ravel().reshape(data.shape, order='F') and assigns
into variables[key]) stating the assumption that the HDF5/Silo dataset shape
preserves the Fortran dimension ordering (nx, ny, nz), and note that if that
ordering ever changes the reshape will silently transpose data; optionally add a
runtime sanity check (shape or known axis order) or a warning log to detect
unexpected dimension-order changes before assigning to variables[key].
- Around line 42-49: The helper _read_silo_object is dead code or duplicated
logic; either remove it or update read_silo_file to call it instead of repeating
type and attribute checks. Locate the two places in read_silo_file where the
code checks isinstance(obj, h5py.Datatype) and accesses obj.attrs["silo"]
(currently duplicated) and replace that block with a call to
_read_silo_object(obj) and handle a None return (skip/raise) the same way the
inline code currently does; alternatively, delete the unused _read_silo_object
function if you prefer to keep the inline checks.

In `@toolchain/mfc/viz/viz.py`:
- Around line 14-34: The single-int branch in _parse_steps doesn't validate
step_arg against available_steps; update the final return path to convert
step_arg to int, check membership against available_steps (or its set) and
return [that_int] only if present, otherwise return an empty list (matching the
range filtering behavior) so callers get consistent, pre-validated timesteps.

In `@toolchain/pyproject.toml`:
- Around line 40-43: Update pyproject.toml to pin lower bounds for the image I/O
packages and move the heavy ffmpeg wheel into an optional extra: add a minimum
version constraint for "imageio" in the dependencies list (e.g.,
"imageio>=2.33") and remove "imageio-ffmpeg" from the main dependencies, then
add an [project.optional-dependencies] (or equivalent) section named something
like "viz-video" containing "imageio-ffmpeg>=0.5.0" so users can opt into the
~60 MiB binary only when they need MP4 generation.

@sbryngelson sbryngelson marked this pull request as ready for review February 22, 2026 02:25
Copilot AI review requested due to automatic review settings February 22, 2026 02:25
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

2 issues found across 20 files

Confidence score: 3/5

  • There is a concrete user-impacting risk: in toolchain/mfc/viz/renderer.py, log-scale rendering can crash when hi is non‑positive, which would abort CLI rendering for some fields.
  • The reported issue appears twice but points to the same underlying log-scale guard gap, so the impact is localized yet real.
  • Pay close attention to toolchain/mfc/viz/renderer.py - log-scale handling for non‑positive data can throw and halt rendering.
Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="toolchain/mfc/viz/renderer.py">

<violation number="1" location="toolchain/mfc/viz/renderer.py:48">
P2: Guard log-scale against non‑positive data. As written, `hi` can be <= 0, causing LogNorm to throw and the CLI to crash for fields with non‑positive values.</violation>

<violation number="2" location="toolchain/mfc/viz/renderer.py:110">
P2: Guard log-scale against non‑positive slice data; `hi` can be <= 0, which makes LogNorm error and aborts rendering.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request adds a built-in visualization command (./mfc.sh viz) to MFC's toolchain, enabling users to render PNG images and MP4 videos directly from post-processed simulation output without requiring external GUI tools like ParaView or VisIt.

Changes:

  • Adds mfc/viz/ package with binary and Silo-HDF5 readers, matplotlib-based renderer, and CLI entry point
  • Integrates new viz command into MFC's CLI system
  • Adds imageio and imageio-ffmpeg dependencies for portable MP4 generation
  • Renames existing mfc.viz to mfc.viz_legacy for backward compatibility
  • Updates documentation with comprehensive usage guide and troubleshooting

Reviewed changes

Copilot reviewed 18 out of 20 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
toolchain/pyproject.toml Adds imageio dependencies for video rendering
toolchain/mfc/viz_legacy.py Legacy visualization API renamed from mfc.viz for backward compatibility
toolchain/mfc/viz/viz.py Main CLI entry point; handles argument parsing and dispatching
toolchain/mfc/viz/reader.py Binary format reader with endianness/precision auto-detection
toolchain/mfc/viz/silo_reader.py Silo-HDF5 format reader using h5py
toolchain/mfc/viz/renderer.py Matplotlib-based rendering for 1D/2D/3D visualizations and MP4 videos
toolchain/mfc/viz/__init__.py Empty package init (new package)
toolchain/mfc/cli/commands.py VIZ_COMMAND definition with arguments and examples
toolchain/mfc/args.py Adds viz to relevant_subparsers list
toolchain/main.py Integrates viz command into dispatch and skips CMake check
examples/*/viz.py, examples/*/export.py, examples/*/analyze.py Updates imports from mfc.viz to mfc.viz_legacy
docs/documentation/visualization.md Comprehensive viz command documentation
docs/documentation/troubleshooting.md Viz-specific troubleshooting section
docs/documentation/running.md Cross-reference to viz command
docs/documentation/getting-started.md Quick start viz examples
docs/documentation/case.md Notes on format compatibility with viz
.typos.toml Adds "PNGs" to allowed words list

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (5)
toolchain/mfc/viz/reader.py (2)

370-379: Add stacklevel=2 to warnings.warn calls.

Per Python best practices (and flagged by Ruff B028), warnings.warn without an explicit stacklevel points the warning at the warn() call itself rather than the caller. stacklevel=2 makes the warning message more useful for debugging.

♻️ Proposed fix
-            warnings.warn(f"Processor file not found, skipping: {fpath}")
+            warnings.warn(f"Processor file not found, skipping: {fpath}", stacklevel=2)
             continue
         pdata = read_binary_file(fpath, var_filter=var)
         if pdata.m == 0 and pdata.n == 0 and pdata.p == 0:
             import warnings  # pylint: disable=import-outside-toplevel
-            warnings.warn(f"Processor p{rank} has zero dimensions, skipping")
+            warnings.warn(f"Processor p{rank} has zero dimensions, skipping", stacklevel=2)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 370 - 379, The two warnings.warn
calls in the loop that builds fpath and checks pdata dimensions should include
stacklevel=2 so the warning is attributed to the caller instead of the warn
call; locate the calls that warn "Processor file not found, skipping: {fpath}"
and "Processor p{rank} has zero dimensions, skipping" (within the loop that
constructs fpath and calls read_binary_file) and add stacklevel=2 to each
warnings.warn invocation.

70-80: Trailing record marker is consumed but not verified.

Line 79 reads the trailing 4-byte marker but doesn't check that it matches the leading marker. For robustness against corrupted files, consider verifying:

trailing = f.read(4)
if len(trailing) == 4:
    trail_len = struct.unpack(f'{endian}i', trailing)[0]
    if trail_len != rec_len:
        raise ValueError(f"Record marker mismatch: {rec_len} vs {trail_len}")

This is optional — most Fortran readers skip this check, and corrupted files are rare.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 70 - 80, In _read_record_endian,
after reading the trailing 4 bytes (currently f.read(4)), read and verify the
trailing marker instead of discarding it: ensure you check the trailing marker
length is 4, unpack it with the same endian as used for the leading marker,
compare the unpacked trail_len to rec_len, and raise a ValueError (e.g., "Record
marker mismatch: {rec_len} vs {trail_len}") if they differ; also raise an
EOFError if the trailing read returns fewer than 4 bytes to mirror the earlier
checks.
toolchain/mfc/viz/viz.py (1)

156-174: First timestep is read twice — once for validation, once for rendering.

read_step_all_vars(requested_steps[0]) at line 169 reads all variables for validation, then the rendering loop reads the same step again (with var filter) at line 204. For large datasets this doubles I/O for the first frame. Consider caching or reusing the already-loaded data for the first frame.

This is a minor optimization and not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/viz.py` around lines 156 - 174, The first timestep is read
twice via read_step_all_vars(requested_steps[0]) for validation and later via
read_step in the rendering loop; change the code to cache test_assembled and
reuse it for the first frame instead of calling read_step again: after computing
test_assembled (using assemble_silo or assemble) store it in a variable (e.g.,
cached_first = test_assembled) and in the rendering loop check if current step
== requested_steps[0] then use cached_first.variables / cached_first data rather
than invoking read_step (which calls assemble_silo/assemble), otherwise call
read_step as before; keep existing symbols read_step, read_step_all_vars,
assemble_silo, assemble, test_assembled, requested_steps, and varname to locate
code.
toolchain/mfc/viz/renderer.py (1)

46-52: Consider extracting the duplicated LogNorm computation into a small helper.

The same pattern — pick lo from vmin or positive-data minimum (fallback 1e-10), pick hi from vmax or data max, then build LogNorm — appears identically in render_2d and render_3d_slice. A tiny helper (e.g., _build_log_norm(data, vmin, vmax)) would remove the duplication and make it easier to evolve (e.g., handling all-NaN data more gracefully).

Also applies to: 108-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/renderer.py` around lines 46 - 52, Extract the duplicated
LogNorm construction into a helper (e.g., _build_log_norm) and call it from
render_2d and render_3d_slice: implement _build_log_norm(data, vmin, vmax) to
compute lo = vmin if provided else np.nanmin(data[data > 0]) if any positive
values else 1e-10, hi = vmax if provided else np.nanmax(data), return
LogNorm(vmin=lo, vmax=hi); then replace the inline blocks that set norm =
LogNorm(...) and vmin = vmax = None with norm = _build_log_norm(data, vmin,
vmax) and clear vmin/vmax as before. Ensure the helper handles NaNs consistently
as in the original logic.
toolchain/mfc/viz/silo_reader.py (1)

158-163: Missing processor files are silently skipped — inconsistent with binary reader.

The binary reader (reader.py line 373) emits warnings.warn(...) when a processor file is not found. Here the silo reader silently continues past missing .silo files. For consistency and debuggability, consider adding a similar warning.

♻️ Suggested fix
         silo_file = os.path.join(base, f"p{rank}", f"{step}.silo")
         if not os.path.isfile(silo_file):
+            import warnings  # pylint: disable=import-outside-toplevel
+            warnings.warn(f"Processor file not found, skipping: {silo_file}", stacklevel=2)
             continue
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/silo_reader.py` around lines 158 - 163, The loop that
builds proc_data in silo_reader.py currently skips missing per-rank files
silently; update it to warn like the binary reader does by emitting
warnings.warn when a constructed silo_file (os.path.join(base, f"p{rank}",
f"{step}.silo")) is not found. Ensure warnings is imported if absent, and
include contextual info (rank, silo_file, step) in the message so callers of
read_silo_file/read_silo_files can diagnose missing processor files; keep the
continue behavior after warning and leave read_silo_file and proc_data usage
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/documentation/visualization.md`:
- Around line 43-47: Update the documentation table entry for the Range `--step`
format to indicate that the end value is inclusive (the code uses range(start,
end + 1, stride)), e.g., change the Description for the Range row (`--step
0:10000:500`) to include "(inclusive)" so readers know the end value is
included.

In `@toolchain/mfc/viz/viz.py`:
- Around line 14-37: The _parse_steps function should validate and handle
malformed step_arg instead of letting int() raise raw ValueError: wrap parsing
of start/end/stride and the single-int conversion in a try/except that raises a
clean ValueError with a descriptive message referencing the provided step_arg
and expected formats (function: _parse_steps, params: step_arg,
available_steps); then update the caller viz to wrap its call to _parse_steps in
a try/except that catches ValueError (when computing requested_steps) and prints
a user-friendly error via cons.print and exits with sys.exit(1).
- Around line 90-94: The code crashes when step_arg == "all": change the branch
handling step_arg so that if step_arg is None or step_arg.lower() == "all" you
set step = steps[0] and call cons.print(f"[dim]Using first available timestep:
{step}[/dim]"); otherwise attempt to parse step = int(step_arg) inside a
try/except catching ValueError and emit a clear error via cons.print (or raise a
user-facing exception) mentioning the invalid step_arg. Update the logic around
the variables step_arg, steps, step and the existing cons.print call to
implement this behavior.

---

Nitpick comments:
In `@toolchain/mfc/viz/reader.py`:
- Around line 370-379: The two warnings.warn calls in the loop that builds fpath
and checks pdata dimensions should include stacklevel=2 so the warning is
attributed to the caller instead of the warn call; locate the calls that warn
"Processor file not found, skipping: {fpath}" and "Processor p{rank} has zero
dimensions, skipping" (within the loop that constructs fpath and calls
read_binary_file) and add stacklevel=2 to each warnings.warn invocation.
- Around line 70-80: In _read_record_endian, after reading the trailing 4 bytes
(currently f.read(4)), read and verify the trailing marker instead of discarding
it: ensure you check the trailing marker length is 4, unpack it with the same
endian as used for the leading marker, compare the unpacked trail_len to
rec_len, and raise a ValueError (e.g., "Record marker mismatch: {rec_len} vs
{trail_len}") if they differ; also raise an EOFError if the trailing read
returns fewer than 4 bytes to mirror the earlier checks.

In `@toolchain/mfc/viz/renderer.py`:
- Around line 46-52: Extract the duplicated LogNorm construction into a helper
(e.g., _build_log_norm) and call it from render_2d and render_3d_slice:
implement _build_log_norm(data, vmin, vmax) to compute lo = vmin if provided
else np.nanmin(data[data > 0]) if any positive values else 1e-10, hi = vmax if
provided else np.nanmax(data), return LogNorm(vmin=lo, vmax=hi); then replace
the inline blocks that set norm = LogNorm(...) and vmin = vmax = None with norm
= _build_log_norm(data, vmin, vmax) and clear vmin/vmax as before. Ensure the
helper handles NaNs consistently as in the original logic.

In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 158-163: The loop that builds proc_data in silo_reader.py
currently skips missing per-rank files silently; update it to warn like the
binary reader does by emitting warnings.warn when a constructed silo_file
(os.path.join(base, f"p{rank}", f"{step}.silo")) is not found. Ensure warnings
is imported if absent, and include contextual info (rank, silo_file, step) in
the message so callers of read_silo_file/read_silo_files can diagnose missing
processor files; keep the continue behavior after warning and leave
read_silo_file and proc_data usage unchanged.

In `@toolchain/mfc/viz/viz.py`:
- Around line 156-174: The first timestep is read twice via
read_step_all_vars(requested_steps[0]) for validation and later via read_step in
the rendering loop; change the code to cache test_assembled and reuse it for the
first frame instead of calling read_step again: after computing test_assembled
(using assemble_silo or assemble) store it in a variable (e.g., cached_first =
test_assembled) and in the rendering loop check if current step ==
requested_steps[0] then use cached_first.variables / cached_first data rather
than invoking read_step (which calls assemble_silo/assemble), otherwise call
read_step as before; keep existing symbols read_step, read_step_all_vars,
assemble_silo, assemble, test_assembled, requested_steps, and varname to locate
code.

@codeant-ai codeant-ai bot added the size:XXL This PR changes 1000+ lines, ignoring generated files label Feb 22, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/documentation/visualization.md (1)

265-265: ⚠️ Potential issue | 🟡 Minor

Stale "last updated" date.

The page footer says 2026-02-13 but this PR is dated 2026-02-22. Consider updating to match.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/documentation/visualization.md` at line 265, Update the stale page
footer date in the HTML footer div (<div style='text-align:center;
font-size:0.75rem; color:`#888`; padding:16px 0 0;'>Page last updated:
2026-02-13</div>) to the current PR date (2026-02-22) so the "Page last updated"
text matches the PR date.
🧹 Nitpick comments (3)
toolchain/mfc/viz/renderer.py (2)

46-56: Duplicated log-scale normalization logic in render_2d and render_3d_slice.

The log-scale guard block (compute lo/hi, clamp non-positive values, construct LogNorm, clear vmin/vmax) is identical in both functions. Consider extracting a small helper like _build_log_norm(data, vmin, vmax) that returns (norm, vmin, vmax).

Not blocking — the duplication is contained to two call sites.

♻️ Example helper extraction
def _build_log_norm(data, vmin, vmax):
    """Build a LogNorm from data, clamping non-positive bounds."""
    lo = vmin if vmin is not None else (np.nanmin(data[data > 0]) if np.any(data > 0) else 1e-10)
    hi = vmax if vmax is not None else np.nanmax(data)
    if hi <= 0:
        hi = 1.0
    if lo <= 0 or lo >= hi:
        lo = hi * 1e-10
    return LogNorm(vmin=lo, vmax=hi), None, None

Then in both render_2d and render_3d_slice:

if log_scale:
    norm, vmin, vmax = _build_log_norm(data, vmin, vmax)

Also applies to: 112-122

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/renderer.py` around lines 46 - 56, Extract the duplicated
log-scale normalization block into a small helper function (e.g.,
_build_log_norm(data, vmin, vmax)) and replace the identical logic in both
render_2d and render_3d_slice with a call that assigns its return values;
specifically, move the lo/hi computation, non-positive clamps, LogNorm
construction, and clearing of vmin/vmax into _build_log_norm so each call
becomes: if log_scale: norm, vmin, vmax = _build_log_norm(data, vmin, vmax)
while keeping the helper signature and return values consistent with the current
usage in renderer.py.

159-181: Auto vmin/vmax uses global 3D range, not slice range — document or note for 3D.

For 3D data, the sampling at lines 173-176 computes nanmin/nanmax over the entire 3D volume, but the rendered frames are 2D slices. The auto-computed color range may be wider than what appears in the slice, potentially producing washed-out colors. This is a reasonable default (consistent color scale across a video), but worth noting. The --vmin/--vmax override covers users who need tighter scaling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/renderer.py` around lines 159 - 181, The auto vmin/vmax
computation currently takes nanmin/nanmax over full 3D volumes (see sample_steps
loop using read_func and d = ad.variables.get(varname) then np.nanmin/np.nanmax)
which can produce a wider color range than individual 2D slices; add a brief
code comment and a user-facing note/warning when d.ndim == 3 (or when sample
data is 3D) explaining that the computed vmin/vmax are global 3D ranges and may
appear washed-out on per-slice renders, and mention that users can override with
--vmin/--vmax (or keep an option to switch to per-slice scaling if desired).
toolchain/mfc/viz/viz.py (1)

120-137: No --step defaults to rendering every available timestep — consider a warning or explicit opt-in.

When step_arg is None (user omits --step), _parse_steps(None, steps) returns all available steps (line 23-24). For simulations with thousands of timesteps this silently generates thousands of PNGs with no confirmation. All documentation examples show --step explicitly.

Consider either requiring --step for rendering or printing a warning like "No --step specified — rendering all N timesteps. Continue? (use --step all to suppress)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/viz.py` around lines 120 - 137, The current flow silently
renders all timesteps when step_arg is None; update the logic around ARG('step')
/ step_arg and _parse_steps to avoid accidental huge renders by detecting when
step_arg is None and _parse_steps would return all steps: after
discover_timesteps(...) compute steps_count, and if step_arg is None then either
(preferred) prompt the user with cons.print asking "No --step specified —
rendering all N timesteps. Continue? (use --step all to suppress)" and abort
(sys.exit(1)) unless the user confirms, or require explicit --step and exit with
that warning; reference ARG, step_arg, _parse_steps, requested_steps,
discover_timesteps and use cons.print for the message and sys.exit on negative
response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@toolchain/mfc/viz/viz.py`:
- Around line 61-68: Wrap the call to discover_format(case_dir) (used when
fmt_arg is falsy) in a try/except that catches FileNotFoundError, and on that
exception print a clean CLI error via cons.print (or similar) including the
exception message and then exit non‑zero (e.g., sys.exit(1)); keep the existing
behavior of using fmt_arg when present, and only apply the try/except around the
discover_format call referenced where fmt_arg and fmt are set.

---

Outside diff comments:
In `@docs/documentation/visualization.md`:
- Line 265: Update the stale page footer date in the HTML footer div (<div
style='text-align:center; font-size:0.75rem; color:`#888`; padding:16px 0 0;'>Page
last updated: 2026-02-13</div>) to the current PR date (2026-02-22) so the "Page
last updated" text matches the PR date.

---

Nitpick comments:
In `@toolchain/mfc/viz/renderer.py`:
- Around line 46-56: Extract the duplicated log-scale normalization block into a
small helper function (e.g., _build_log_norm(data, vmin, vmax)) and replace the
identical logic in both render_2d and render_3d_slice with a call that assigns
its return values; specifically, move the lo/hi computation, non-positive
clamps, LogNorm construction, and clearing of vmin/vmax into _build_log_norm so
each call becomes: if log_scale: norm, vmin, vmax = _build_log_norm(data, vmin,
vmax) while keeping the helper signature and return values consistent with the
current usage in renderer.py.
- Around line 159-181: The auto vmin/vmax computation currently takes
nanmin/nanmax over full 3D volumes (see sample_steps loop using read_func and d
= ad.variables.get(varname) then np.nanmin/np.nanmax) which can produce a wider
color range than individual 2D slices; add a brief code comment and a
user-facing note/warning when d.ndim == 3 (or when sample data is 3D) explaining
that the computed vmin/vmax are global 3D ranges and may appear washed-out on
per-slice renders, and mention that users can override with --vmin/--vmax (or
keep an option to switch to per-slice scaling if desired).

In `@toolchain/mfc/viz/viz.py`:
- Around line 120-137: The current flow silently renders all timesteps when
step_arg is None; update the logic around ARG('step') / step_arg and
_parse_steps to avoid accidental huge renders by detecting when step_arg is None
and _parse_steps would return all steps: after discover_timesteps(...) compute
steps_count, and if step_arg is None then either (preferred) prompt the user
with cons.print asking "No --step specified — rendering all N timesteps.
Continue? (use --step all to suppress)" and abort (sys.exit(1)) unless the user
confirms, or require explicit --step and exit with that warning; reference ARG,
step_arg, _parse_steps, requested_steps, discover_timesteps and use cons.print
for the message and sys.exit on negative response.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (2)
toolchain/mfc/viz/silo_reader.py (2)

147-169: Consider warning on missing per-rank Silo files to avoid “partial assembly” surprises.

In assemble_silo, missing rank files are silently skipped (Lines 161-164). If a run produced incomplete output, users may unknowingly render an incomplete domain. Even a low-noise warning (once per missing file, with stacklevel=2) would help.

Possible tweak
     for rank in ranks:
         silo_file = os.path.join(base, f"p{rank}", f"{step}.silo")
         if not os.path.isfile(silo_file):
+            import warnings  # pylint: disable=import-outside-toplevel
+            warnings.warn(f"Missing Silo rank file, skipping: {silo_file}", stacklevel=2)
             continue
         pdata = read_silo_file(silo_file, var_filter=var)
         proc_data.append((rank, pdata))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/silo_reader.py` around lines 147 - 169, The loop that
iterates ranks and skips missing per-rank files (the block that builds proc_data
by checking os.path.isfile(silo_file) and continue) should emit a low-noise
warning for each missing file instead of silently skipping; use warnings.warn
with a clear message including the missing path and step (and include
stacklevel=2) so users know about partial assemblies, and keep appending
existing files via read_silo_file as before (refer to variables ranks,
silo_file, step, read_silo_file, and proc_data).

121-130: Data reordering is subtle; add/ensure a regression test for axis/order parity vs binary.

The Line 128-130 reshape (ravel() then reshape(..., order='F')) encodes a strong assumption about how MFC writes DBPUTQV1 buffers into HDF5 and how h5py materializes them. If this is wrong, plots will look “plausible” but be transposed/permuted—hard to notice without a known-field test.

I’d strongly recommend a small golden test that:

  • writes a tiny 2D/3D field with a known pattern (e.g., f(i,j,k)=i+10*j+100*k),
  • reads via both read_binary_file and read_silo_file,
  • asserts arrays match exactly (or at least match under the intended orientation).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/silo_reader.py` around lines 121 - 130, The data reshape
logic in silo_reader.py (the data.ndim check that does data =
np.ascontiguousarray(data).ravel().reshape(data.shape, order='F') and assigns
into variables[key]) relies on an assumption about Fortran vs C ordering and can
silently transpose axes; add a regression test that creates a tiny 2D and 3D
golden field (e.g., f(i,j,k)=i+10*j+100*k), writes it with the binary writer
used by read_binary_file and also writes/reads it via read_silo_file (or writes
an HDF5/Silo file that mimics DBPUTQV1 layout), then assert exact equality (or
equality under the intended orientation) between the arrays returned by
read_binary_file and read_silo_file to lock in axis/order parity and catch
future regressions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@toolchain/mfc/viz/reader.py`:
- Around line 55-67: The _detect_endianness function should guard against
empty/truncated files: after reading raw = f.read(4) check if len(raw) < 4 and
raise a clear ValueError (or EOFError) that includes the path argument;
alternatively wrap the struct.unpack calls in a try/except struct.error and
re-raise a ValueError with the filename and a concise message indicating the
file is empty or corrupt. Ensure the raised error mentions the path and keeps
the existing behavior of returning '<' or '>' when detection succeeds.
- Around line 70-82: _in _read_record_endian the trailing Fortran record marker
is read but not validated; change the trailing read to ensure 4 bytes were
returned, unpack it with the same endian (e.g., struct.unpack(f'{endian}i',
trailing)[0]) and compare it to rec_len, and raise an appropriate
EOFError/ValueError if the trailing marker is missing or does not match rec_len
so corrupted/truncated files are detected immediately.
- Around line 85-177: In read_binary_file validate the unpacked header fields
(m, n, p, dbvars) immediately after they are read: ensure m, n, p are
non-negative integers and dbvars is non-negative and within a reasonable cap
(e.g., a max expected vars constant), and raise a clear ValueError if they fail;
use these validated values for subsequent calculations of n_vals and data_size
to avoid bogus allocations and dtype detection failures (check the variables m,
n, p, dbvars, n_vals, data_size, and the function read_binary_file).
- Around line 371-381: The warnings emitted inside the ranks loop (when a
processor file is missing or when pdata has zero dimensions) should include a
stacklevel parameter so the warning points to the caller; update the two
warnings.warn calls inside the loop that trigger for missing fpath and for
pdata.m/n/p == 0 to pass stacklevel=2 (keeping the same message) so users see
the warning originating from their callsite; these occur alongside use of
read_binary_file and variables fpath, pdata, and rank.
- Around line 256-338: The assemble_from_proc_data function silently clips
out-of-range searchsorted indices (np.clip around searchsorted in the
proc_centers loop) which can misplace data — replace clipping with assertions
that xi/yi/zi are within [0, nx-1], [0, ny-1], [0, nz-1] respectively to fail
fast on coordinate mismatches; also build varnames as the union of all
pd.variables across proc_data (instead of using proc_data[0][1].variables) and
initialize global_vars for that union so no per-rank variables are dropped,
keeping the rest of the placement logic (np.ix_ + assignment) unchanged.

In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 43-47: The _resolve_path function must handle numpy.bytes_ values
returned by h5py so the path isn't mangled; update the type check in
_resolve_path to detect and decode numpy.bytes_ (e.g., treat instances of bytes
and numpy.bytes_ the same) or fall back to calling .decode() when available
before constructing the lookup key, then use the decoded string when indexing
h5file (function: _resolve_path).

---

Nitpick comments:
In `@toolchain/mfc/viz/silo_reader.py`:
- Around line 147-169: The loop that iterates ranks and skips missing per-rank
files (the block that builds proc_data by checking os.path.isfile(silo_file) and
continue) should emit a low-noise warning for each missing file instead of
silently skipping; use warnings.warn with a clear message including the missing
path and step (and include stacklevel=2) so users know about partial assemblies,
and keep appending existing files via read_silo_file as before (refer to
variables ranks, silo_file, step, read_silo_file, and proc_data).
- Around line 121-130: The data reshape logic in silo_reader.py (the data.ndim
check that does data = np.ascontiguousarray(data).ravel().reshape(data.shape,
order='F') and assigns into variables[key]) relies on an assumption about
Fortran vs C ordering and can silently transpose axes; add a regression test
that creates a tiny 2D and 3D golden field (e.g., f(i,j,k)=i+10*j+100*k), writes
it with the binary writer used by read_binary_file and also writes/reads it via
read_silo_file (or writes an HDF5/Silo file that mimics DBPUTQV1 layout), then
assert exact equality (or equality under the intended orientation) between the
arrays returned by read_binary_file and read_silo_file to lock in axis/order
parity and catch future regressions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
toolchain/mfc/viz/reader.py (1)

378-392: Move import warnings to module-level imports.

warnings is imported twice inside the loop body (lines 380, 385), suppressed with # pylint: disable=import-outside-toplevel. Moving it to the top-level imports (alongside os, struct, etc.) removes the suppressions and follows PEP 8's import ordering convention.

♻️ Proposed refactor
 import os
 import struct
+import warnings
 from dataclasses import dataclass, field

Then in assemble:

         if not os.path.isfile(fpath):
-            import warnings  # pylint: disable=import-outside-toplevel
             warnings.warn(f"Processor file not found, skipping: {fpath}", stacklevel=2)
             continue
         pdata = read_binary_file(fpath, var_filter=var)
         if pdata.m == 0 and pdata.n == 0 and pdata.p == 0:
-            import warnings  # pylint: disable=import-outside-toplevel
             warnings.warn(f"Processor p{rank} has zero dimensions, skipping", stacklevel=2)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@toolchain/mfc/viz/reader.py` around lines 378 - 392, The code in reader.py
imports warnings inside the loop where files are checked (around the block that
builds proc_data using read_binary_file and appends (rank, pdata)) which causes
duplicate local imports and uses pylint disables; move the import to the
module-level imports (alongside os, struct, etc.), remove the in-function
imports and the "# pylint: disable=import-outside-toplevel" comments, and leave
the warnings.warn calls unchanged (e.g., the two warnings.warn(...) calls in the
loop should continue to use the module-level warnings name); ensure any linter
comments referencing the local import are deleted.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@toolchain/mfc/viz/reader.py`:
- Line 83: The trailing Fortran record marker read (currently just `f.read(4)`)
must be validated: after reading the 4 bytes verify that 4 bytes were returned
and that the integer value equals `rec_len`; on failure raise a descriptive
exception (or return an error) to avoid silent desync. Update the code around
the `f.read(4)` call in reader.py to check the length of the returned bytes,
convert them to an integer (matching the same endianness/format used elsewhere),
compare to `rec_len`, and handle mismatches or short reads by raising an
exception that includes expected vs actual lengths.
- Around line 327-329: The np.clip calls masking out-of-range searchsorted
results should be removed and replaced with explicit bounds checks so coordinate
mismatches are surfaced: after computing xi = np.searchsorted(global_x,
np.round(x_cc, 12)) (and similarly yi/zi), assert or raise if any xi < 0 or xi
>= nx (and yi < 0 or yi >= ny when ndim>=2, zi when ndim>=3) so invalid
insertion indices are not silently mapped to 0 or nx-1; use the existing names
xi, yi, zi, global_x/global_y/global_z, np.round and nx/ny/nz to locate the
lines to change and ensure well-formed data fails fast instead of being
corrupted by clipping.

---

Nitpick comments:
In `@toolchain/mfc/viz/reader.py`:
- Around line 378-392: The code in reader.py imports warnings inside the loop
where files are checked (around the block that builds proc_data using
read_binary_file and appends (rank, pdata)) which causes duplicate local imports
and uses pylint disables; move the import to the module-level imports (alongside
os, struct, etc.), remove the in-function imports and the "# pylint:
disable=import-outside-toplevel" comments, and leave the warnings.warn calls
unchanged (e.g., the two warnings.warn(...) calls in the loop should continue to
use the module-level warnings name); ensure any linter comments referencing the
local import are deleted.

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="toolchain/mfc/viz/silo_reader.py">

<violation number="1" location="toolchain/mfc/viz/silo_reader.py:164">
P2: Skipping missing processor files can silently return incomplete assembled data. For multi-rank cases, a missing rank should be treated as an error to avoid incorrect visualizations.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@codecov
Copy link

codecov bot commented Feb 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 44.04%. Comparing base (ab5082e) to head (e72dfd9).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1233   +/-   ##
=======================================
  Coverage   44.04%   44.04%           
=======================================
  Files          70       70           
  Lines       20499    20499           
  Branches     1993     1993           
=======================================
  Hits         9028     9028           
  Misses      10330    10330           
  Partials     1141     1141           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codeant-ai codeant-ai bot added size:XXL This PR changes 1000+ lines, ignoring generated files and removed size:XXL This PR changes 1000+ lines, ignoring generated files labels Feb 22, 2026
@MFlowCode MFlowCode deleted a comment from github-actions bot Feb 23, 2026
@MFlowCode MFlowCode deleted a comment from github-actions bot Feb 23, 2026
@codeant-ai codeant-ai bot removed the size:XXL This PR changes 1000+ lines, ignoring generated files label Feb 23, 2026
@MFlowCode MFlowCode deleted a comment from coderabbitai bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from coderabbitai bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from coderabbitai bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from cubic-dev-ai bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from github-actions bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from github-actions bot Mar 1, 2026
@MFlowCode MFlowCode deleted a comment from github-actions bot Mar 1, 2026
sbryngelson and others added 2 commits February 28, 2026 19:28
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: 5b8d16e

Files changed: 54

Key files (10 of 54)
  • toolchain/mfc/viz/viz.py (488 lines, new)
  • toolchain/mfc/viz/reader.py (571 lines, new)
  • toolchain/mfc/viz/renderer.py (555 lines, new)
  • toolchain/mfc/viz/interactive.py (738 lines, new)
  • toolchain/mfc/viz/tui.py (722 lines, new)
  • toolchain/mfc/viz/_step_cache.py (57 lines, new)
  • toolchain/mfc/viz/silo_reader.py (159 lines, new)
  • toolchain/mfc/cli/commands.py (+253 lines)
  • toolchain/pyproject.toml (restructured deps)
  • examples/nD_perfect_reactor/analyze.py (rewritten)

Summary

  • Adds a full CLI visualization system (./mfc.sh viz) covering 1D/2D/3D renders, MP4 video, interactive Dash UI, and TUI — a substantial and well-structured addition.
  • Removes the old toolchain/mfc/viz.py (Pandas-based mfc.viz.Case API) and the per-example viz.py scripts that depended on it.
  • Adds unit tests with binary fixture files and integrates them into ./mfc.sh lint.
  • All documentation, CLI registration, and auto-install plumbing are wired up.
  • Multi-rank ghost-cell deduplication uses a domain-relative rounding approach to handle micro-scale and mega-scale domains correctly.

Findings

1. Dependencies are not optional despite PR description saying so —

The PR description says:

Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run

But the actual diff adds them to the main dependencies list:

+    # Visualization (./mfc.sh viz)
+    "matplotlib",
+    "h5py",
+    "imageio>=2.33",
+    "imageio-ffmpeg>=0.5.0",
+    "plotext>=5.2.0",
+    "textual>=0.47.0",
+    "textual-plotext>=0.2.0",
+    "dash>=2.0",
+    "plotly",

dash, plotly, textual, and imageio-ffmpeg are heavy optional GUI/codec packages that most compute-node users will never need. Pulling them into the required dependencies will slow down pip install for every MFC user and introduce unnecessary failure modes (e.g. imageio-ffmpeg requires ffmpeg at runtime; textual requires Python ≥ 3.8 but with OS/terminal constraints).

If the intent is optional extras, the correct fix is:

[project.optional-dependencies]
viz = ["matplotlib", "h5py", "imageio>=2.33", "imageio-ffmpeg>=0.5.0",
       "plotext>=5.2.0", "textual>=0.47.0", "textual-plotext>=0.2.0",
       "dash>=2.0", "plotly", "tqdm"]

…with auto-install logic in viz.py when the command runs.

2. typos removed from pyproject.toml — may break ./mfc.sh spelling

"typos" is removed from the main dependencies. The ./mfc.sh spelling precheck calls the typos binary. If it was previously installed via pip install typos (which ships a binary), removing it here means new environments won't have it, silently breaking ./mfc.sh precheck. Confirm that typos is installed by another means (e.g. apt, Cargo, GitHub Actions step) before removing it from pip deps.

3. 1D silo timestep discovery only checks silo_hdf5/p0/

discover_timesteps for the silo format only scans silo_hdf5/p0/. For consistency with the binary path (which checks binary/root/ first for 1D), the silo path should also check for a silo_hdf5/root/ directory if MFC can write single-rank silo output there. The existing 1D silo fixture uses p0/ so the tests pass, but a real 1D Silo run may differ.

4. Lagrange bubble reader ignores restart offset — documented but silently incorrect —

The existing comment acknowledges: for restarts where t_step_start>0 the lag file starts at 0 but step numbers begin at t_step_start — seeking would overshoot. When a user visualizes a restart run with Lagrange bubbles, the bubble overlay will silently show data from the wrong timestep rather than erroring. Consider emitting a warning (warnings.warn) or checking for the offset condition rather than silently producing wrong output.

5. suppress_callback_exceptions=True in Dash app —

app = Dash(__name__, ..., suppress_callback_exceptions=True)

This silences all Dash callback errors, making it hard to diagnose broken interactions. It is typically used only for dynamic layouts where components appear/disappear after load. If the layout is fully static after run_interactive initializes, removing suppress_callback_exceptions=True would surface any future programming errors. If dynamic callbacks are genuinely needed, a comment explaining why would help reviewers.


Minor / Improvement Opportunities

  • **** — Single-processor fast path uses pd.n > 0 and pd.p > 0 to compute cell centers. This is correct for the binary reader (where n/p are Fortran header values), but the docstring at line 2336 warns that m/n/p semantics differ between binary and silo readers. A clarifying assertion or comment at the fast path would prevent future confusion.
  • ** — TUI on 3D data gives a confusing two-step error**: With many 3D steps, the 3D step-count limit check fires before the "TUI only supports 1D/2D" error. Swapping the order of checks would give a cleaner user-facing message.
  • **** — The _INDEXED_PATTERNS list uses list indexing to get ['u', 'v', 'w'][int(m.group(1)) - 1] for momentum components; this will raise IndexError if the variable index is 0 (Fortran 1-indexed, so shouldn't happen, but worth a guard matching the vel pattern above it which already handles > 3).

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: 9491519
Files changed: 54 (4,837 additions / 494 deletions)

Key new files:

  • toolchain/mfc/viz/viz.py — CLI entry point
  • toolchain/mfc/viz/reader.py — binary format reader + multi-rank assembly
  • toolchain/mfc/viz/silo_reader.py — Silo-HDF5 reader (h5py)
  • toolchain/mfc/viz/renderer.py — matplotlib PNG/MP4 rendering
  • toolchain/mfc/viz/interactive.py — Dash web UI
  • toolchain/mfc/viz/tui.py — Textual terminal UI
  • toolchain/mfc/viz/_step_cache.py — thread-safe bounded FIFO cache
  • toolchain/mfc/viz/test_viz.py — unit tests with checked-in fixtures

Summary

  • Adds ./mfc.sh viz — a pure-Python toolchain addition with no Fortran changes and no blast radius to the CFD solver.
  • Binary reader correctly handles Fortran unformatted record markers, endianness detection, precision auto-detection (float32/float64), and variable-level I/O filtering.
  • Multi-rank assembly uses domain-extent-normalized rounding to deduplicate ghost-cell overlap, with a documented fix for micro/macro scale domains.
  • Step cache uses a threading.Lock with read-before-evict semantics for Dash callbacks. Correct.
  • Well-structured unit tests with fixture data covering 1D/2D/3D × binary/silo combinations.

Findings

1. MEDIUM — pyproject.toml: heavy viz deps added to main dependencies, not optional

The PR description explicitly states:

Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run

But the diff puts everything in the main dependencies section:

# Visualization (./mfc.sh viz)
"imageio>=2.33",
"imageio-ffmpeg>=0.5.0",   # bundles FFmpeg binary (~50 MB)
"textual>=0.47.0",
"textual-plotext>=0.2.0",
"dash>=2.0",
"plotly",
...

toolchain/pyproject.toml — these will be installed for every mfc.sh invocation, not lazily on first viz use. There is no [project.optional-dependencies] section in the diff. Either the PR description is wrong (the dependencies are intentionally always-installed) or the implementation diverged from the design. Worth clarifying, as imageio-ffmpeg alone downloads ~50 MB of FFmpeg binaries for every user regardless of whether they ever visualise output.

2. MEDIUM — examples/nD_perfect_reactor/analyze.py: matplotlib.use('Agg') called after pyplot import

toolchain/mfc/viz/renderer.py does this correctly (use before pyplot), but analyze.py inverts the order:

# analyze.py  (wrong order — lines ~604–609 in the diff)
import matplotlib.pyplot as plt   # ← pyplot imported first
import sys
import matplotlib
matplotlib.use('Agg')             # ← use() called after; has no effect

matplotlib.use() must precede import matplotlib.pyplot. On many environments this silently falls back to the interactive backend, causing a UserWarning or DisplayError when running headlessly (e.g. CI). The fix is to reorder the imports:

import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt

3. LOW — pyproject.toml: "typos" removed from dependencies

toolchain/pyproject.toml line 5704 removes "typos" from the Python dependency list. typos is a Rust binary and was arguably misplaced there (Rust tools are not pip-installable), so the removal may be correct cleanup. However, ./mfc.sh spelling and the precheck CI gate rely on the typos binary. Verify that the CI environment installs typos through another mechanism (e.g. a separate step or bootstrap script) so the spelling precheck does not silently skip.

4. LOW — _step_cache.py: module-level global state; test isolation relies on manual clear()

_cache and _cache_order are module globals shared across all callers in the same process. The clear() API exists, but test_viz.py must call it in tearDown/addCleanup for every test class that exercises the cache. If any test omits this, cache state from one test bleeds into the next, potentially masking read errors or returning stale data. Not a correctness bug in production (single-process TUI or interactive), but worth guarding in tests.


Opportunities (no blockers)

  • The half-precision detection in reader.py raises a helpful ValueError for --mixed builds; consider noting this in the visualization.md troubleshooting section so users with mixed-precision output get a clear pointer.
  • silo_reader.py documents an assumption that Silo preserves Fortran dimension ordering — if a future Silo/HDF5 version changes this, a silent data transposition occurs. A debug-mode shape assertion could catch regressions early.

sbryngelson and others added 2 commits February 28, 2026 19:54
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: a49ceba

Files changed: 54 (4,843 additions / 494 deletions)

Key files:

  • toolchain/mfc/viz/viz.py (+488) — CLI dispatcher
  • toolchain/mfc/viz/reader.py (+571) — binary format reader + multi-rank assembly
  • toolchain/mfc/viz/renderer.py (+555) — PNG/MP4 rendering
  • toolchain/mfc/viz/interactive.py (+738) — Dash web UI
  • toolchain/mfc/viz/tui.py (+722) — Textual terminal UI
  • toolchain/mfc/viz/silo_reader.py (+159) — Silo-HDF5 reader
  • toolchain/mfc/viz/test_viz.py (+868) — unit tests with fixture data
  • toolchain/pyproject.toml — dependency changes

Summary

  • Adds a full-featured ./mfc.sh viz CLI supporting 1D/2D/3D visualization, PNG/MP4 output, interactive Dash UI, and SSH-friendly TUI — no ParaView required.
  • Replaces the old toolchain/mfc/viz.py (silo-only, CSV-based) with a proper multi-format reader that handles Fortran unformatted binary with ghost-cell deduplication across ranks.
  • Removes legacy examples/1D_inert_shocktube/viz.py, examples/1D_reactive_shocktube/viz.py, examples/nD_perfect_reactor/export.py; rewrites analyze.py to use the new API.
  • Good: comprehensive unit tests with checked-in binary/silo fixture data, thorough error messages, endianness auto-detection, half-precision guard (clear error instead of silent garbage).
  • Good: thread-safe step cache with bounded FIFO eviction; read-before-evict so a failed read never discards a valid entry.

Findings

1. pyproject.toml: PR description contradicts implementation (medium)

The PR description states:

Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run

But the actual diff puts all viz deps (dash>=2.0, plotly, textual>=0.47.0, textual-plotext, imageio-ffmpeg, plotext, h5py) into the main mandatory dependencies list — they will be installed on every mfc.sh toolchain bootstrap, including on HPC clusters where users never visualize locally. No [project.optional-dependencies] section appears in the diff. Either the description should be updated, or the deps should actually be made optional (with lazy pip install at first viz invocation, as described).

2. typos silently removed from pyproject.toml (medium)

"typos" is deleted from the mandatory deps with no replacement noted. ./mfc.sh spelling (step 5 of precheck) invokes the typos CLI. If the toolchain previously relied on the typos pip package to supply that binary, removing it will silently break ./mfc.sh spelling on fresh installs. Verify that typos is installed via an alternative path (e.g., pre-installed on CI runners or via a separate install step) before merging.

3. reader.py: Lagrange bubble restart support silently returns wrong data (low-medium)

toolchain/mfc/viz/reader.py (inside read_lag_bubbles_at_step): the inline comment documents that for restart runs (t_step_start > 0), the line-offset calculation would overshoot the correct block, returning data from the wrong simulation time. The function currently returns incorrect data silently in that scenario. Consider emitting a warnings.warn at minimum, or a structured check if the first time value in the file is non-zero.

4. silo_reader.py: F-order reshape assumption (low)

toolchain/mfc/viz/silo_reader.py line ~120:

data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F')

The comment acknowledges: "If a future Silo version reverses the shape, this reshape would silently transpose data." The existing 1D/2D/3D silo fixture tests should catch a regression here if the fixtures were generated from real MFC output, but it is worth confirming the fixtures encode known field values (e.g., a ramp or step function) so a transposition would produce a visible test failure rather than a subtly wrong plot.

5. viz.py: --step last silently promoted to all in interactive/TUI/mp4 modes (low)

toolchain/mfc/viz/viz.py (inside viz()):

if ARG('interactive') or use_tui or ARG('mp4'):
    if step_arg == 'last':
        step_arg = 'all'

The default value of --step is 'last', so this correctly loads all steps for interactive/TUI (where single-step would be useless). However, a user who explicitly passes --step last to intentionally limit the TUI to one step also gets silently promoted to 'all'. This is a minor UX surprise; a comment explaining that explicit last is also promoted (and why) would help.

6. __init__.py: unconditional assemble_silo import (informational)

toolchain/mfc/viz/__init__.py unconditionally imports from silo_reader, which unconditionally imports h5py. This is currently harmless because h5py is in the mandatory deps (see finding 1). But if deps are later moved to optional extras as the PR description intends, this import will break from mfc.viz import discover_timesteps for users without h5py. A lazy import (try/except ImportError) would be more robust.


Opportunities (no blocking issues)

  • : multi-rank assembly writes ghost cells from last rank — for single-rank output the full ghost region (2×buff_size cells) is included in the assembled arrays. For most visualization purposes this is harmless, but users inspecting domain extents may see slightly inflated bounds. A note in the docstring would set expectations.
  • : clears all existing cache entries — the API is correct for the TUI init pattern, but the semantics are non-obvious (the name implies "add one entry", not "reset and add"). Consider renaming to or adding a doc note.

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: 632199416fa8b3c7f1528afd626efe77b10eec7b
Files changed: 55

Key files (top 10)
  • toolchain/mfc/viz/viz.py (new, 488 lines) — CLI entry point
  • toolchain/mfc/viz/reader.py (new, 571 lines) — Binary format reader + assembly
  • toolchain/mfc/viz/renderer.py (new, 555 lines) — matplotlib PNG/MP4 rendering
  • toolchain/mfc/viz/tui.py (new, 722 lines) — Textual terminal UI
  • toolchain/mfc/viz/interactive.py (new, 738 lines) — Dash/Plotly web UI
  • toolchain/mfc/viz/silo_reader.py (new, 159 lines) — HDF5/Silo reader
  • toolchain/mfc/viz/test_viz.py (new, 868 lines) — Unit tests with fixtures
  • toolchain/mfc/viz/_step_cache.py (new, 57 lines) — Thread-safe bounded FIFO cache
  • toolchain/mfc/cli/commands.py (+259 lines) — VIZ_COMMAND definition
  • toolchain/pyproject.toml (modified) — Dependency changes

Summary

  • Substantial, well-structured new feature: the reader, assembler, renderer, TUI, and interactive layers are cleanly separated with good test coverage (fixtures for 1D/2D/3D binary and silo).
  • Multi-rank assembly using normalized relative deduplication is a thoughtful solution to the ghost-cell overlap problem across scales.
  • Thread-safety via _step_cache is correctly handled; read is inside the lock with deferred eviction on success.
  • One design claim in the PR description doesn't match the implementation (viz dependencies). Two additional correctness/UX concerns noted below.

Findings

1. Medium — pyproject.toml: Viz deps in core dependencies, not optional

toolchain/pyproject.toml (added block starting at line ~39):

# Visualization (./mfc.sh viz)
"matplotlib",
"h5py",
"imageio>=2.33",
"imageio-ffmpeg>=0.5.0",
"plotext>=5.2.0",
"textual>=0.47.0",
"textual-plotext>=0.2.0",
"dash>=2.0",
"plotly",

The PR description states: "Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run." The actual diff places every one of these heavy packages in the core dependencies list, so they are installed for all ./mfc.sh users — including on HPC nodes where textual, dash, plotly, and imageio-ffmpeg are not expected.

Either the description should be updated to reflect that these are now core deps, or they should be moved to [project.optional-dependencies] viz with a lazy install triggered by viz.py on first run (e.g. subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'mfc[viz]'])).

2. Medium — pyproject.toml: typos removed but still used by ./mfc.sh spelling

toolchain/pyproject.toml line ~12:

-    "typos",

typos is removed from the pip-managed toolchain dependencies. If ./mfc.sh spelling (CI lint check 5/5) invokes the pip-installed typos binary, this will cause the CI spell-check to regress for clean environments. Verify that typos is available via another route (e.g. pre-installed system binary, Cargo in CI runner) before merging. The .typos.toml is still updated in this PR, confirming the tool is still in use.

3. Low — viz.py: --step last silently promoted to --step all in TUI/interactive modes

toolchain/mfc/viz/viz.py (~line 67 of the new file / diff line ~5499):

if ARG('interactive') or use_tui or ARG('mp4'):
    if step_arg == 'last':
        step_arg = 'all'

A user who runs ./mfc.sh viz dir/ --step last --interactive explicitly requested one timestep but silently gets all timesteps loaded. For a large 3D case this may hit the 50-step cap (_3d_limit) and raise an error that appears unrelated to their intent. A cons.print informing the user of this promotion would aid debuggability, e.g.:

if step_arg == 'last':
    cons.print("[dim]TUI/interactive: expanding --step last to all timesteps[/dim]")
    step_arg = 'all'

4. Low — reader.py: Lagrange bubble restart correctness gap (undocumented in user-facing help)

toolchain/mfc/viz/reader.py (read_lag_bubbles_at_step, inline comment):

# For restarts where t_step_start>0 the lag file starts at 0 but step numbers
# begin at t_step_start — seeking would overshoot; restart support is not yet implemented.

The code comment is accurate and honest. However, the behavior when this case is encountered is to silently return wrong bubble positions (off by t_step_start * nBubs rows), rather than returning None or emitting a warning. Consider adding a runtime guard that detects the mismatch and emits a warnings.warn so users aren't misled.


Minor / Improvement Opportunities

  • renderer.py render_mp4() auto vmin/vmax sampling: Only 3 frames (first, middle, last) are sampled to set the color scale. For shock-tube cases with fast-evolving fields the computed range may clip important features. Consider sampling ~5–10% of frames or documenting this behavior in the CLI help for --mp4.

  • commands.py description block: The quick-start block in VIZ_COMMAND.description shows ./mfc.sh viz case_dir/ (no flag) to launch the TUI, which is correct. However, the PR description's Usage section shows ./mfc.sh viz case_dir/ --tui — there is no --tui flag. Updating the PR description avoids user confusion.

  • silo_reader.py read_silo_file() shape assumption (inline comment): The code correctly notes "If a future Silo version reverses the shape, this reshape would silently transpose data." A shape-validation assertion (e.g. checking data.shape == (m+1, n+1) for 2D) would catch this early rather than silently producing mirrored output.

sbryngelson and others added 2 commits February 28, 2026 20:05
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
TUI and interactive modes have their own UI controls for colormap,
vmin/vmax, log scale, and slice position, so those CLI flags are
only needed for batch rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: d1ccb3d01062880d4599e2fec66b7677e01044c7

Files changed: 55 (key paths)

  • toolchain/mfc/viz/reader.py (+571)
  • toolchain/mfc/viz/interactive.py (+738)
  • toolchain/mfc/viz/renderer.py (+555)
  • toolchain/mfc/viz/tui.py (+722)
  • toolchain/mfc/viz/viz.py (+488)
  • toolchain/mfc/viz/test_viz.py (+868)
  • toolchain/mfc/viz/silo_reader.py (+159)
  • toolchain/mfc/viz/_step_cache.py (+57)
  • toolchain/mfc/cli/commands.py (+259)
  • toolchain/pyproject.toml (+-22)

Summary

  • Adds ./mfc.sh viz — CLI visualization (PNG/MP4/TUI/Dash) built on matplotlib, plotly/dash, and textual; replaces the old seaborn/pandas mfc.viz.Case API.
  • Binary reader handles Fortran unformatted records with endianness detection and precision auto-detection; silo reader parses HDF5-backed Silo via h5py. Multi-rank assembly deduplicates ghost-cell overlap via relative-extent normalization.
  • TUI and interactive mode share a bounded thread-safe FIFO step cache (_step_cache.py).
  • Unit tests with checked-in binary/silo fixture files cover 1D/2D/3D, both formats, multi-rank assembly, step parsing, and label generation.
  • Old toolchain/mfc/viz.py (seaborn-based) removed; nD_perfect_reactor/analyze.py rewritten to use new API.

Findings

1. pyproject.toml:39–68 — Viz deps land in main dependencies, not optional

The PR description says they go into [project.optional-dependencies] viz, but the diff places h5py, imageio, imageio-ffmpeg, plotext, textual>=0.47.0, textual-plotext, dash>=2.0, and plotly directly in the main dependencies = [] array. These packages (~50 MB+ combined) will be installed in every MFC toolchain environment. If optional-by-default is the intent, move them to [project.optional-dependencies] viz and have ./mfc.sh viz trigger pip install '.[viz]' on first use.

2. pyproject.toml:11typos removed from dependencies

typos is removed from dependencies but ./mfc.sh spelling still runs it and .typos.toml is updated in this PR. Verify that typos is bootstrapped via another mechanism; if not, restore the dependency to prevent silent failures in ./mfc.sh precheck.

3. toolchain/mfc/viz/__init__.py:3 — Top-level h5py import via silo_reader

silo_reader.py does import h5py at module level, and __init__.py unconditionally imports from it. If viz deps ever become optional (finding #1), from mfc.viz import discover_timesteps would break even for binary-only users. Making the silo import lazy (inside assemble_silo) would decouple the two paths.

4. reader.py:876–883 — Lagrange bubble restart offset silent wrong result

# restart support is not yet implemented.
skip = 1 + step * nBubs

The limitation is documented, but for a restarted simulation the code silently reads the wrong bubble block rather than failing. Recommend emitting a warnings.warn (or raising) when the first timestamp in the file is non-zero so users aren't quietly misled.

5. silo_reader.py:1587–1588 — Fortran reshape is an untested assumption

data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F')

The comment correctly flags that if HDF5 ever reverses the shape ordering, multi-D silo data will be silently transposed. The fixture files are MFC-generated, so they can't catch this assumption failing. A test that verifies a known spatial gradient in a fixture variable would provide stronger assurance.

6. renderer.py:965–966vel4+ fallback label inconsistency

(r'^vel(\d+)$', lambda m: [r'$u$', r'$v$', r'$w$'][int(m.group(1)) - 1]
 if int(m.group(1)) <= 3 else rf'$v_{m.group(1)}$'),

vel4 and above render as $v_4$ (lowercase v), while vel1–3 render as $u$/$v$/$w$. The inconsistency is minor but potentially confusing alongside the mom pattern which uses $m_N$ for out-of-range indices.

7. interactive.py:1803suppress_callback_exceptions=True

Standard for Dash apps with dynamic IDs, but unexpected exceptions in future callbacks will vanish silently. The current _update callback handles the main error cases with visible UI feedback — just worth noting for future maintenance.


Improvement opportunities

  • TUI step filtering: --step last makes sense for PNG but the TUI appears to load all available steps regardless of --step. Applying the filter consistently (or documenting that TUI ignores --step) would reduce confusion.
  • Documented batch caps not enforced in code: Docs mention 500-step cap for 3D batch and 50-step cap for --interactive, but these are not hard limits in the code. Clarify whether they are hard limits or guidance.
  • _step_cache global state in tests: The module-level cache is shared across all test cases in a process. Tests that exercise TUI/interactive paths should call _step_cache.clear() in setUp/tearDown to prevent cross-test interference.

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: 64257b4

Files changed: 55 (4,848 additions / 496 deletions)

Key files:

  • toolchain/mfc/viz/viz.py — CLI entry point
  • toolchain/mfc/viz/reader.py — binary format reader + multi-rank assembly
  • toolchain/mfc/viz/silo_reader.py — Silo-HDF5 reader
  • toolchain/mfc/viz/renderer.py — matplotlib PNG/MP4 rendering
  • toolchain/mfc/viz/interactive.py — Dash web UI
  • toolchain/mfc/viz/tui.py — Textual terminal UI
  • toolchain/mfc/viz/_step_cache.py — thread-safe bounded FIFO cache
  • toolchain/mfc/viz/test_viz.py — unit tests with checked-in fixtures
  • toolchain/pyproject.toml — dependency changes

Summary

  • Well-architected addition; clean module separation (reader / renderer / interactive / TUI / cache), solid binary format parsing, careful ghost-cell deduplication logic, and good unit-test coverage with checked-in fixtures.
  • The ghost-cell dedup approach (normalize by extent, round to 12 significant digits relative to domain size) is thoughtfully documented and handles both micro- and macro-scale domains correctly.
  • Removing the old mfc.viz.Case API and converting nD_perfect_reactor/analyze.py to the new reader is a clean break. The new analyze.py is much clearer.

Findings

1. PR description contradicts actual dependency placement — important

The PR says:

Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run

But toolchain/pyproject.toml adds them to the regular dependencies list:

# Visualization (./mfc.sh viz)
"matplotlib",
"h5py",
"imageio>=2.33",
"imageio-ffmpeg>=0.5.0",
"plotext>=5.2.0",
"textual>=0.47.0",
"textual-plotext>=0.2.0",
"dash>=2.0",
"plotly",
"tqdm",

These packages (especially textual, plotly, imageio-ffmpeg, and dash) are heavyweight and will be installed for every MFC user, including those who never use ./mfc.sh viz. This meaningfully increases environment setup time on HPC clusters with slow shared filesystems. If optional-deps were the intent, this should be corrected. If always-installed is the intent, the PR description should be updated.

2. --slice-axis / --slice-value / --slice-index silently ignored in --interactive mode — bug

toolchain/mfc/viz/viz.py (~line 395)

render_opts (which contains slice_axis, slice_index, slice_value) is built and validated, but never passed to run_interactive():

if interactive:
    from .interactive import run_interactive
    run_interactive(init_var, requested_steps, read_step,
                    port=int(port), host=str(host),
                    bubble_func=bubble_func)   # <-- render_opts not passed
    return

A user who runs ./mfc.sh viz case_dir/ --slice-axis x --interactive will silently get the default z-midplane instead. Either wire the initial slice axis to run_interactive(), or emit a [yellow]Warning:[/yellow] that these flags are ignored in interactive mode.

3. typos removed from pyproject.toml — needs verification

toolchain/pyproject.toml removes "typos" from dependencies. The ./mfc.sh spelling step calls typos; if CI installs the Rust typos binary via a separate step this is fine, but if any environment relied on the Python typos package being present after pip install, ./mfc.sh precheck will silently break for those users. Please confirm this is intentional and that bootstrap/precheck.sh no longer depends on the pip-installed binary.

4. Lagrange bubble overlay broken for restarted runs — undocumented limitation

toolchain/mfc/viz/reader.py (~line 485)

The code comment says:

# For restarts where t_step_start>0 the lag file starts at 0
# but step numbers begin at t_step_start — seeking would overshoot;
# restart support is not yet implemented.

But has_lag_bubble_evol() will still return True for a restarted run, and the overlay will render silently wrong positions. Consider surfacing a warning when the lag_bubble_evol_*.dat file looks like a restart (i.e., first recorded time != 0), or at minimum adding a note to the docs/troubleshooting section.

5. Minor: X-label assignment in render_1d_tiled is fragile

toolchain/mfc/viz/renderer.py (~line 165)

bottom_row = min(nrows - 1, (n - 1) // ncols) if col < (n % ncols or ncols) else nrows - 2

For n=4, ncols=3 (nrows=2): n % ncols = 1, so only col=0 gets bottom_row = min(1,1)=1; cols 1 and 2 get nrows-2 = 0, placing x-labels on the visible second-column cell in row 0. This is accidentally correct, but the logic is hard to follow and breaks if ncols > nrows. A cleaner approach: for each col, iterate from the last row upward to find the first visible axis.


Improvement opportunities (no action required)

  • _step_cache.py: seed() currently clears the entire cache and inserts one entry. If TUI pre-loads step 0 before launching and the cache is subsequently used by a parallel Dash callback, the single-entry post-seed state could cause a thundering-herd of cache misses. Consider seeding without clearing (or document that seed is intended as a reset).
  • renderer.py: The top-level import imageio means even TUI/PNG paths require imageio to be importable. This is fine given imageio is a regular dep (per finding Error on READE.md #1), but if deps are ever moved to optional, this will need a lazy import.
  • reader.py:discover_timesteps: Returns [] silently when binary/root/ and binary/p0/ are both absent. A warnings.warn here (like the "both formats found" warning) would help users diagnose misconfigured case directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: ced26e4
Files changed: 55 (4,855 additions / 496 deletions)

Key files:

  • toolchain/mfc/viz/viz.py (494 lines, main CLI dispatcher)
  • toolchain/mfc/viz/reader.py (571 lines, binary format reader + multi-rank assembly)
  • toolchain/mfc/viz/tui.py (722 lines, Textual TUI)
  • toolchain/mfc/viz/interactive.py (738 lines, Dash web UI)
  • toolchain/mfc/viz/renderer.py (555 lines, PNG/MP4 output)
  • toolchain/mfc/viz/silo_reader.py (159 lines, Silo-HDF5 reader)
  • toolchain/mfc/viz/_step_cache.py (57 lines, FIFO cache)
  • toolchain/mfc/viz/test_viz.py (868 lines, unit tests)
  • toolchain/pyproject.toml, toolchain/mfc/cli/commands.py, toolchain/main.py

Summary

  • Adds a full ./mfc.sh viz command with TUI (Textual), interactive Dash UI, PNG, and MP4 modes.
  • Binary and Silo-HDF5 readers with ghost-cell-aware multi-rank assembly.
  • Replaces the old toolchain/mfc/viz.py CSV-based Case class API and related example scripts.
  • Removes stale deps (seaborn, colorlover, etc.) and reorganises pyproject.toml.
  • Solid unit tests with checked-in binary/silo fixture data for 1D–3D.
  • The assembly logic (normalised deduplication, np.ix_ placement) is well-reasoned and documented.

Findings

P1 — Correctness / breakage

1. typos removed from pyproject.toml with no replacement (toolchain/pyproject.toml diff, - "typos",)

./mfc.sh spelling and ./mfc.sh precheck (step 5/5) call the typos CLI. That binary was previously installed via the Python venv dependency. It is removed here without being added to bootstrap scripts or documented as a system requirement. Fresh virtualenv installs will fail the spell-check step. If it is now assumed to be a system-level tool, that assumption should be documented in CLAUDE.md / README and added to the CI bootstrap.

2. PR description says deps are optional — they are not (toolchain/pyproject.toml)

The PR description states: "Viz-specific dependencies moved to [project.optional-dependencies] viz — not installed by default, auto-installed on first ./mfc.sh viz run." The actual diff adds all viz deps (dash, textual, textual-plotext, h5py, imageio, imageio-ffmpeg, plotly, plotext) directly to the main dependencies list. Heavy deps that require a C compiler (h5py) and bundled ffmpeg (imageio-ffmpeg) are now forced on every MFC user, including HPC users who just want to build and run. If the intent is optional/lazy install, the [project.optional-dependencies] wiring needs to be implemented; if the intent is eager install, the PR description should be corrected.


P2 — Behavioural

3. Silent step='last' → 'all' override breaks large-3D default (toolchain/mfc/viz/viz.py)

# viz.py  (TUI + interactive branch)
if ARG('interactive') or use_tui or ARG('mp4'):
    if step_arg == 'last':
        step_arg = 'all'       # silently loads EVERY timestep

The CLI default for --step is 'last'. When launching the TUI (./mfc.sh viz case_dir/) the default is quietly promoted to 'all'. For a 3D case with >500 saved steps the user immediately hits the error "Refusing to load N timesteps for 3D data (limit is 500)", with no guidance that --step last or --step 0:10000:500 would work. The help text for --step does not mention this override. At minimum, emit a cons.print when the promotion happens; better yet, keep 'last' as the TUI default unless the user explicitly passes a range.

4. Lagrange bubble reader gives wrong results for restarted simulations, silently (toolchain/mfc/viz/reader.py:read_lag_bubbles_at_step)

# reader.py  ~line 548
# MFC writes one bubble block per simulation timestep...
# For restarts where t_step_start>0 the lag file starts at 0
# but step numbers begin at t_step_start — seeking would overshoot;
# restart support is not yet implemented.
skip = 1 + step * nBubs

The comment acknowledges that restart simulations will silently produce incorrect bubble overlays (off-by-t_step_start * nBubs rows). Since post-processing of restarted cases is common, this should either be detected and warned about (e.g., by reading the first time-value and comparing against step * dt) or flagged clearly in the help output. Silent wrong overlays are harder to notice than an explicit warning.

5. Silo reshape assumption could silently transpose 2-D/3-D data (toolchain/mfc/viz/silo_reader.py:read_silo_file)

if data.ndim >= 2:
    data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F')
# Assumption: Silo/HDF5 preserves the Fortran dimension ordering
# (nx, ny, nz) as the dataset shape.  If a future Silo version
# reverses the shape, this reshape would silently transpose data.

This assumption is currently unverified in the test suite (the 2D/3D silo tests check ndim and grid size, not that data[i,j] corresponds to the correct (x_i, y_j) cell). A lightweight cross-check — e.g., compare the assembled silo and binary results for the same fixture step and assert that max difference is below a tolerance — would catch a regression here.


P3 — Minor

6. fps declared as type=int in CLI but used as float (toolchain/mfc/cli/commands.py + toolchain/mfc/viz/viz.py)

commands.py declares Argument(name="fps", type=int, ...); viz.py uses fps = float(ARG('fps')). The coercion is safe but type=float in commands.py would make the interface self-consistent and allow fractional fps (e.g. --fps 0.5 for slow-motion review).

7. _step_cache FIFO uses list.pop(0) (toolchain/mfc/viz/_step_cache.py:load)

list.pop(0) is O(n). With CACHE_MAX=50 this is negligible, but collections.deque(maxlen=CACHE_MAX) would be idiomatic and eliminate the need to manage _cache_order separately.

8. render_mp4 reads every frame PNG twice (toolchain/mfc/viz/renderer.py:render_mp4)

A first pass over all frame files finds the max dimensions; a second pass encodes. For a 500-frame render this doubles PNG I/O. Fixing the output figsize (and therefore pixel dimensions) at the start of the render loop would eliminate the first pass entirely.


Overall assessment

The core reading/assembly logic and the TUI/interactive UI are well-implemented and well-tested for the happy path. The two blocking items are the typos dependency removal (breaks precheck) and the optional-deps promise not matching reality (forces heavy deps on all users). The silent step-override and Lagrange-bubble-restart issues are user-facing correctness concerns worth addressing before merge.

@sbryngelson sbryngelson marked this pull request as ready for review March 1, 2026 02:34
@sbryngelson sbryngelson merged commit b5486c9 into MFlowCode:master Mar 1, 2026
18 of 19 checks passed
@qodo-code-review
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Concurrency

The cache load() holds _lock while executing read_func(step). If read_func performs slow I/O (or can block on network FS), this serializes all Dash callbacks and may make the interactive UI feel frozen; it also increases the risk of deadlocks if read_func indirectly triggers code paths that attempt to re-enter cache operations. Consider reading outside the lock with a per-step in-flight mechanism (or double-checked locking) so unrelated steps/callbacks are not blocked.

def load(step: int, read_func: Callable) -> object:
    """Return cached data for *step*, calling *read_func* on a miss.

    read_func is called *before* eviction so that a failed read (e.g. a
    missing or corrupt file) does not discard a valid cache entry.
    """
    with _lock:
        if step in _cache:
            return _cache[step]
        # Read outside-the-lock would allow concurrent loads of the same
        # step; keeping it inside is simpler and safe since read_func is
        # plain file I/O that never calls load() recursively.
        data = read_func(step)
        # Evict only after a successful read.
        if len(_cache) >= CACHE_MAX:
            evict = _cache_order.pop(0)
            _cache.pop(evict, None)
        _cache[step] = data
        _cache_order.append(step)
        return data
Possible Issue

render_1d() enables ax.set_yscale('log') without guarding against non-positive or non-finite values. If the selected variable contains zeros/negatives/NaNs (common for some derived fields), matplotlib log scaling can raise or silently produce misleading plots. Align behavior with the 2D/3D log-scale handling by computing a safe positive range (or masking non-positive values) and emitting a clear error when the data is not log-plottable.

def render_1d(x_cc, data, varname, step, output, **opts):  # pylint: disable=too-many-arguments,too-many-positional-arguments
    """Render a 1D line plot and save as PNG."""
    fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 6)))
    label = pretty_label(varname)
    ax.plot(x_cc, data, linewidth=1.5)
    ax.set_xlabel(r'$x$')
    ax.set_ylabel(label)
    ax.set_title(f'{label} (step {step})')
    ax.grid(True, alpha=0.3)
    ax.ticklabel_format(axis='y', style='sci', scilimits=(-3, 4), useMathText=True)

    log_scale = opts.get('log_scale', False)
    if log_scale:
        ax.set_yscale('log')
    vmin = opts.get('vmin')
    vmax = opts.get('vmax')
    if vmin is not None or vmax is not None:
        ax.set_ylim(vmin, vmax)
Robustness

run_interactive() assumes steps is non-empty and immediately indexes steps[0]. If the user points at an empty or partially-written case directory (or filters steps to none), this will crash before showing a helpful message. Consider validating steps early and raising a user-facing exception with a hint (e.g., list discovered steps / expected directories).

# Load first step to know dimensionality and available variables
init = _load(steps[0], read_func)
ndim = init.ndim
all_varnames = sorted(init.variables.keys())
if varname not in all_varnames:
    varname = all_varnames[0] if all_varnames else varname

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: e72dfd95d5405a2074054775431c4179d5994091
Files changed: 55 (key paths below)

  • toolchain/mfc/viz/reader.py (+571)
  • toolchain/mfc/viz/interactive.py (+738)
  • toolchain/mfc/viz/tui.py (+722)
  • toolchain/mfc/viz/renderer.py (+555)
  • toolchain/mfc/viz/viz.py (+494)
  • toolchain/mfc/viz/test_viz.py (+868)
  • toolchain/mfc/viz/_step_cache.py (+57)
  • toolchain/mfc/viz/silo_reader.py (+159)
  • toolchain/mfc/cli/commands.py (+260)
  • toolchain/mfc/viz/__init__.py (+3)

Summary

  • Adds a comprehensive ./mfc.sh viz CLI covering 1D/2D/3D data, binary and Silo-HDF5 formats, PNG/MP4 rendering, an interactive Dash web UI, and a terminal TUI — a substantial and well-structured addition.
  • Multi-processor assembly uses coordinate-normalized deduplication (_dedup / _norm_round) which is robust to both micro-scale and macro-scale domains.
  • Thread-safe bounded FIFO cache (_step_cache.py) is correctly protected by a Lock and reads before eviction, preventing stale-eviction on read failure.
  • Optional dependencies are auto-installed on first use and kept in [project.optional-dependencies] viz, keeping the default install lightweight.
  • Old mfc.viz.Case API and dependent example scripts are removed in the same PR — clean break with no dead code left behind.

Findings

1. Viz unit tests added to lint.sh may break CI in minimal environments
toolchain/bootstrap/lint.sh (line +886):

python3 -m unittest mfc.viz.test_viz -v

The viz tests require optional deps (h5py, imageio, matplotlib, plotly, textual) that are not installed in the base toolchain environment. If test_viz.py does not guard with unittest.skip on import failures, this will cause lint to fail in environments that haven't run ./mfc.sh viz first. Recommend wrapping imports in try/except ImportError with @unittest.skipIf decorators, or only importing them lazily per test.

2. _nBubs_per_step is cached by file path but not invalidated on restart
toolchain/mfc/viz/reader.py, @lru_cache(maxsize=32) on _nBubs_per_step:
The cached bubble count is keyed on file path only. The code comment correctly notes that restart support is not implemented, but if a user deletes and rewrites the lag file while the process is running the cached count silently poisons the step index arithmetic. Consider keying on (path, os.path.getmtime(path)) or documenting that the cache must be invalidated between runs.

3. Lagrange bubble step alignment is not verified after seeking
toolchain/mfc/viz/reader.py, read_lag_bubbles_at_step (~line 2883):

skip = 1 + step * nBubs

After seeking, no check is made that the first column (simulation time or step index) of the rows read actually corresponds to step. If nBubs is wrong for any reason (e.g., variable-count RK stages, partially written file from a crash), the function silently returns data from the wrong time block. A sanity check on the time column against the expected step number (or an assert that the row count matches nBubs) would make misalignment detectable.

4. pyproject.toml raises minimum Python to 3.10 — verify CI coverage
toolchain/pyproject.toml:
requires-python = ">=3.10" was added. MFC clusters with older system Python (e.g., RHEL 8 defaults at 3.6–3.8, OLCF Frontier at 3.10) should be validated. The viz subcommand is the only consumer of 3.10-only features (match-case? structural pattern matching?) — if those features are confined to mfc/viz/, it may be possible to defer the global minimum bump and instead gate viz on a runtime version check.

5. Silent matplotlib.use('Agg') failure in renderer.py
toolchain/mfc/viz/renderer.py (~line 2931):

try:
    matplotlib.use('Agg')
except ValueError:
    pass

If matplotlib is already initialized (e.g., in a test that imported matplotlib before renderer), the backend remains whatever was set earlier and renders may open GUI windows or fail silently. The ValueError is expected only when the backend is already set; the correct response is usually to check matplotlib.get_backend() and warn the user, not silently continue.

6. _INDEXED_PATTERNS label for index 0 would return wrong label (edge case)
toolchain/mfc/viz/renderer.py (~line 2965):

(r'^vel(\d+)$', lambda m: [r'$u$', r'$v$', r'$w$'][int(m.group(1)) - 1] ...)

If varname == 'vel0', int('0') - 1 == -1 and the list index wraps to '$w$'. MFC indices begin at 1 so this is unlikely in practice, but it will silently produce a wrong label rather than raising an error. A guard (if int(m.group(1)) >= 1) or an explicit bounds check would make the failure obvious.


Improvement Opportunities (no blocking issues)

  • discover_timesteps for silo only scans p0/: if p0/ is absent but other ranks exist, the function returns []. A fallback scan of any p<N>/ directory would be more robust.
  • ProcessorData.m/n/p semantics differ between binary and silo readers (documented in the dataclass docstring). Consider enforcing a canonical convention (e.g., m = cell count) at construction time to reduce future confusion.
  • Cache state is not cleared between consecutive ./mfc.sh viz invocations in the same process (relevant for tests). _step_cache.clear() exists but is not called in viz.viz() on entry. Tests that run multiple viz invocations may observe cross-test cache contamination.

Comment on lines +500 to +508
def _advance_step(_, current, loop_val):
try:
idx = steps.index(current)
except ValueError:
idx = 0
nxt = idx + 1
if nxt >= len(steps):
return steps[0] if ('loop' in (loop_val or [])) else no_update
return steps[nxt]
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Fix a bug in _advance_step that causes an infinite loop during playback. Modify the function to stop the playback timer when it reaches the end of the steps and looping is disabled. [possible issue, importance: 8]

Suggested change
def _advance_step(_, current, loop_val):
try:
idx = steps.index(current)
except ValueError:
idx = 0
nxt = idx + 1
if nxt >= len(steps):
return steps[0] if ('loop' in (loop_val or [])) else no_update
return steps[nxt]
@app.callback(
Output('step-sel', 'value'),
Output('playing-st', 'data', allow_duplicate=True),
Input('play-iv', 'n_intervals'),
State('step-sel', 'value'),
State('loop-chk', 'value'),
prevent_initial_call=True,
)
def _advance_step(_, current, loop_val):
try:
idx = steps.index(current)
except ValueError:
idx = 0
nxt = idx + 1
if nxt >= len(steps):
if 'loop' in (loop_val or []):
return steps[0], no_update
return no_update, False # Stop playback
return steps[nxt], no_update

)

# Load first step to know dimensionality and available variables
init = _load(steps[0], read_func)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Prevent a potential IndexError in run_interactive by checking if the steps list is empty before accessing its first element. [possible issue, importance: 6]

Suggested change
init = _load(steps[0], read_func)
if not steps:
from mfc.common import MFCException
raise MFCException("No steps provided for visualization")
init = _load(steps[0], read_func)

Comment on lines +695 to +696
# Preload first step to discover variables
first = _load(steps[0], read_func)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Prevent a potential IndexError in run_tui by checking if the steps list is empty before accessing its first element. [possible issue, importance: 6]

Suggested change
# Preload first step to discover variables
first = _load(steps[0], read_func)
# Preload first step to discover variables
if not steps:
raise MFCException("No steps provided for TUI visualization")
first = _load(steps[0], read_func)

Comment on lines +431 to +440
def _cleanup():
for fname in sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')):
try:
os.remove(os.path.join(viz_dir, fname))
except OSError:
pass
try:
os.rmdir(viz_dir)
except OSError:
pass
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Replace the manual file and directory removal in the _cleanup function with a single, more robust call to shutil.rmtree(viz_dir, ignore_errors=True). [general, importance: 6]

Suggested change
def _cleanup():
for fname in sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')):
try:
os.remove(os.path.join(viz_dir, fname))
except OSError:
pass
try:
os.rmdir(viz_dir)
except OSError:
pass
def _cleanup():
import shutil # pylint: disable=import-outside-toplevel
shutil.rmtree(viz_dir, ignore_errors=True)

Comment on lines +376 to +382
def _dedup(arr):
extent = arr.max() - arr.min()
if extent > 0:
origin = arr.min()
norm = np.round((arr - origin) / extent, 12)
return origin + np.unique(norm) * extent, origin, extent
return np.unique(arr), arr.min(), 0.0
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: In the _dedup function, filter out non-finite values from the input array arr before calculating extent to prevent potential division-by-zero errors. [possible issue, importance: 8]

Suggested change
def _dedup(arr):
extent = arr.max() - arr.min()
if extent > 0:
origin = arr.min()
norm = np.round((arr - origin) / extent, 12)
return origin + np.unique(norm) * extent, origin, extent
return np.unique(arr), arr.min(), 0.0
def _dedup(arr):
finite_arr = arr[np.isfinite(arr)]
if finite_arr.size == 0:
return np.array([]), 0.0, 0.0
extent = finite_arr.max() - finite_arr.min()
if extent > 0:
origin = finite_arr.min()
norm = np.round((finite_arr - origin) / extent, 12)
return origin + np.unique(norm) * extent, origin, extent
return np.unique(finite_arr), finite_arr.min(), 0.0

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 1, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ab5082e and e72dfd9.

⛔ Files ignored due to path filters (12)
  • toolchain/mfc/viz/fixtures/1d_binary/binary/p0/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary/binary/p0/1.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary/binary/p0/2.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary/binary/root/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary/binary/root/1.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary/binary/root/2.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p0/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p1/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/2d_binary/binary/p0/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/2d_binary/binary/p0/1.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/3d_binary/binary/p0/0.dat is excluded by !**/*.dat
  • toolchain/mfc/viz/fixtures/3d_binary/binary/p0/1.dat is excluded by !**/*.dat
📒 Files selected for processing (43)
  • .typos.toml
  • CLAUDE.md
  • docs/documentation/case.md
  • docs/documentation/getting-started.md
  • docs/documentation/running.md
  • docs/documentation/troubleshooting.md
  • docs/documentation/visualization.md
  • examples/1D_inert_shocktube/viz.py
  • examples/1D_reactive_shocktube/viz.py
  • examples/nD_perfect_reactor/README.md
  • examples/nD_perfect_reactor/analyze.py
  • examples/nD_perfect_reactor/export.py
  • toolchain/bootstrap/lint.sh
  • toolchain/main.py
  • toolchain/mfc/args.py
  • toolchain/mfc/cli/commands.py
  • toolchain/mfc/cli/docs_gen.py
  • toolchain/mfc/user_guide.py
  • toolchain/mfc/viz.py
  • toolchain/mfc/viz/__init__.py
  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/0.silo
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/1.silo
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/2.silo
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_0.silo
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_1.silo
  • toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_2.silo
  • toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/0.silo
  • toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/1.silo
  • toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_0.silo
  • toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_1.silo
  • toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/0.silo
  • toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/1.silo
  • toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_0.silo
  • toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_1.silo
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/renderer.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/test_viz.py
  • toolchain/mfc/viz/tui.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

📝 Walkthrough

Walkthrough

This change introduces a comprehensive rewrite of MFC's visualization system. It replaces the previous file-based Python visualization scripts with a unified, CLI-integrated visualization infrastructure accessible via ./mfc.sh viz. The new system supports both binary (format=2) and Silo-HDF5 (format=1) post-processed output formats, offering multiple rendering modes: terminal UI, interactive web dashboard, static PNG images, and MP4 videos. Core components include data readers, rendering engines for 1D/2D/3D slice outputs, and a step cache for performance. Documentation is expanded with visualization workflows, and example scripts are consolidated. Dependencies are reorganized to support the new interactive and rendering libraries.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions
Copy link

github-actions bot commented Mar 1, 2026

Claude Code Review

Head SHA: e72dfd9
Files changed: 55 (4,855 additions / 496 deletions)

Key files:

  • toolchain/mfc/viz/reader.py (571 lines — binary reader + multi-rank assembly)
  • toolchain/mfc/viz/interactive.py (738 lines — Dash UI)
  • toolchain/mfc/viz/tui.py (722 lines — terminal UI)
  • toolchain/mfc/viz/renderer.py (555 lines — matplotlib PNG + MP4)
  • toolchain/mfc/viz/viz.py (494 lines — CLI entry point)
  • toolchain/mfc/viz/_step_cache.py (57 lines — FIFO cache)
  • toolchain/mfc/viz/test_viz.py (868 lines — unit tests)
  • toolchain/mfc/cli/commands.py (260 additions — VIZ_COMMAND)

Summary:

  • Adds a full-featured built-in visualization pipeline (./mfc.sh viz) replacing the old mfc.viz.Case API
  • Multi-format support (binary + Silo-HDF5), multi-rank assembly with ghost-cell deduplication, 1D/2D/3D rendering
  • Three output modes: TUI (default), Dash interactive, headless PNG/MP4
  • Thread-safe bounded FIFO step cache shared between TUI and interactive server
  • Removes old mfc.viz module and example scripts; updates docs

Findings

Bug: matplotlib.use('Agg') called after import matplotlib.pyplot in analyze.py

examples/nD_perfect_reactor/analyze.py, lines ~601-609 (diff context):

from mfc.viz import assemble_silo, discover_timesteps
from tqdm import tqdm
import cantera as ct
import matplotlib.pyplot as plt   # ← pyplot imported here
import sys

import matplotlib
matplotlib.use('Agg')             # ← use() AFTER pyplot import — has no effect

Once matplotlib.pyplot is imported, matplotlib.use() is a no-op (raises UserWarning in recent matplotlib, silently ignored in older versions). The script will use whatever default backend is active, which may fail in headless environments. Fix: move import matplotlib; matplotlib.use('Agg') before the matplotlib.pyplot import (as is done correctly in renderer.py).


Medium: Lock held during file I/O in _step_cache.load()

toolchain/mfc/viz/_step_cache.py, lines 1451-1464:

def load(step: int, read_func: Callable) -> object:
    with _lock:                     # ← lock held for entire read
        if step in _cache:
            return _cache[step]
        data = read_func(step)      # ← can be slow (large 3D .dat files)
        ...

The code comment notes the tradeoff. For large 3D datasets this means concurrent Dash callbacks (multiple browser tabs) will serialize on disk I/O, potentially freezing all browser tabs for the duration of a single read. The fix would be to drop the lock before calling read_func and re-acquire to populate the cache, with a double-check after re-acquiring (standard double-checked locking). This is a usability concern rather than a correctness bug.


Low: assemble_from_proc_data single-processor fast path uses pd.n > 0 with silo semantics inconsistency

toolchain/mfc/viz/reader.py, lines 2682-2686:

x_cc = (pd.x_cb[:-1] + pd.x_cb[1:]) / 2.0
y_cc = (pd.y_cb[:-1] + pd.y_cb[1:]) / 2.0 if pd.n > 0 else np.array([0.0])
z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0])
ndim = 1 + (pd.n > 0) + (pd.p > 0)

The ProcessorData docstring correctly flags that binary vs. silo readers populate m/n/p differently. For silo: m = cell count = len(x_cb) - 1; for binary: m = Fortran header value, x_cb has m+2 elements. ndim is derived from pd.n > 0 / pd.p > 0, which works correctly in both cases since both readers set n=0/p=0 for inactive dimensions. The inconsistency is real but benign here; the existing docstring warning is sufficient.


Low: Lagrange bubble reader assumes t_step_start=0 for restarts

toolchain/mfc/viz/reader.py, lines 2877-2883:

# For restarts where t_step_start>0 the lag file starts at 0
# but step numbers begin at t_step_start — seeking would overshoot;
# restart support is not yet implemented.
skip = 1 + step * nBubs

This is clearly documented. No action needed unless restart support is added.


Informational: export.py removed without a CSV export replacement

examples/nD_perfect_reactor/export.py is deleted with no equivalent. Users who used the CSV export workflow would need to write their own reader using the new mfc.viz API. Consider adding a brief note in the README about how to reproduce the CSV export using the new public API (assemble_silo + pandas to CSV).


Informational: suppress_callback_exceptions=True in Dash app

toolchain/mfc/viz/interactive.py, line 1804:

app = Dash(__name__, title=..., suppress_callback_exceptions=True)

This is required for the dynamic slice/iso/volume control panels (hidden div IDs not in initial layout), but it also suppresses legitimate programming errors in callbacks. Acceptable given the controlled scope, but worth noting for future debugging.


Overall: This is a substantial, well-structured addition. The binary format reader is robust (endian detection, precision auto-detection, Fortran record validation, var_filter for skip-seeking). The ghost-cell deduplication via normalized rounding is clever and correctly handles both micro-scale and large-scale domains. The analyze.py matplotlib backend ordering issue is the only correctness bug found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Review effort 4/5 size:XXL This PR changes 1000+ lines, ignoring generated files

Development

Successfully merging this pull request may close these issues.

2 participants