Skip to content

Improve interactive visualization server performance and UI#1293

Merged
sbryngelson merged 5 commits intoMFlowCode:masterfrom
sbryngelson:interactive-viz
Mar 6, 2026
Merged

Improve interactive visualization server performance and UI#1293
sbryngelson merged 5 commits intoMFlowCode:masterfrom
sbryngelson:interactive-viz

Conversation

@sbryngelson
Copy link
Copy Markdown
Member

  • Add bounded FIFO step cache (_step_cache.py) with prefetch support
  • Add turbojpeg/Pillow JPEG encoding with LUT-based colormap for 2D
  • Use Dash Patch() for partial browser updates in 2D and 3D modes
  • Add server-side marching cubes for 3D isosurface rendering
  • Add downsampled 3D array cache for iso/volume/slice modes
  • Parallelize MPI rank file reads in native binary reader
  • Fix colormap application (was rendering grayscale)
  • Fix 3D axis rescaling when adjusting isosurfaces
  • Fix slice mode not advancing timesteps
  • Improve colorbar layout (bounded, two-digit scientific notation)
  • Fix dark theme styling for dropdowns and radio buttons (Dash 4)
  • Add float32 mesh data to halve 3D JSON payload size

- Add bounded FIFO step cache (_step_cache.py) with prefetch support
- Add turbojpeg/Pillow JPEG encoding with LUT-based colormap for 2D
- Use Dash Patch() for partial browser updates in 2D and 3D modes
- Add server-side marching cubes for 3D isosurface rendering
- Add downsampled 3D array cache for iso/volume/slice modes
- Parallelize MPI rank file reads in native binary reader
- Fix colormap application (was rendering grayscale)
- Fix 3D axis rescaling when adjusting isosurfaces
- Fix slice mode not advancing timesteps
- Improve colorbar layout (bounded, two-digit scientific notation)
- Fix dark theme styling for dropdowns and radio buttons (Dash 4)
- Add float32 mesh data to halve 3D JSON payload size

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

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: 4ce5fd8

Files changed: 5

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py

Summary

  • Adds bounded FIFO prefetch cache and background thread pool for step and JPEG pre-encoding, with correct double-checked locking patterns throughout.
  • Replaces go.Heatmap with go.Image + LUT colormap for 2D rendering; replaces go.Isosurface with server-side skimage marching cubes for 3D.
  • Uses Dash Patch() for partial browser updates on step/range changes (both 2D and 3D), avoiding full figure reconstruction.
  • Parallelises MPI rank file reads (binary and silo) using ThreadPoolExecutor.
  • Caches silo HDF5 file structure per rank directory to skip expensive metadata iteration on subsequent timesteps.

Findings

1. Dead code: unreachable slice branch inside 3D patch path — interactive.py (around the _do_patch_3d block)

_do_patch_3d is only True when mode == 'isosurface' or mode == 'volume' (see _PT_ISO/_PT_VOL). The if mode == 'slice': branch immediately inside that block is therefore unreachable. A comment already documents that slice always does a full render, but the dead branch creates confusion and will silently fail to patch if the condition is ever relaxed:

if _do_patch_3d:
    ...
    if mode == 'slice':   # never True — _do_patch_3d excludes slice
        ...
    elif mode == 'isosurface':

Recommendation: Remove the unreachable slice branch (or add an assert mode != 'slice' guard).


2. Stale vmin/vmax UI inputs after variable switch — interactive.py (~line 931 area)

The PR removes 'var-sel.value' from _reset_range's inputs so that the vmin/vmax dcc.Input components are no longer cleared in the browser when the variable selector changes. Instead, the _update callback nulls out the values internally for that one render:

if 'var-sel' in trig:
    vmin_in = vmax_in = None

The browser inputs still display the old values. The next user interaction (e.g., a step change) will re-read those stale inputs and apply the previous variable's color range to the new variable — until the user manually clicks Reset.

Recommendation: Either keep 'var-sel.value' as an input to _reset_range (original behavior), or add Output('vmin-inp', 'value') / Output('vmax-inp', 'value') to the _update callback and return None, None on variable-change triggers.


3. Missing scikit-image import guard — interactive.py (_compute_isomesh)

The turbojpeg→Pillow fallback is handled gracefully with a try/except ImportError. skimage.measure.marching_cubes inside _compute_isomesh has no such guard:

from skimage.measure import marching_cubes  # raises ImportError if not installed

If scikit-image is not installed, any 3D isosurface render fails with a cryptic ImportError at callback time rather than at startup or with a user-friendly message.

Recommendation: Add a top-level try/except ImportError for skimage (like the turbojpeg guard) and surface a clear error message in the status div if it is unavailable.


4. Minor: two blank lines before return fig, statusinteractive.py (end of _update)

There are two blank lines before the final return fig, status (introduced by the patch), which will fail ./mfc.sh precheck (flake8 E303). Worth checking before merge.


Improvement Opportunities (no blocking issues)

  • silo_reader.py clear_structure_cache(): The function is exported but never called. If the viz server is reused across different case directories in the same process (unlikely but possible), stale structure would be silently returned. Consider calling it at the start of each assemble_silo call when case_dir changes, or documenting the limitation.
  • _step_cache.py seed(): Clears _in_flight while background workers may still be running. Workers call _in_flight.discard(key) in their finally block, which is safe (discard on absent key is a no-op), but the discrepancy between _in_flight and the actual pool state could cause a previously-in-flight step to be re-prefetched immediately after seed(). This is a very minor double-read hazard, not a correctness bug.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: 4ce5fd8
Files changed: 5

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py

Summary

  • Substantial performance improvement: parallel rank reads, server-side marching cubes via scikit-image, LUT colormap, JPEG encoding, and Dash Patch() partial updates.
  • _step_cache.py correctly moves I/O outside the lock for parallelism; _in_flight set prevents redundant prefetch submissions.
  • Silo structure-caching in silo_reader.py eliminates repeated HDF5 attribute iteration (previously ~0.5–3 s per rank per step).
  • interactive.py grows substantially in complexity; several correctness and dependency concerns below.

Findings

1. Dead code in 3D Patch path — slice mode branch is unreachable

toolchain/mfc/viz/interactive.py (~line 1064 in new code)

_do_patch_3d is explicitly gated to exclude mode == 'slice':

_do_patch_3d = (
    _trig3
    and '.' not in _trig3
    and (
        (mode == 'isosurface' and _trig3.issubset(_PT_ISO)) or
        (mode == 'volume'     and _trig3.issubset(_PT_VOL))
    )
)

But inside the if _do_patch_3d: block there is a if mode == 'slice': branch that calls _slice_3d, patches surfacecolor, etc. This branch can never execute. The comment says "Slice mode always does a full render — Plotly does not reliably re-render go.Surface when surfacecolor is updated via Patch()" but the dead code contradicts it. Either the condition or the dead code should be removed to avoid future confusion.

2. scikit-image is an undeclared runtime dependency

toolchain/mfc/viz/interactive.py_compute_isomesh function

from skimage.measure import marching_cubes is a lazy import inside the function with no fallback. If scikit-image is not installed (it is not in MFC's current dependency list), selecting isosurface mode will raise an ImportError at runtime with no user-friendly message. Either:

  • Add scikit-image to the viz optional dependencies and document it, or
  • Catch ImportError and show a clear error in the UI (e.g., return a dummy mesh + status message), or
  • Fall back to go.Isosurface (JavaScript marching cubes) when scikit-image is unavailable.

Similarly, turbojpeg (TurboJPEG) is an optional import with a Pillow fallback, which is the right pattern — apply the same pattern to scikit-image.

3. reader.py: new ThreadPoolExecutor created per assemble() call

toolchain/mfc/viz/reader.py lines ~475–487

n_workers = min(len(rank_paths), 32)
with ThreadPoolExecutor(max_workers=n_workers) as pool:
    results = list(pool.map(_read_one, rank_paths))

assemble() is called on every timestep in interactive mode. Each call creates and tears down a thread pool (thread creation + join overhead). silo_reader.py uses a persistent module-level pool (_get_pool()) which is the better pattern here. Consider either reusing a module-level pool or, for the binary reader, capping workers with the persistent pool approach.

4. silo_reader.py: persistent pool always sized at max_workers=32

toolchain/mfc/viz/silo_reader.py_get_pool()

The binary reader caps at min(len(rank_paths), 32), but the Silo persistent pool is always 32 regardless of how many ranks a case has. For a 4-rank case this keeps 28 idle threads alive for the life of the process. Minor, but inconsistent. Consider sizing the pool lazily based on the first observed rank count, or using a reasonable smaller default (e.g., 8).

5. _ds3_cache eviction order is unpredictable

toolchain/mfc/viz/interactive.py_get_ds3

if len(_ds3_cache) >= _DS3_CACHE_MAX:
    _ds3_cache.pop(next(iter(_ds3_cache)))

next(iter(dict)) gives insertion order (FIFO in Python 3.7+), which works. But there is no _cache_order list as in _step_cache.py — the eviction relies on dict insertion order being preserved, which is an implementation detail. This is fine for CPython 3.7+ but worth a comment that this is intentional FIFO via dict ordering. The same pattern is used in _jpeg_cache; it would be worth a brief comment in both places.

6. Minor: two trailing blank lines before return fig, status

toolchain/mfc/viz/interactive.py — end of _update callback (~line 1378)

Two blank lines appear before the final return fig, status at the end of the full-render path. Likely a merge artifact; ./mfc.sh format may or may not catch this depending on pylint/black config.


Improvement Opportunities

  • _make_png_source naming: The function encodes JPEG (not PNG) and returns a data:image/jpeg URI; renaming to _make_jpeg_source would prevent confusion.
  • Timing logs: cons.print(f'[dim]viz timing ...') in production code is useful for development but verbose for end users. Consider guarding with an MFC_VIZ_TIMING env var or a --debug flag.
  • _lut_cache / _cscale_cache growth: These grow unbounded (one entry per unique colormap name). For the small number of named colormaps available in the picker this is fine, but a maxsize comment would clarify intent.

sbryngelson and others added 2 commits March 6, 2026 11:17
scikit-image is required for server-side marching cubes (3D isosurface
mode). Adding it to pyproject.toml ensures it is installed in CI so
pylint no longer reports E0401 import-error on skimage.measure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both packages install on Python 3.9-3.14. Add as hard viz dependencies
so users get fast paths (server-side marching cubes, libjpeg-turbo JPEG)
automatically. Retain try/except fallbacks for environments where the
underlying C libraries (libjpeg-turbo) are absent at runtime.

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

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: c614263

Files changed: 6

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

Summary

  • Adds a prefetch-capable bounded FIFO step cache with a background thread pool in _step_cache.py.
  • Adds fast 2D JPEG rendering via a 256-entry LUT colormap and libjpeg-turbo fallback, server-side marching cubes via scikit-image, and downsampled 3D array caching in interactive.py.
  • Parallelizes MPI rank file reads in both the binary (reader.py) and Silo (silo_reader.py) readers; the Silo reader also adds a first-read structure cache to skip repeated HDF5 metadata iteration.
  • Uses Dash Patch() for partial browser updates in 2D and 3D modes, shrinking round-trips significantly.
  • Adds PyTurboJPEG<2.0 and scikit-image as hard dependencies.

Findings

1. Strict upper-bound pin on a hard dependency —

"PyTurboJPEG<2.0",

PyTurboJPEG<2.0 will silently break the toolchain install when 2.0 is released. Both PyTurboJPEG and scikit-image are gracefully handled as optional in the code (turbojpeg falls back to Pillow; _HAVE_SKIMAGE guards the marching-cubes path). Making them hard dependencies in the default install group is a mismatch with that design. Options:

  • Drop the <2.0 pin and open-range it (or pin >=1,<2 with a tracked ticket to test against 2.x).
  • Move them to an [viz] optional-extras group so they do not break non-viz install paths.

2. Ephemeral thread pool per call in

n_workers = min(len(rank_paths), 32)
with ThreadPoolExecutor(max_workers=n_workers) as pool:
    results = list(pool.map(_read_one, rank_paths))

A new pool with up to 32 threads is created and joined on every assemble() call (i.e., every timestep). silo_reader.py correctly uses a persistent module-level pool (_get_pool()); the binary reader should mirror that pattern to eliminate per-step thread creation/teardown overhead.

3. Silent exception in —

    except Exception:  # pylint: disable=broad-except
        pass

Unlike _bg_load() in _step_cache.py which logs at DEBUG level on failure, the JPEG prefetch worker silently drops all exceptions. JPEG prefetch failures (e.g., corrupt data, out-of-memory during marching cubes) will be completely invisible. At minimum add logger.debug(..., exc_info=True) as the silo code does.

4. never invalidated on case-directory switch —

_struct_cache: Dict[str, _SiloStructure] = {}   # key = rank directory path

clear_structure_cache() is provided but never called from viz.py when the case directory changes (e.g., when the user restarts the viz server against a different case). If two cases share the same rank-directory layout (both have silo_hdf5/p0/) the cached structure from the first case will be reused for the second, potentially mapping variable names to wrong HDF5 paths. viz.py should call clear_structure_cache() before seeding the cache for a new case.

5. Module-level thread pool instantiation at import time — ,

_prefetch_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='mfc_prefetch')
_jpeg_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix='mfc_jpeg')

Both pools spawn threads the moment the module is imported, even in test contexts or toolchain commands that import these modules but never start the viz server. Lazily initialising them (same pattern as _get_pool() in silo_reader.py) would avoid surprising thread creation in unrelated toolchain operations.


Minor / nit

  • _lut_cache and _cscale_cache in interactive.py are plain dicts with no lock. In a multi-threaded Dash server, two concurrent callbacks can race to write the same entry. The race is benign (both writes produce identical data), but making this explicit with a comment or a threading.Lock would match the thread-safety discipline used elsewhere in this PR.
  • _ds3_cache eviction uses next(iter(_ds3_cache)) (FIFO via dict insertion order). This is correct in CPython 3.7+ but is not guaranteed by the language spec; a short comment noting the Python-version assumption would help future readers.

- Lazy-init thread pools in _step_cache.py and interactive.py to avoid
  spawning threads at import time in non-viz toolchain contexts
- Use persistent module-level pool in reader.py (mirrors silo_reader.py)
  instead of creating/tearing down a new pool per assemble() call
- Log JPEG prefetch exceptions at DEBUG level instead of silently dropping
- Move turbojpeg and skimage imports to module top-level (remove fallback
  code now that both are hard dependencies)
- Add PyTurboJPEG<2.0 pin comment explaining libjpeg-turbo version constraint
- Add comments on benign _lut_cache/_cscale_cache write races and FIFO
  dict eviction relying on CPython 3.7+ insertion-order guarantee

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

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: 07f74916bdd1146f0494086c511bf2231fffab8c

Files changed: 6

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

Summary

  • Replaces go.Heatmap with go.Image + LUT-colored JPEG (via libjpeg-turbo) for 2D rendering, fixing the grayscale colormap bug and dramatically reducing payload size.
  • Replaces go.Isosurface (JS marching cubes) with server-side skimage.marching_cubes + go.Mesh3d, with server-side downsampling to bound JSON size.
  • Uses Dash.Patch() for incremental browser updates in both 2D and 3D modes, avoiding full figure reconstructions on step/range changes.
  • Parallelises MPI rank file reads in both the binary and Silo readers using ThreadPoolExecutor, and caches the HDF5 file structure across timesteps in silo_reader.py.
  • Adds background prefetch for adjacent timesteps and pre-encoded JPEG frames.

Findings

1. Unconditional top-level import crashes if native libjpeg-turbo is absentinteractive.py, lines ~16–19

from turbojpeg import TurboJPEG as _TurboJPEG
...
_tj = _TurboJPEG()   # raises OSError if libjpeg-turbo .so is not found

PyTurboJPEG is a thin ctypes wrapper; TurboJPEG() at import time opens the native libjpeg-turbo shared library. On HPC clusters that don't have libjpeg-turbo in LD_LIBRARY_PATH, importing the module raises OSError and breaks run_interactive entirely — even for users who don't use 2D mode. This should be lazily initialised inside _encode_jpeg with a try/except that falls back to Pillow:

try:
    from turbojpeg import TurboJPEG as _TurboJPEG
    _tj = _TurboJPEG()
except (ImportError, OSError):
    _tj = None

and _encode_jpeg should fall back to PIL.Image.fromarray(...).tobytes() or similar when _tj is None.

2. Unconditional top-level import of skimageinteractive.py, line ~17

from skimage.measure import marching_cubes as _marching_cubes

Same problem: importing interactive.py will fail (ImportError) on any system that doesn't have scikit-image installed, breaking 1D and 2D visualization modes that don't use marching cubes at all. Guard with a lazy import inside _compute_isomesh or with a try/except at module level.

3. Thread pool max_workers=32 is too large for typical HPC filesystemsreader.py and silo_reader.py

_READ_POOL = ThreadPoolExecutor(max_workers=32, ...)

On a Lustre or GPFS parallel filesystem (which all MFC target clusters use), 32 concurrent readers per step can saturate the metadata server and cause worse throughput than sequential reads, especially with many ranks. Consider bounding by the actual number of ranks or a smaller cap (e.g. min(len(rank_paths), 8)), and measuring whether parallel reads actually help on representative cluster filesystems before merging.

4. Stale inline comment in _step_cache.py — line ~105

_get_prefetch_pool().submit(_bg_load, s, read_func)  # s is a key here

The comment # s is a key here is confusing and leftover — _bg_load takes key as its first argument and read_func as second; the comment adds no information.

5. _prefetch_jpeg duplicates color-range logic from _updateinteractive.py ~lines 250–310 and ~985–1020
The vmin/vmax estimation code (subsample, log handling, etc.) is copy-pasted verbatim between _prefetch_jpeg and _update. If the main callback logic changes (e.g., the 200K subsample threshold or the log10 floor), the prefetch will silently encode frames with wrong color scaling, producing a visual flash when the pre-encoded frame is first shown. Consider extracting _estimate_range(raw, vmin_in, vmax_in, log) as a shared helper.

6. PyTurboJPEG version pin may be too restrictivepyproject.toml

"PyTurboJPEG<2.0",  # 2.x requires libjpeg-turbo>=3; most systems ship 2.x

PyPI's PyTurboJPEG 1.x supports libjpeg-turbo 2.x (which ships with Ubuntu 22.04, RHEL9, etc.), so the comment is correct that the pin is safe. However, <2.0 blocks future upgrades even if the native library is updated. A comment explaining that this should be revisited when cluster images update to libjpeg-turbo 3.x would help future maintainers.


Minor

  • interactive.py line ~1381: double blank line before return fig, status is a cosmetic artifact.
  • The _do_patch_3d block for slice mode (lines ~1045–1060) is unreachable: _do_patch_3d is False when mode == 'slice' (comment says "Slice mode always does a full render"), but the block inside still handles the slice case. This dead branch is harmless but confusing.

…mment

- Wrap TurboJPEG instantiation in try/except (ImportError, OSError) so
  that HPC clusters without libjpeg-turbo in LD_LIBRARY_PATH fall back
  to Pillow instead of crashing all viz modes at import time
- Remove unreachable slice branch inside _do_patch_3d block (slice always
  forces a full render; the branch could never execute)
- Remove stale '# s is a key here' comment in _step_cache.py

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

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: 1c0f4f848d451c15b6bf068a329a8b1c8925d5ed

Files changed: 6

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

Summary

  • Adds a bounded FIFO step cache with lock-free parallel reads and background prefetch
  • Replaces go.Heatmap with LUT-colored JPEG via go.Image, fixing the grayscale colormap bug
  • Adds server-side marching cubes (scikit-image) for 3D isosurface rendering with downsampled data
  • Parallelizes MPI rank file reads in both binary and Silo readers with persistent ThreadPoolExecutor pools
  • Uses Dash Patch() for partial browser updates in 2D and 3D modes, reducing per-step overhead
  • Caches HDF5 Silo file structure so metadata iteration (~0.5–3 s) is only paid once per rank directory

Findings

1. Hard import — no fallback for 2D-only users (interactive.py, diff line 21)

from skimage.measure import marching_cubes as _marching_cubes  # type: ignore[import]

This is a bare module-level import with no try/except. If scikit-image is absent (common on lean HPC environments), the entire interactive module fails to import, breaking 2D and 1D visualization even though marching cubes is only needed for 3D isosurface mode.

Compare: PyTurboJPEG is correctly guarded:

try:
    from turbojpeg import TurboJPEG as _TurboJPEG
    _tj = _TurboJPEG()
except (ImportError, OSError):
    _tj = None

Suggestion: Apply the same pattern:

try:
    from skimage.measure import marching_cubes as _marching_cubes
except ImportError:
    _marching_cubes = None

Then guard the call site in _compute_isomesh with if _marching_cubes is None: return <dummy>.


2. could overwhelm disk I/O on large MPI runs (reader.py line ~37, silo_reader.py line ~200)

Both the binary and Silo thread pools are created with max_workers=32 and use pool.map() which submits all ranks simultaneously. For a simulation with 256+ MPI ranks, all files are opened concurrently, potentially saturating a parallel filesystem's metadata bandwidth (a common bottleneck on Lustre/GPFS systems).

Suggestion: Cap at the number of ranks actually present, or use a reasonable I/O concurrency limit such as min(32, len(rank_paths)) or min(32, (os.cpu_count() or 4)).


3. Log-scale inconsistency between and the main callback (interactive.py)

The main callback applies _tf(raw) for display, where _tf for the log case is something like np.log10(np.maximum(arr, 1e-300)) (applied uniformly to all values). The background JPEG prefetch computes:

display = np.where(raw > 0, np.log10(np.maximum(raw, 1e-300)), np.nan)

These differ at zero/negative grid values: the main path fills them with log10(1e-300) ≈ -300 (mapped to the colormap floor), while the prefetch fills them with np.nan (transparent / white in JPEG). When the prefetch JPEG cache is hit, the rendered image may look slightly different from a cache-miss render of the same step.

Suggestion: Extract a shared helper _apply_log_transform(arr) used by both paths.


4. does not cancel in-flight prefetch futures (_step_cache.py)

seed() clears _cache, _cache_order, and _in_flight, but background tasks already submitted to the ThreadPoolExecutor are not cancelled. A racing worker that completes after seed() will re-enter under _lock, find its step absent, and insert it. In practice this is harmless (adjacent steps are identical data), but if seed() is ever called to switch case directories (different underlying data), a stale prefetch could silently re-populate the cache with data from the old case.

def seed(step: int, data: object) -> None:
    with _lock:
        _cache.clear()
        _cache_order.clear()
        _in_flight.clear()   # futures still running — can re-insert after this
        _cache[step] = data
        _cache_order.append(step)

Low priority if seed() is only called at initialization, but worth a comment noting the assumption.


5. Duplicated range/log computation in (interactive.py)

The ~40-line range estimation + log transform + subsampling block inside _prefetch_jpeg._bg() is a near-copy of the corresponding section in the main update callback. These two paths can silently diverge if one is updated without the other.

Suggestion (improvement opportunity): Extract a _compute_display_range_and_array(raw, vmin_in, vmax_in, log_bool) helper shared by both paths.


Minor notes

  • The 2D Patch path hardcodes data[0] = Image trace and data[1] = colorbar scatter. This is stable because the full render always constructs traces in the same order, but a comment noting this assumption would help future maintainers.
  • _DS3_CACHE_MAX = 10 and _JPEG_CACHE_MAX = 30 are module-level globals with no configuration knob. Fine for now, but a note in the docstring about expected memory footprint would be useful (each 3D array at 150K float32 cells ≈ 600 KB → ~6 MB for the DS3 cache).
  • scikit-image is a heavy transitive dependency (~50 MB wheel) added solely for marching_cubes. The alternative mcubes package is much lighter if a future dependency audit is warranted.

@sbryngelson sbryngelson marked this pull request as ready for review March 6, 2026 19:37
Copilot AI review requested due to automatic review settings March 6, 2026 19:37
@sbryngelson sbryngelson merged commit d4336a1 into MFlowCode:master Mar 6, 2026
29 of 37 checks passed
@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Improve interactive visualization server performance and UI

✨ Enhancement 🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Add bounded FIFO step cache with prefetch support for faster timestep navigation
• Implement server-side marching cubes for 3D isosurface rendering (10-100× faster)
• Add LUT-based JPEG encoding with libjpeg-turbo for 2D visualization
• Use Dash Patch() for partial browser updates in 2D and 3D modes
• Add downsampled 3D array cache and parallel MPI rank file reads
• Fix colormap application, 3D axis rescaling, slice mode timesteps, and dark theme styling
• Improve colorbar layout with bounded size and two-digit scientific notation
• Add float32 mesh data to reduce 3D JSON payload by ~50%
Diagram
flowchart LR
  A["Step Cache<br/>with Prefetch"] --> B["Parallel<br/>File Reads"]
  B --> C["Downsampled<br/>3D Arrays"]
  C --> D["Server-side<br/>Marching Cubes"]
  D --> E["JPEG Encoding<br/>with LUT"]
  E --> F["Dash Patch<br/>Updates"]
  F --> G["Fast Browser<br/>Rendering"]
Loading

Grey Divider

File Changes

1. toolchain/mfc/viz/_step_cache.py ✨ Enhancement +110/-12

Add prefetch support and concurrent cache loading

• Refactored cache to perform disk reads outside lock for concurrent step loading
• Added prefetch() function to eagerly load adjacent steps in background thread pool
• Added prefetch_one() for flexible single-key background loading with any hashable key
• Implemented lazy thread pool initialization via _get_prefetch_pool() to avoid spawning threads at
 import time
• Reduced CACHE_MAX from 50 to 20 entries and added _in_flight set to track in-progress loads

toolchain/mfc/viz/_step_cache.py


2. toolchain/mfc/viz/interactive.py ✨ Enhancement +743/-73

Add JPEG encoding, server marching cubes, and Patch updates

• Added LUT-based JPEG encoding with libjpeg-turbo fallback to Pillow for fast 2D colorization
• Implemented server-side marching cubes via skimage for 3D isosurface rendering (10-100× faster
 than JavaScript)
• Added downsampled 3D array cache (_get_ds3) with uniform stride targeting max cell budget
• Implemented Dash Patch() fast path for 2D and 3D to skip full figure rebuilds on step/range
 changes
• Added background JPEG pre-encoding (_prefetch_jpeg) to have next step ready before navigation
• Refactored _build_3d to use matplotlib LUT-derived colorscales and float32 arrays for smaller JSON
 payloads
• Added _slice_3d helper for efficient 2D slice extraction from 3D arrays
• Improved colorbar with _make_cbar() using Python f-string formatting for consistent two-digit
 exponents
• Fixed dark theme styling for Dash 4 dropdowns and radio buttons via CSS custom properties
• Added timing instrumentation to console output for performance monitoring
• Added read_one_var_func parameter to support single-variable loading for N_vars speedup

toolchain/mfc/viz/interactive.py


3. toolchain/mfc/viz/reader.py ✨ Enhancement +29/-2

Parallelize MPI rank file reads with thread pool

• Added persistent module-level thread pool (_get_pool) with lazy initialization for parallel rank
 file reads
• Parallelized multi-processor binary file reads using ThreadPoolExecutor.map() instead of
 sequential reads
• Validated all file paths synchronously before spawning threads to ensure immediate error reporting
• Registered pool shutdown via atexit to clean up threads on process exit

toolchain/mfc/viz/reader.py


View more (3)
4. toolchain/mfc/viz/silo_reader.py ✨ Enhancement +172/-69

Cache HDF5 structure and parallelize silo reads

• Added _SiloStructure dataclass to cache HDF5 file layout (mesh paths, variable paths) per rank
 directory
• Implemented _parse_structure() to walk HDF5 metadata once and extract silo layout
• Added _get_structure() with caching to avoid re-parsing identical structure on every timestep read
• Refactored read_silo_file() to use cached structure, skipping expensive metadata iteration on
 subsequent calls
• Added clear_structure_cache() function for cache invalidation when case directory changes
• Parallelized multi-processor silo reads using ThreadPoolExecutor.map() with persistent
 module-level pool
• Reduced per-step overhead from ~0.5-3s to near-raw-I/O speed (~0.15s) via structure caching

toolchain/mfc/viz/silo_reader.py


5. toolchain/mfc/viz/viz.py ✨ Enhancement +9/-1

Add single-variable read function for interactive mode

• Added read_step_one_var() function to load single variable per step for N_vars speedup
• Passed read_one_var_func to run_interactive() to enable single-variable cache keys

toolchain/mfc/viz/viz.py


6. toolchain/pyproject.toml Dependencies +2/-0

Add PyTurboJPEG and scikit-image dependencies

• Added PyTurboJPEG<2.0 as hard dependency for libjpeg-turbo JPEG encoding with fallback to Pillow
• Added scikit-image as hard dependency for server-side marching cubes in 3D isosurface mode

toolchain/pyproject.toml


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review bot commented Mar 6, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. JPEG fallback lacks Pillow 🐞 Bug ⛯ Reliability
Description
When libjpeg-turbo cannot be loaded, interactive.py falls back to Pillow-based encoding, but Pillow
is not declared as a dependency. In that environment, 2D rendering will crash at runtime when trying
to import PIL.
Code

toolchain/mfc/viz/interactive.py[R108-119]

+def _encode_jpeg(rgb: np.ndarray) -> bytes:
+    """Encode (h, w, 3) uint8 RGB → JPEG bytes.
+
+    Uses libjpeg-turbo when available; falls back to Pillow otherwise.
+    """
+    if _tj is not None:
+        return _tj.encode(rgb, quality=90)
+    from PIL import Image as _PIL  # pylint: disable=import-outside-toplevel
+    import io as _io              # pylint: disable=import-outside-toplevel
+    buf = _io.BytesIO()
+    _PIL.fromarray(rgb, 'RGB').save(buf, format='jpeg', quality=90, optimize=False)
+    return buf.getvalue()
Evidence
interactive.py explicitly anticipates TurboJPEG failing to initialize (OSError) and sets _tj=None;
_encode_jpeg then imports PIL on that path. The project dependencies include PyTurboJPEG but do not
include Pillow, so the fallback import can fail at runtime.

toolchain/mfc/viz/interactive.py[31-40]
toolchain/mfc/viz/interactive.py[108-119]
toolchain/pyproject.toml[45-58]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`interactive.py` falls back to Pillow when TurboJPEG cannot be initialized, but Pillow is not declared in `toolchain/pyproject.toml`. On systems without libjpeg-turbo discoverable at runtime, 2D rendering can crash with `ImportError: No module named PIL`.

### Issue Context
The code explicitly expects `TurboJPEG()` to raise `OSError` in some environments and sets `_tj = None`, making the PIL import path a real runtime path.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[31-40]
- toolchain/mfc/viz/interactive.py[108-119]
- toolchain/pyproject.toml[45-58]

### Suggested fix
- Add `Pillow` to `dependencies`.
- (Optional) If you want Pillow to remain optional, catch `ImportError` around the PIL import and raise a clear exception instructing the user to install Pillow or configure libjpeg-turbo.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. 3D downsample cache pins memory 🐞 Bug ⛯ Reliability
Description
The 3D downsample cache stores strided NumPy slices (views). Views keep references to their base
arrays, so caching them can keep full-resolution 3D arrays alive even if the main step cache evicts
entries, undermining memory bounds and risking OOM on large grids.
Code

toolchain/mfc/viz/interactive.py[R207-245]

+def _downsample_3d(raw: np.ndarray, x_cc: np.ndarray, y_cc: np.ndarray,
+                   z_cc: np.ndarray, max_total: int = 150_000):
+    """Stride a (nx, ny, nz) array to stay within a total cell budget.
+
+    Uses a **uniform** stride s = ceil((nx*ny*nz / max_total)^(1/3)) so that
+    all axes are sampled equally.  For an anisotropic grid like 901×201×201,
+    this gives stride=7 → 129×29×29 = 108K cells instead of the per-axis
+    strategy which would give stride=18 in x (only 50 pts), causing jagged
+    isosurfaces along the long axis.
+    """
+    nx, ny, nz = raw.shape
+    total = nx * ny * nz
+    if total <= max_total:
+        return raw, x_cc, y_cc, z_cc
+    s = max(1, math.ceil((total / max_total) ** (1.0 / 3.0)))
+    return raw[::s, ::s, ::s], x_cc[::s], y_cc[::s], z_cc[::s]
+
+
+def _get_ds3(step, var, raw, x_cc, y_cc, z_cc, max_total):  # pylint: disable=too-many-arguments,too-many-positional-arguments
+    """Downsampled 3D array with bounded LRU caching.
+
+    Avoids re-striding the same large array on every iso threshold / volume
+    opacity slider move.  Key is (step, var, max_total); value is the tuple
+    (raw_ds, x_ds, y_ds, z_ds) returned by _downsample_3d.
+    """
+    key = (step, var, max_total)
+    with _ds3_lock:
+        if key in _ds3_cache:
+            return _ds3_cache[key]
+    result = _downsample_3d(raw, x_cc, y_cc, z_cc, max_total)
+    with _ds3_lock:
+        if key not in _ds3_cache:
+            if len(_ds3_cache) >= _DS3_CACHE_MAX:
+                # FIFO eviction: next(iter(dict)) yields the oldest entry by
+                # insertion order, guaranteed in CPython 3.7+ and the language
+                # spec from Python 3.7.
+                _ds3_cache.pop(next(iter(_ds3_cache)))
+            _ds3_cache[key] = result
+    return result
Evidence
_downsample_3d returns raw[::s, ::s, ::s] (a slice with no copy), and _get_ds3 stores that result
in a bounded cache of size 10. Because these cached objects are views, they can retain the
underlying full raw buffer independent of _step_cache’s eviction policy.

toolchain/mfc/viz/interactive.py[51-55]
toolchain/mfc/viz/interactive.py[207-223]
toolchain/mfc/viz/interactive.py[225-245]
toolchain/mfc/viz/_step_cache.py[31-35]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_downsample_3d` returns a strided slice of `raw` (`raw[::s, ::s, ::s]`). That is a NumPy view, which retains a reference to the full underlying `raw` buffer. Storing these views in `_ds3_cache` can pin large full-resolution arrays in memory even when `_step_cache` tries to evict them.

### Issue Context
This is especially problematic for large 3D fields where a single `raw` can be hundreds of MB; `_DS3_CACHE_MAX = 10` can amplify the worst-case memory footprint.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[207-223]
- toolchain/mfc/viz/interactive.py[225-245]

### Suggested fix
- Change `_downsample_3d` to return copies for the downsampled volume (at least `raw_ds`):
 - `raw_ds = raw[::s, ::s, ::s].copy()` (optionally `np.ascontiguousarray`)
 - keep coord vectors as slices or copy them too (they’re small).
- Consider documenting the memory/perf trade-off (copy cost vs bounded memory).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Volume Patch misses cmin/cmax 🐞 Bug ✓ Correctness
Description
In 3D volume mode, the Patch() fast-path updates value/isomin/isomax/etc. but does not update
cmin/cmax (or colorbar). After vmin/vmax changes, the browser can keep using stale color scaling,
producing incorrect colors.
Code

toolchain/mfc/viz/interactive.py[R1109-1121]

+                else:  # volume
+                    raw_ds, _, _, _ = _get_ds3(
+                        step, selected_var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 150_000,
+                    )
+                    vf = _tf(raw_ds).ravel()
+                    vlo = cmin + rng3 * float(vol_min_frac or 0.0)
+                    vhi = cmin + rng3 * max(float(vol_max_frac or 1.0), vlo + 0.01)
+                    patch['data'][0]['value'] = vf.tolist()
+                    patch['data'][0]['isomin'] = vlo
+                    patch['data'][0]['isomax'] = vhi
+                    patch['data'][0]['opacity'] = float(vol_opacity or 0.1)
+                    patch['data'][0]['surface_count'] = int(vol_nsurf or 15)
+                    patch['data'][0]['colorscale'] = _cscale3
Evidence
The full render explicitly sets cmin/cmax and colorbar on the Volume trace, indicating they
are part of correct rendering. The patch path for volume does not patch those fields, so updates
driven by vmin/vmax can leave stale values in the client-side figure.

toolchain/mfc/viz/interactive.py[1109-1121]
toolchain/mfc/viz/interactive.py[542-550]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The 3D Volume Patch() path does not update `cmin`/`cmax` (and does not refresh the `colorbar`). When vmin/vmax changes in volume mode, the client may keep the previous scaling, showing incorrect colors.

### Issue Context
Full 3D volume rendering sets `cmin`, `cmax`, and `colorbar` on the trace; the patch path should keep those consistent when it is used.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[1109-1121]
- toolchain/mfc/viz/interactive.py[542-550]

### Suggested fix
In the volume branch of the `_do_patch_3d` path:
- Add:
 - `patch[&#x27;data&#x27;][0][&#x27;cmin&#x27;] = cmin`
 - `patch[&#x27;data&#x27;][0][&#x27;cmax&#x27;] = cmax`
 - `patch[&#x27;data&#x27;][0][&#x27;colorbar&#x27;] = _make_cbar(cbar_title, cmin, cmax)` (or ensure the existing colorbar updates as desired)
- Consider also updating the trace title/legend fields if they depend on range/log settings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Marching cubes uses uniform spacing 🐞 Bug ✓ Correctness
Description
Server-side marching cubes computes a single constant spacing per axis from endpoints, which assumes
uniform x/y/z coordinates. The assembler builds coordinate arrays by deduplicating actual
cell-center positions (potentially non-uniform), so isosurface geometry can be spatially distorted
on stretched/non-uniform grids.
Code

toolchain/mfc/viz/interactive.py[R162-176]

+    dx = float(x_ds[-1] - x_ds[0]) / max(len(x_ds) - 1, 1) if len(x_ds) > 1 else 1.0
+    dy = float(y_ds[-1] - y_ds[0]) / max(len(y_ds) - 1, 1) if len(y_ds) > 1 else 1.0
+    dz = float(z_ds[-1] - z_ds[0]) / max(len(z_ds) - 1, 1) if len(z_ds) > 1 else 1.0
+    spacing = (dx, dy, dz)
+
+    levels = np.linspace(ilo, ihi, max(int(iso_n), 1))
+    xs, ys, zs, ii, jj, kk, intens = [], [], [], [], [], [], []
+    offset = 0
+
+    for level in levels:
+        try:
+            verts, faces, _, _ = _marching_cubes(
+                vol, level=float(level), spacing=spacing,
+                allow_degenerate=False,
+            )
Evidence
_compute_isomesh derives a constant dx/dy/dz from endpoints and passes that to
skimage.measure.marching_cubes, then applies only an origin shift. Meanwhile, the reader/assembler
constructs x_cc/y_cc/z_cc by unique-ing the actual coordinate values, indicating the system
supports non-uniform coordinates rather than assuming linear spacing.

toolchain/mfc/viz/interactive.py[162-176]
toolchain/mfc/viz/interactive.py[181-184]
toolchain/mfc/viz/reader.py[387-419]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_compute_isomesh` assumes uniform spacing by using a single `dx/dy/dz` per axis. For non-uniform coordinate vectors, this produces distorted isosurface geometry.

### Issue Context
The reader/assembler constructs coordinate vectors from unique actual cell-center positions, which can be non-uniform (e.g., stretched grids). `skimage.measure.marching_cubes` only accepts constant spacing.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[162-185]
- toolchain/mfc/viz/reader.py[387-419]

### Suggested fix
- Call `marching_cubes` with `spacing=(1.0, 1.0, 1.0)` (index space).
- Convert `verts[:,0]` (index-coordinate) to physical x via interpolation over `x_ds`:
 - `x = np.interp(verts[:,0], np.arange(len(x_ds)), x_ds)`
 - same for y/z.
- Remove the current origin-shift logic (or keep only if still needed after mapping).
- Add a small unit/integration test (or a debug assertion) to detect strongly non-uniform axes and validate geometry mapping.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 6, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 22b8591b-87cb-426c-bd56-73eed53d3935

📥 Commits

Reviewing files that changed from the base of the PR and between 2c3590c and 1c0f4f8.

📒 Files selected for processing (6)
  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

📝 Walkthrough

Walkthrough

This pull request introduces asynchronous prefetching, caching, and parallel data reading throughout the visualization pipeline. The step cache now performs background loading of adjacent steps with in-flight tracking. The interactive visualization module adds server-side mesh computation via marching cubes, LUT-based image rendering, and JPEG caching. Data readers implement thread pool-based parallel file access across ranks. Per-variable loading optimization is added to reduce data fetching overhead. New dependencies PyTurboJPEG and scikit-image are introduced for image processing. Overall, the changes shift from sequential, single-threaded operations to asynchronous, parallel-aware implementations with caching layers.


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
Copy Markdown

github-actions bot commented Mar 6, 2026

Claude Code Review

Head SHA: 1c0f4f8

Files changed: 6

  • toolchain/mfc/viz/_step_cache.py
  • toolchain/mfc/viz/interactive.py
  • toolchain/mfc/viz/reader.py
  • toolchain/mfc/viz/silo_reader.py
  • toolchain/mfc/viz/viz.py
  • toolchain/pyproject.toml

Summary

  • Adds a bounded FIFO step cache with background prefetch (double-checked locking pattern), replacing the simpler locked FIFO.
  • Replaces go.Heatmap / go.Isosurface with LUT-based JPEG rendering (2D) and server-side marching cubes via skimage (3D), reducing both server compute and browser JSON payload size.
  • Uses Dash Patch() for partial browser figure updates on step/range changes, skipping full figure reconstruction.
  • Parallelizes MPI rank file reads with a ThreadPoolExecutor(max_workers=32) in reader.py, and caches HDF5 silo file structure to skip per-step metadata parsing.
  • Fixes grayscale colormap bug (was using go.Heatmap with a default colorscale rather than the user's colormap choice), dark-theme dropdown styling for Dash 4, and 3D axis auto-rescaling on iso threshold changes.

Findings

1. Misleading function name: _make_png_source actually encodes JPEG
toolchain/mfc/viz/interactive.py — The function is named _make_png_source and returns a data:image/jpeg;base64,... URI. The name says PNG but it emits JPEG. Rename to _make_image_source or _make_jpeg_source to avoid confusion.

2. Docstring/return-value mismatch in _slice_3d
toolchain/mfc/viz/interactive.py — The docstring says:

Returns (sliced_ds, coord1_ds, coord2_ds, actual, const_axis_value)

But the function returns only 4 values:

return sliced[::s1, ::s2], c1[::s1], c2[::s2], actual

(no const_axis_value). Callers unpack 4 correctly so there's no runtime bug, but the docstring is wrong.

3. skimage is a hard top-level import
toolchain/mfc/viz/interactive.py, line ~19:

from skimage.measure import marching_cubes as _marching_cubes

This import is unconditional. If scikit-image is not installed the entire interactive.py module fails to import, breaking 2D visualization on systems where only 2D is needed. turbojpeg is correctly guarded with try/except; the same pattern should apply to skimage (or the import can be deferred to the 3D code path). Adding scikit-image to pyproject.toml helps for managed environments, but HPC clusters with Lmod-style module systems may not have it.

4. ThreadPoolExecutor with max_workers=32 in reader.py
toolchain/mfc/viz/reader.py — A pool of 32 threads is created for parallel MPI rank file reads. For cases with few ranks (e.g., 4–16) most threads are idle; for cases run across many ranks on a shared parallel filesystem (Lustre/GPFS), 32 simultaneous readers from a single client may saturate the OST striping and cause I/O contention. Consider bounding max_workers to min(32, len(rank_paths)) or making it configurable.

5. Unlocked _lut_cache / _cscale_cache mutation
toolchain/mfc/viz/interactive.py — These module-level dicts are written from multiple threads without a lock. The comment correctly notes this is benign on CPython (GIL serialises dict.__setitem__), but it is worth a brief note that this assumption breaks under nogil CPython (PEP 703, coming in 3.14+) or if the code is ever run under Jython/PyPy with true parallelism. A trivial dict.setdefault-style write or a per-cache lock would future-proof this.

6. JPEG quality hardcoded in two places
toolchain/mfc/viz/interactive.pyquality=90 appears in both _tj.encode(rgb, quality=90) and the Pillow fallback save(buf, format='jpeg', quality=90, ...). Define a module-level constant (e.g., _JPEG_QUALITY = 90) and reference it in both paths to keep them in sync.


Minor / Improvement Opportunities

  • _build_3d signature has unused-argument suppression for ad (renamed to an unused positional). The ad argument is actually still used (ad.x_cc, etc.), so the suppression comment is there for the renamed _iso_caps parameter. Consider removing the ad mention from the pylint disable comment to reduce confusion.
  • In the 3D Patch path, vf.tolist() is called on a potentially large float32 array before sending to Plotly. This works but can be slow for large meshes; Plotly's Dash serialiser accepts numpy arrays directly in recent versions — worth testing.
  • The CACHE_MAX reduction from 50 → 20 in _step_cache.py is intentional (memory bound), but a comment explaining the rationale (e.g., large 3D arrays at stp) would help future maintainers.

Copy link
Copy Markdown
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 PR enhances the Python-based mfc viz --interactive visualization server by reducing disk I/O overhead, shrinking browser payloads, and improving UI responsiveness for 2D/3D rendering workflows.

Changes:

  • Adds bounded step caching + background prefetch (including single-variable reads in interactive mode).
  • Improves 2D rendering performance via LUT-based colormap + JPEG encoding and uses Dash Patch() for partial updates.
  • Speeds up multi-rank reads and 3D rendering via parallel rank file reads, downsampled 3D caching, and server-side marching cubes.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
toolchain/pyproject.toml Adds new visualization dependencies (PyTurboJPEG, scikit-image).
toolchain/mfc/viz/viz.py Wires interactive mode to optionally load a single variable per step.
toolchain/mfc/viz/silo_reader.py Adds per-rank structure caching and parallel rank reads via a persistent thread pool.
toolchain/mfc/viz/reader.py Parallelizes per-rank binary file reads via a persistent thread pool.
toolchain/mfc/viz/interactive.py Major interactive server changes: Patch-based updates, LUT colormap + JPEG, downsample caches, marching cubes, UI tweaks.
toolchain/mfc/viz/_step_cache.py Implements bounded FIFO cache with background prefetch and in-flight tracking.

Comment on lines +1093 to +1094
ilo = cmin + rng3 * float(iso_min_frac or 0.2)
ihi = cmin + rng3 * max(float(iso_max_frac or 0.8), ilo + 0.01)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In the 3D Patch() path, ihi is computed by comparing a fraction (iso_max_frac) against an absolute value (ilo + 0.01). This mixes units and can make the isosurface band far too wide or too narrow depending on the data scale. Compute the minimum span in fraction-space (e.g., max(iso_max_frac, iso_min_frac + 0.01)) before converting to the absolute cmin + rng * frac range.

Suggested change
ilo = cmin + rng3 * float(iso_min_frac or 0.2)
ihi = cmin + rng3 * max(float(iso_max_frac or 0.8), ilo + 0.01)
iso_min_f = float(iso_min_frac or 0.2)
iso_max_f = float(iso_max_frac or 0.8)
# Enforce a minimum span in fraction space before mapping to data values
iso_max_f = max(iso_max_f, iso_min_f + 0.01)
ilo = cmin + rng3 * iso_min_f
ihi = cmin + rng3 * iso_max_f

Copilot uses AI. Check for mistakes.
Comment on lines +427 to +429
Returns (sliced_ds, coord1_ds, coord2_ds, actual, const_axis_value)
where sliced_ds is the downsampled surfacecolor array and coord*_ds are
the downsampled coordinate vectors for the two varying axes.
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

_slice_3d's docstring says it returns 5 values including const_axis_value, but the function currently returns 4 values. Please update the docstring to match the actual return signature (or return the additional value if it’s needed by callers).

Suggested change
Returns (sliced_ds, coord1_ds, coord2_ds, actual, const_axis_value)
where sliced_ds is the downsampled surfacecolor array and coord*_ds are
the downsampled coordinate vectors for the two varying axes.
Returns (sliced_ds, coord1_ds, coord2_ds, actual) where ``sliced_ds`` is
the downsampled surfacecolor array, ``coord*_ds`` are the downsampled
coordinate vectors for the two varying axes, and ``actual`` is the
coordinate value along the constant slice axis.

Copilot uses AI. Check for mistakes.
Comment on lines 52 to 57
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.
The disk read is performed outside the lock so concurrent callbacks
for different steps do not serialize behind a single I/O operation.
"""
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

load is now used with non-int keys (e.g. (step, var) tuples in interactive mode), but its signature/types and docstring still imply step: int. Consider updating the parameter name/type (e.g. key: Hashable) so callers and type checkers don’t get misleading guidance.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +41
_READ_POOL = ThreadPoolExecutor(
max_workers=32, thread_name_prefix='mfc_binary'
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The reader thread pool uses a hard-coded max_workers=32. On machines with fewer cores (or when len(rank_paths) is small), this can cause unnecessary thread overhead; on shared filesystems it can also create an avoidable I/O burst. Consider capping workers with something like min(32, len(rank_paths), os.cpu_count() or 1).

Suggested change
_READ_POOL = ThreadPoolExecutor(
max_workers=32, thread_name_prefix='mfc_binary'
max_workers = min(32, os.cpu_count() or 1)
_READ_POOL = ThreadPoolExecutor(
max_workers=max_workers, thread_name_prefix='mfc_binary'

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +209
def _get_pool() -> ThreadPoolExecutor:
"""Return a module-level thread pool, creating it on first use."""
global _READ_POOL # pylint: disable=global-statement
with _POOL_LOCK:
if _READ_POOL is None:
_READ_POOL = ThreadPoolExecutor(
max_workers=32, thread_name_prefix="mfc_silo"
)
atexit.register(_READ_POOL.shutdown, wait=False)
return _READ_POOL
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The silo reader thread pool is created with a fixed max_workers=32. Consider sizing this based on len(ranks) and CPU count to avoid oversubscribing threads and overloading shared filesystems when many users run viz concurrently.

Copilot uses AI. Check for mistakes.
Comment on lines +1114 to +1115
vlo = cmin + rng3 * float(vol_min_frac or 0.0)
vhi = cmin + rng3 * max(float(vol_max_frac or 1.0), vlo + 0.01)
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

In the 3D Patch() volume path, vhi is computed using max(vol_max_frac, vlo + 0.01), which compares a fraction to an absolute value (vlo is already scaled into data units). This will produce incorrect thresholds. Use max(vol_max_frac, vol_min_frac + 0.01) (both fractions) and then scale with cmin + rng * frac.

Suggested change
vlo = cmin + rng3 * float(vol_min_frac or 0.0)
vhi = cmin + rng3 * max(float(vol_max_frac or 1.0), vlo + 0.01)
vmin_f = float(vol_min_frac or 0.0)
vmax_f = float(vol_max_frac or 1.0)
hi_frac = max(vmax_f, vmin_f + 0.01)
vlo = cmin + rng3 * vmin_f
vhi = cmin + rng3 * hi_frac

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +140
def _make_png_source(arr_yx: np.ndarray, cmap_name: str,
vmin: float, vmax: float) -> str:
"""Encode a (ny, nx) float array as a colorized base64 JPEG data URI.

Uses a 256-entry LUT for fast colormap application and libjpeg-turbo
(when available) for JPEG encoding.

arr_yx: shape (ny, nx), row 0 = smallest y (physical bottom).
No vertical flip is applied — go.Image with y0=y_min, dy>0 renders
row 0 at the bottom of the y-axis, matching physics convention.
"""
lut = _get_lut(cmap_name)
scale = max(float(vmax - vmin), 1e-30)
normed = np.clip(
(arr_yx - vmin) / scale * 255.0 + 0.5, 0, 255
).astype(np.uint8)
rgb = lut[normed] # (ny, nx, 3) uint8
b64 = base64.b64encode(_encode_jpeg(rgb)).decode()
return f"data:image/jpeg;base64,{b64}"
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

_make_png_source returns a data:image/jpeg URI and uses JPEG encoding, but the function name/comments still refer to PNG. Renaming (or switching back to PNG) would avoid confusion for future maintenance and debugging.

Copilot uses AI. Check for mistakes.
"textual-plotext>=0.2.0",
"dash>=2.0",
"plotly",
"PyTurboJPEG<2.0", # 2.x requires libjpeg-turbo>=3; most systems ship 2.x
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

interactive.py falls back to importing PIL when libjpeg-turbo isn’t available, but Pillow isn’t declared as a direct dependency here. That can make mfc viz --interactive fail at runtime on systems where PyTurboJPEG can’t load the shared library. Either add an explicit pillow dependency or handle the missing Pillow import with a clearer error / alternative path.

Suggested change
"PyTurboJPEG<2.0", # 2.x requires libjpeg-turbo>=3; most systems ship 2.x
"PyTurboJPEG<2.0", # 2.x requires libjpeg-turbo>=3; most systems ship 2.x
"pillow",

Copilot uses AI. Check for mistakes.
Comment on lines 918 to 925
@app.callback(
Output('vmin-inp', 'value'),
Output('vmax-inp', 'value'),
Input('reset-btn', 'n_clicks'),
Input('var-sel', 'value'),
prevent_initial_call=True,
)
def _reset_range(_reset, _var):
def _reset_range(_reset):
return None, None
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

vmin-inp / vmax-inp are no longer cleared when the variable changes (the var-sel input was removed from _reset_range). The _update callback temporarily ignores stale values when var-sel is the trigger, but the stale numbers remain in the inputs and will be applied again on the next step-sel change. Consider restoring the var-change reset (or otherwise clearing these inputs) so manual ranges don’t “stick” across variables unintentionally.

Copilot uses AI. Check for mistakes.
Comment on lines +108 to +119
def _encode_jpeg(rgb: np.ndarray) -> bytes:
"""Encode (h, w, 3) uint8 RGB → JPEG bytes.

Uses libjpeg-turbo when available; falls back to Pillow otherwise.
"""
if _tj is not None:
return _tj.encode(rgb, quality=90)
from PIL import Image as _PIL # pylint: disable=import-outside-toplevel
import io as _io # pylint: disable=import-outside-toplevel
buf = _io.BytesIO()
_PIL.fromarray(rgb, 'RGB').save(buf, format='jpeg', quality=90, optimize=False)
return buf.getvalue()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Jpeg fallback lacks pillow 🐞 Bug ⛯ Reliability

When libjpeg-turbo cannot be loaded, interactive.py falls back to Pillow-based encoding, but Pillow
is not declared as a dependency. In that environment, 2D rendering will crash at runtime when trying
to import PIL.
Agent Prompt
### Issue description
`interactive.py` falls back to Pillow when TurboJPEG cannot be initialized, but Pillow is not declared in `toolchain/pyproject.toml`. On systems without libjpeg-turbo discoverable at runtime, 2D rendering can crash with `ImportError: No module named PIL`.

### Issue Context
The code explicitly expects `TurboJPEG()` to raise `OSError` in some environments and sets `_tj = None`, making the PIL import path a real runtime path.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[31-40]
- toolchain/mfc/viz/interactive.py[108-119]
- toolchain/pyproject.toml[45-58]

### Suggested fix
- Add `Pillow` to `dependencies`.
- (Optional) If you want Pillow to remain optional, catch `ImportError` around the PIL import and raise a clear exception instructing the user to install Pillow or configure libjpeg-turbo.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +207 to +245
def _downsample_3d(raw: np.ndarray, x_cc: np.ndarray, y_cc: np.ndarray,
z_cc: np.ndarray, max_total: int = 150_000):
"""Stride a (nx, ny, nz) array to stay within a total cell budget.

Uses a **uniform** stride s = ceil((nx*ny*nz / max_total)^(1/3)) so that
all axes are sampled equally. For an anisotropic grid like 901×201×201,
this gives stride=7 → 129×29×29 = 108K cells instead of the per-axis
strategy which would give stride=18 in x (only 50 pts), causing jagged
isosurfaces along the long axis.
"""
nx, ny, nz = raw.shape
total = nx * ny * nz
if total <= max_total:
return raw, x_cc, y_cc, z_cc
s = max(1, math.ceil((total / max_total) ** (1.0 / 3.0)))
return raw[::s, ::s, ::s], x_cc[::s], y_cc[::s], z_cc[::s]


def _get_ds3(step, var, raw, x_cc, y_cc, z_cc, max_total): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Downsampled 3D array with bounded LRU caching.

Avoids re-striding the same large array on every iso threshold / volume
opacity slider move. Key is (step, var, max_total); value is the tuple
(raw_ds, x_ds, y_ds, z_ds) returned by _downsample_3d.
"""
key = (step, var, max_total)
with _ds3_lock:
if key in _ds3_cache:
return _ds3_cache[key]
result = _downsample_3d(raw, x_cc, y_cc, z_cc, max_total)
with _ds3_lock:
if key not in _ds3_cache:
if len(_ds3_cache) >= _DS3_CACHE_MAX:
# FIFO eviction: next(iter(dict)) yields the oldest entry by
# insertion order, guaranteed in CPython 3.7+ and the language
# spec from Python 3.7.
_ds3_cache.pop(next(iter(_ds3_cache)))
_ds3_cache[key] = result
return result
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

2. 3d downsample cache pins memory 🐞 Bug ⛯ Reliability

The 3D downsample cache stores strided NumPy slices (views). Views keep references to their base
arrays, so caching them can keep full-resolution 3D arrays alive even if the main step cache evicts
entries, undermining memory bounds and risking OOM on large grids.
Agent Prompt
### Issue description
`_downsample_3d` returns a strided slice of `raw` (`raw[::s, ::s, ::s]`). That is a NumPy view, which retains a reference to the full underlying `raw` buffer. Storing these views in `_ds3_cache` can pin large full-resolution arrays in memory even when `_step_cache` tries to evict them.

### Issue Context
This is especially problematic for large 3D fields where a single `raw` can be hundreds of MB; `_DS3_CACHE_MAX = 10` can amplify the worst-case memory footprint.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[207-223]
- toolchain/mfc/viz/interactive.py[225-245]

### Suggested fix
- Change `_downsample_3d` to return copies for the downsampled volume (at least `raw_ds`):
  - `raw_ds = raw[::s, ::s, ::s].copy()` (optionally `np.ascontiguousarray`)
  - keep coord vectors as slices or copy them too (they’re small).
- Consider documenting the memory/perf trade-off (copy cost vs bounded memory).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +1109 to +1121
else: # volume
raw_ds, _, _, _ = _get_ds3(
step, selected_var, raw, ad.x_cc, ad.y_cc, ad.z_cc, 150_000,
)
vf = _tf(raw_ds).ravel()
vlo = cmin + rng3 * float(vol_min_frac or 0.0)
vhi = cmin + rng3 * max(float(vol_max_frac or 1.0), vlo + 0.01)
patch['data'][0]['value'] = vf.tolist()
patch['data'][0]['isomin'] = vlo
patch['data'][0]['isomax'] = vhi
patch['data'][0]['opacity'] = float(vol_opacity or 0.1)
patch['data'][0]['surface_count'] = int(vol_nsurf or 15)
patch['data'][0]['colorscale'] = _cscale3
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

3. Volume patch misses cmin/cmax 🐞 Bug ✓ Correctness

In 3D volume mode, the Patch() fast-path updates value/isomin/isomax/etc. but does not update
cmin/cmax (or colorbar). After vmin/vmax changes, the browser can keep using stale color scaling,
producing incorrect colors.
Agent Prompt
### Issue description
The 3D Volume Patch() path does not update `cmin`/`cmax` (and does not refresh the `colorbar`). When vmin/vmax changes in volume mode, the client may keep the previous scaling, showing incorrect colors.

### Issue Context
Full 3D volume rendering sets `cmin`, `cmax`, and `colorbar` on the trace; the patch path should keep those consistent when it is used.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[1109-1121]
- toolchain/mfc/viz/interactive.py[542-550]

### Suggested fix
In the volume branch of the `_do_patch_3d` path:
- Add:
  - `patch['data'][0]['cmin'] = cmin`
  - `patch['data'][0]['cmax'] = cmax`
  - `patch['data'][0]['colorbar'] = _make_cbar(cbar_title, cmin, cmax)` (or ensure the existing colorbar updates as desired)
- Consider also updating the trace title/legend fields if they depend on range/log settings.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +162 to +176
dx = float(x_ds[-1] - x_ds[0]) / max(len(x_ds) - 1, 1) if len(x_ds) > 1 else 1.0
dy = float(y_ds[-1] - y_ds[0]) / max(len(y_ds) - 1, 1) if len(y_ds) > 1 else 1.0
dz = float(z_ds[-1] - z_ds[0]) / max(len(z_ds) - 1, 1) if len(z_ds) > 1 else 1.0
spacing = (dx, dy, dz)

levels = np.linspace(ilo, ihi, max(int(iso_n), 1))
xs, ys, zs, ii, jj, kk, intens = [], [], [], [], [], [], []
offset = 0

for level in levels:
try:
verts, faces, _, _ = _marching_cubes(
vol, level=float(level), spacing=spacing,
allow_degenerate=False,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

4. Marching cubes uses uniform spacing 🐞 Bug ✓ Correctness

Server-side marching cubes computes a single constant spacing per axis from endpoints, which assumes
uniform x/y/z coordinates. The assembler builds coordinate arrays by deduplicating actual
cell-center positions (potentially non-uniform), so isosurface geometry can be spatially distorted
on stretched/non-uniform grids.
Agent Prompt
### Issue description
`_compute_isomesh` assumes uniform spacing by using a single `dx/dy/dz` per axis. For non-uniform coordinate vectors, this produces distorted isosurface geometry.

### Issue Context
The reader/assembler constructs coordinate vectors from unique actual cell-center positions, which can be non-uniform (e.g., stretched grids). `skimage.measure.marching_cubes` only accepts constant spacing.

### Fix Focus Areas
- toolchain/mfc/viz/interactive.py[162-185]
- toolchain/mfc/viz/reader.py[387-419]

### Suggested fix
- Call `marching_cubes` with `spacing=(1.0, 1.0, 1.0)` (index space).
- Convert `verts[:,0]` (index-coordinate) to physical x via interpolation over `x_ds`:
  - `x = np.interp(verts[:,0], np.arange(len(x_ds)), x_ds)`
  - same for y/z.
- Remove the current origin-shift logic (or keep only if still needed after mapping).
- Add a small unit/integration test (or a debug assertion) to detect strongly non-uniform axes and validate geometry mapping.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 44.95%. Comparing base (ce98373) to head (1c0f4f8).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1293   +/-   ##
=======================================
  Coverage   44.95%   44.95%           
=======================================
  Files          70       70           
  Lines       20503    20503           
  Branches     1946     1946           
=======================================
  Hits         9217     9217           
  Misses      10164    10164           
  Partials     1122     1122           

☔ 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.

@sbryngelson sbryngelson deleted the interactive-viz branch March 17, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants