From 562919ff62a758fa30f043fc70702d4e46a24b26 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 09:57:12 -0500 Subject: [PATCH 001/102] Add `./mfc.sh viz` command for CLI visualization of post-processed output Adds a new `viz` subcommand that reads MFC binary (and optionally Silo-HDF5) post-processed output and renders 1D line plots, 2D colormaps, 3D slices, and MP4 videos directly from the command line with no GUI required. New files: - toolchain/mfc/viz/{__init__,reader,silo_reader,renderer,viz}.py - toolchain/mfc/viz_legacy.py (renamed from toolchain/mfc/viz.py) Modified files: - toolchain/mfc/cli/commands.py (VIZ_COMMAND definition) - toolchain/main.py (dispatch + skip cmake check for viz) - toolchain/mfc/args.py (add viz to relevant_subparsers) - examples/{1D_inert_shocktube,1D_reactive_shocktube}/viz.py (update imports) - examples/nD_perfect_reactor/{analyze,export}.py (update imports) Co-Authored-By: Claude Opus 4.6 --- examples/1D_inert_shocktube/viz.py | 6 +- examples/1D_reactive_shocktube/viz.py | 6 +- examples/nD_perfect_reactor/analyze.py | 6 +- examples/nD_perfect_reactor/export.py | 4 +- toolchain/main.py | 5 + toolchain/mfc/args.py | 4 +- toolchain/mfc/cli/commands.py | 151 +++++++++ toolchain/mfc/viz/__init__.py | 0 toolchain/mfc/viz/reader.py | 413 ++++++++++++++++++++++++ toolchain/mfc/viz/renderer.py | 228 +++++++++++++ toolchain/mfc/viz/silo_reader.py | 256 +++++++++++++++ toolchain/mfc/viz/viz.py | 200 ++++++++++++ toolchain/mfc/{viz.py => viz_legacy.py} | 0 13 files changed, 1266 insertions(+), 13 deletions(-) create mode 100644 toolchain/mfc/viz/__init__.py create mode 100644 toolchain/mfc/viz/reader.py create mode 100644 toolchain/mfc/viz/renderer.py create mode 100644 toolchain/mfc/viz/silo_reader.py create mode 100644 toolchain/mfc/viz/viz.py rename toolchain/mfc/{viz.py => viz_legacy.py} (100%) diff --git a/examples/1D_inert_shocktube/viz.py b/examples/1D_inert_shocktube/viz.py index 6d96f4a8bb..eb477f2c2a 100644 --- a/examples/1D_inert_shocktube/viz.py +++ b/examples/1D_inert_shocktube/viz.py @@ -1,4 +1,4 @@ -import mfc.viz +import mfc.viz_legacy as mfc_viz import os import subprocess @@ -8,11 +8,11 @@ from case import sol_L as sol -case = mfc.viz.Case(".") +case = mfc_viz.Case(".") os.makedirs("viz", exist_ok=True) -# sns.set_theme(style=mfc.viz.generate_cpg_style()) +# sns.set_theme(style=mfc_viz.generate_cpg_style()) Y_VARS = ["H2", "O2", "H2O", "N2"] diff --git a/examples/1D_reactive_shocktube/viz.py b/examples/1D_reactive_shocktube/viz.py index 63c769fca9..2a38e21b2c 100644 --- a/examples/1D_reactive_shocktube/viz.py +++ b/examples/1D_reactive_shocktube/viz.py @@ -1,4 +1,4 @@ -import mfc.viz +import mfc.viz_legacy as mfc_viz import os import subprocess @@ -8,11 +8,11 @@ from case import sol_L as sol -case = mfc.viz.Case(".") +case = mfc_viz.Case(".") os.makedirs("viz", exist_ok=True) -sns.set_theme(style=mfc.viz.generate_cpg_style()) +sns.set_theme(style=mfc_viz.generate_cpg_style()) Y_VARS = ["H2", "O2", "H2O", "N2"] diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py index b2bd4e1fa8..51ec9f3456 100644 --- a/examples/nD_perfect_reactor/analyze.py +++ b/examples/nD_perfect_reactor/analyze.py @@ -3,12 +3,12 @@ from tqdm import tqdm import matplotlib.pyplot as plt -import mfc.viz +import mfc.viz_legacy as mfc_viz from case import dt, Tend, SAVE_COUNT, sol -case = mfc.viz.Case(".", dt) +case = mfc_viz.Case(".", dt) -sns.set_theme(style=mfc.viz.generate_cpg_style()) +sns.set_theme(style=mfc_viz.generate_cpg_style()) Y_MAJORS = set(["H", "O", "OH", "HO2"]) Y_MINORS = set(["H2O", "H2O2"]) diff --git a/examples/nD_perfect_reactor/export.py b/examples/nD_perfect_reactor/export.py index 112622a32f..1d042791a8 100644 --- a/examples/nD_perfect_reactor/export.py +++ b/examples/nD_perfect_reactor/export.py @@ -2,10 +2,10 @@ import cantera as ct from tqdm import tqdm -import mfc.viz +import mfc.viz_legacy as mfc_viz from case import dt, NS, Tend, SAVE_COUNT, sol -case = mfc.viz.Case(".", dt) +case = mfc_viz.Case(".", dt) for name in tqdm(sol.species_names, desc="Loading Variables"): case.load_variable(f"Y_{name}", f"prim.{5 + sol.species_index(name)}") diff --git a/toolchain/main.py b/toolchain/main.py index 568db1db67..61df696faf 100644 --- a/toolchain/main.py +++ b/toolchain/main.py @@ -125,6 +125,8 @@ def __print_greeting(): def __checks(): + if ARG("command") in ("viz", "params", "completion", "help"): + return if not does_command_exist("cmake"): raise MFCException("CMake is required to build MFC but couldn't be located on your system. Please ensure it installed and discoverable (e.g in your system's $PATH).") @@ -175,6 +177,9 @@ def __run(): # pylint: disable=too-many-branches elif cmd == "generate": from mfc import generate # pylint: disable=import-outside-toplevel generate.generate() + elif cmd == "viz": + from mfc.viz import viz # pylint: disable=import-outside-toplevel + viz.viz() elif cmd == "params": from mfc import params_cmd # pylint: disable=import-outside-toplevel params_cmd.params() diff --git a/toolchain/mfc/args.py b/toolchain/mfc/args.py index a05fdd5d72..4601943d3f 100644 --- a/toolchain/mfc/args.py +++ b/toolchain/mfc/args.py @@ -109,7 +109,7 @@ def custom_error(message): # Add default arguments of other subparsers # This ensures all argument keys exist even for commands that don't define them # Only process subparsers that have common arguments we need - relevant_subparsers = ["run", "test", "build", "clean", "count", "count_diff", "validate"] + relevant_subparsers = ["run", "test", "build", "clean", "count", "count_diff", "validate", "viz"] for name in relevant_subparsers: if args["command"] == name: continue @@ -120,7 +120,7 @@ def custom_error(message): # Parse with dummy input to get defaults (suppress errors for required positionals) try: # Commands with required positional input need a dummy value - if name in ["run", "validate"]: + if name in ["run", "validate", "viz"]: vals, _ = subparser.parse_known_args(["dummy_input.py"]) elif name == "build": vals, _ = subparser.parse_known_args([]) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 8ad8c4bd07..b58ad4bcf9 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -861,6 +861,156 @@ include_common=["targets", "mfc_config", "jobs", "verbose", "debug_log"], ) +VIZ_COMMAND = Command( + name="viz", + help="Visualize post-processed MFC output.", + description="Render 2D colormaps, 3D slices, 1D line plots, and MP4 videos from MFC post-processed output (binary or Silo-HDF5).", + positionals=[ + Positional( + name="input", + help="Path to the case directory containing binary/ or silo_hdf5/ output.", + completion=Completion(type=CompletionType.DIRECTORIES), + ), + ], + arguments=[ + Argument( + name="var", + help="Variable name to visualize (e.g. pres, rho, schlieren).", + type=str, + default=None, + metavar="VAR", + ), + Argument( + name="step", + help="Timestep(s): single int, start:end:stride, or 'all'.", + type=str, + default=None, + metavar="STEP", + ), + Argument( + name="format", + short="f", + help="Output format: binary or silo (auto-detected if omitted).", + type=str, + default=None, + choices=["binary", "silo"], + completion=Completion(type=CompletionType.CHOICES, choices=["binary", "silo"]), + ), + Argument( + name="output", + short="o", + help="Output directory for rendered images/videos.", + type=str, + default=None, + metavar="DIR", + completion=Completion(type=CompletionType.DIRECTORIES), + ), + Argument( + name="cmap", + help="Matplotlib colormap name (default: viridis).", + type=str, + default=None, + metavar="CMAP", + ), + Argument( + name="vmin", + help="Minimum value for color scale.", + type=float, + default=None, + metavar="VMIN", + ), + Argument( + name="vmax", + help="Maximum value for color scale.", + type=float, + default=None, + metavar="VMAX", + ), + Argument( + name="dpi", + help="Image resolution in DPI (default: 150).", + type=int, + default=None, + metavar="DPI", + ), + Argument( + name="slice-axis", + help="Axis for 3D slice: x, y, or z (default: z).", + type=str, + default=None, + choices=["x", "y", "z"], + dest="slice_axis", + completion=Completion(type=CompletionType.CHOICES, choices=["x", "y", "z"]), + ), + Argument( + name="slice-value", + help="Coordinate value at which to take the 3D slice.", + type=float, + default=None, + dest="slice_value", + metavar="VAL", + ), + Argument( + name="slice-index", + help="Array index at which to take the 3D slice.", + type=int, + default=None, + dest="slice_index", + metavar="IDX", + ), + Argument( + name="mp4", + help="Generate an MP4 video instead of individual PNGs.", + action=ArgAction.STORE_TRUE, + default=False, + ), + Argument( + name="fps", + help="Frames per second for MP4 output (default: 10).", + type=int, + default=None, + metavar="FPS", + ), + Argument( + name="list-vars", + help="List available variable names and exit.", + action=ArgAction.STORE_TRUE, + default=False, + dest="list_vars", + ), + Argument( + name="list-steps", + help="List available timesteps and exit.", + action=ArgAction.STORE_TRUE, + default=False, + dest="list_steps", + ), + Argument( + name="log-scale", + help="Use logarithmic color scale.", + action=ArgAction.STORE_TRUE, + default=False, + dest="log_scale", + ), + ], + examples=[ + Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Plot pressure at step 1000"), + Example("./mfc.sh viz case_dir/ --list-vars --step 0", "List available variables"), + Example("./mfc.sh viz case_dir/ --list-steps", "List available timesteps"), + Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video"), + Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z", "3D slice at z midplane"), + ], + key_options=[ + ("--var NAME", "Variable to visualize"), + ("--step STEP", "Timestep(s): int, start:end:stride, or 'all'"), + ("--list-vars", "List available variables"), + ("--list-steps", "List available timesteps"), + ("--mp4", "Generate MP4 video"), + ("--cmap NAME", "Matplotlib colormap"), + ("--slice-axis x|y|z", "Axis for 3D slice"), + ], +) + PARAMS_COMMAND = Command( name="params", help="Search and explore MFC case parameters.", @@ -1002,6 +1152,7 @@ CLEAN_COMMAND, VALIDATE_COMMAND, NEW_COMMAND, + VIZ_COMMAND, PARAMS_COMMAND, PACKER_COMMAND, COMPLETION_COMMAND, diff --git a/toolchain/mfc/viz/__init__.py b/toolchain/mfc/viz/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py new file mode 100644 index 0000000000..426126874e --- /dev/null +++ b/toolchain/mfc/viz/reader.py @@ -0,0 +1,413 @@ +""" +Binary format reader for MFC post-processed output. + +Reads Fortran unformatted binary files produced by MFC's post_process +with format=2. Each Fortran `write` produces one record: + [4-byte record-marker][payload][4-byte record-marker] + +File layout per processor: + Record 1 (header): m(int32), n(int32), p(int32), dbvars(int32) + Record 2 (grid): x_cb [, y_cb [, z_cb]] (float32 or float64) + Records 3..N (vars): varname(50-char) + data((m+1)*(n+1)*(p+1) floats) +""" + +import os +import struct +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +import numpy as np + + +NAME_LEN = 50 # Fortran character length for variable names + + +@dataclass +class ProcessorData: + """Data from a single processor file.""" + m: int + n: int + p: int + x_cb: np.ndarray + y_cb: np.ndarray + z_cb: np.ndarray + variables: Dict[str, np.ndarray] = field(default_factory=dict) + + +@dataclass +class AssembledData: + """Assembled multi-processor data on a global grid.""" + ndim: int + x_cc: np.ndarray + y_cc: np.ndarray + z_cc: np.ndarray + variables: Dict[str, np.ndarray] = field(default_factory=dict) + + +def read_record(f) -> bytes: + """Read one Fortran unformatted record, returning the payload bytes.""" + raw = f.read(4) + if len(raw) < 4: + raise EOFError("Unexpected end of file reading record marker") + rec_len = struct.unpack('i', raw)[0] + if rec_len < 0: + raise ValueError(f"Invalid record length: {rec_len}") + payload = f.read(rec_len) + if len(payload) < rec_len: + raise EOFError("Unexpected end of file reading record payload") + f.read(4) # trailing marker + return payload + + +def _detect_endianness(path: str) -> str: + """Detect endianness from the first record marker (should be 16 for header).""" + with open(path, 'rb') as f: + raw = f.read(4) + le = struct.unpack('i', raw)[0] + if be == 16: + return '>' + raise ValueError( + f"Cannot detect endianness: first record marker is {le} (LE) / {be} (BE), expected 16" + ) + + +def _read_record_endian(f, endian: str) -> bytes: + """Read one Fortran unformatted record with known endianness.""" + raw = f.read(4) + if len(raw) < 4: + raise EOFError("Unexpected end of file reading record marker") + rec_len = struct.unpack(f'{endian}i', raw)[0] + payload = f.read(rec_len) + if len(payload) < rec_len: + raise EOFError("Unexpected end of file reading record payload") + f.read(4) # trailing marker + return payload + + +def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: + """ + Read a single MFC binary post-process file. + + Args: + path: Path to the .dat file. + var_filter: If given, only load this variable (skip others). + + Returns: + ProcessorData with grid and variable data. + """ + endian = _detect_endianness(path) + + with open(path, 'rb') as f: + # Record 1: header [m, n, p, dbvars] — 4 int32 + hdr = _read_record_endian(f, endian) + m, n, p, dbvars = struct.unpack(f'{endian}4i', hdr) + + # Record 2: grid coordinates — all in one record + grid_raw = _read_record_endian(f, endian) + grid_bytes = len(grid_raw) + + # Determine number of grid values + if p > 0: + n_vals = (m + 2) + (n + 2) + (p + 2) + elif n > 0: + n_vals = (m + 2) + (n + 2) + else: + n_vals = (m + 2) + + # 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') + else: + raise ValueError( + f"Cannot determine grid precision: {grid_bytes} bytes for {n_vals} values " + f"({bytes_per_val:.1f} bytes/value)" + ) + + grid_arr = np.frombuffer(grid_raw, dtype=grid_dtype) + + # Split into x_cb, y_cb, z_cb + offset = 0 + x_cb = grid_arr[offset:offset + m + 2].astype(np.float64) + offset += m + 2 + if n > 0: + y_cb = grid_arr[offset:offset + n + 2].astype(np.float64) + offset += n + 2 + else: + y_cb = np.array([0.0]) + if p > 0: + z_cb = grid_arr[offset:offset + p + 2].astype(np.float64) + else: + z_cb = np.array([0.0]) + + # Records 3..N: variables + variables: Dict[str, np.ndarray] = {} + data_size = (m + 1) * max(n + 1, 1) * max(p + 1, 1) + + for _ in range(dbvars): + var_raw = _read_record_endian(f, endian) + varname = var_raw[:NAME_LEN].decode('ascii', errors='replace').strip() + + if var_filter is not None and varname != var_filter: + continue + + # Auto-detect variable data precision from record size + data_bytes = len(var_raw) - NAME_LEN + var_bpv = data_bytes / data_size + if abs(var_bpv - 8.0) < 0.5: + var_dtype = np.dtype(f'{endian}f8') + elif abs(var_bpv - 4.0) < 0.5: + var_dtype = np.dtype(f'{endian}f4') + else: + raise ValueError( + f"Cannot determine variable precision for '{varname}': " + f"{data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)" + ) + + data = np.frombuffer(var_raw[NAME_LEN:], dtype=var_dtype).astype(np.float64) + + # Reshape for multi-dimensional data (Fortran column-major order) + if p > 0: + data = data.reshape((m + 1, n + 1, p + 1), order='F') + elif n > 0: + data = data.reshape((m + 1, n + 1), order='F') + + variables[varname] = data + + return ProcessorData(m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables) + + +def discover_format(case_dir: str) -> str: + """Detect whether case has binary or silo_hdf5 output.""" + if os.path.isdir(os.path.join(case_dir, 'binary')): + return 'binary' + if os.path.isdir(os.path.join(case_dir, 'silo_hdf5')): + return 'silo' + raise FileNotFoundError( + f"No 'binary/' or 'silo_hdf5/' directory found in {case_dir}. " + "Run post_process with format=1 (Silo) or format=2 (binary) first." + ) + + +def discover_timesteps(case_dir: str, fmt: str) -> List[int]: + """Return sorted list of available timesteps.""" + if fmt == 'binary': + # Check root/ first (1D), then p0/ + root_dir = os.path.join(case_dir, 'binary', 'root') + if os.path.isdir(root_dir): + steps = set() + for fname in os.listdir(root_dir): + if fname.endswith('.dat'): + try: + steps.add(int(fname[:-4])) + except ValueError: + pass + if steps: + return sorted(steps) + + # Multi-dimensional: look in p0/ + p0_dir = os.path.join(case_dir, 'binary', 'p0') + if os.path.isdir(p0_dir): + steps = set() + for fname in os.listdir(p0_dir): + if fname.endswith('.dat'): + try: + steps.add(int(fname[:-4])) + except ValueError: + pass + return sorted(steps) + + elif fmt == 'silo': + p0_dir = os.path.join(case_dir, 'silo_hdf5', 'p0') + if os.path.isdir(p0_dir): + steps = set() + for dname in os.listdir(p0_dir): + if dname.startswith('t_step='): + try: + steps.add(int(dname.split('=')[1])) + except (ValueError, IndexError): + pass + return sorted(steps) + + return [] + + +def _discover_processors(case_dir: str, fmt: str) -> List[int]: + """Return sorted list of processor ranks.""" + if fmt == 'binary': + base = os.path.join(case_dir, 'binary') + else: + base = os.path.join(case_dir, 'silo_hdf5') + + ranks = [] + if not os.path.isdir(base): + return ranks + for entry in os.listdir(base): + if entry.startswith('p') and entry[1:].isdigit(): + ranks.append(int(entry[1:])) + return sorted(ranks) + + +def _is_1d(case_dir: str) -> bool: + """Check if the output is 1D (has binary/root/ directory).""" + return os.path.isdir(os.path.join(case_dir, 'binary', 'root')) + + +def assemble(case_dir: str, step: int, fmt: str = 'binary', + var: Optional[str] = None) -> AssembledData: + """ + Read and assemble multi-processor data for a given timestep. + + For 1D, reads the root file directly. + For 2D/3D, reads all processor files and assembles into global arrays. + """ + if fmt != 'binary': + raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.") + + # 1D case: read root file directly + if _is_1d(case_dir): + root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat') + if not os.path.isfile(root_path): + raise FileNotFoundError(f"Root file not found: {root_path}") + pdata = read_binary_file(root_path, var_filter=var) + x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0 + return AssembledData( + ndim=1, x_cc=x_cc, + y_cc=np.array([0.0]), z_cc=np.array([0.0]), + variables=pdata.variables, + ) + + # Multi-dimensional: read all processor files + ranks = _discover_processors(case_dir, fmt) + if not ranks: + raise FileNotFoundError(f"No processor directories found in {case_dir}/binary/") + + # Read all processor data + proc_data: List[Tuple[int, ProcessorData]] = [] + for rank in ranks: + fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') + if not os.path.isfile(fpath): + continue + pdata = read_binary_file(fpath, var_filter=var) + if pdata.m == 0 and pdata.n == 0 and pdata.p == 0: + continue + proc_data.append((rank, pdata)) + + if not proc_data: + raise FileNotFoundError(f"No valid processor data found for step {step}") + + ndim = 1 + sample = proc_data[0][1] + if sample.n > 0: + ndim = 2 + if sample.p > 0: + ndim = 3 + + # Compute cell centers for each processor + proc_centers = [] + for rank, pd in proc_data: + 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]) + proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) + + # Build sorted unique coordinate sets to determine global ordering + # Sort processors by their coordinate origins + all_x_origins = sorted(set(c[2][0] for c in proc_centers)) + all_y_origins = sorted(set(c[3][0] for c in proc_centers)) if ndim >= 2 else [0.0] + all_z_origins = sorted(set(c[4][0] for c in proc_centers)) if ndim >= 3 else [0.0] + + # Build global coordinate arrays + # For each unique origin in each dimension, accumulate sizes + x_chunks: Dict[float, Tuple[int, np.ndarray]] = {} + y_chunks: Dict[float, Tuple[int, np.ndarray]] = {} + z_chunks: Dict[float, Tuple[int, np.ndarray]] = {} + + for rank, pd, x_cc, y_cc, z_cc in proc_centers: + x_key = round(x_cc[0], 12) + y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 + z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 + if x_key not in x_chunks: + x_chunks[x_key] = (len(x_cc), x_cc) + if y_key not in y_chunks: + y_chunks[y_key] = (len(y_cc), y_cc) + if z_key not in z_chunks: + z_chunks[z_key] = (len(z_cc), z_cc) + + # Build global coordinate arrays by concatenating sorted chunks + sorted_x_keys = sorted(x_chunks.keys()) + sorted_y_keys = sorted(y_chunks.keys()) + sorted_z_keys = sorted(z_chunks.keys()) + + global_x = np.concatenate([x_chunks[k][1] for k in sorted_x_keys]) + global_y = np.concatenate([y_chunks[k][1] for k in sorted_y_keys]) if ndim >= 2 else np.array([0.0]) + global_z = np.concatenate([z_chunks[k][1] for k in sorted_z_keys]) if ndim >= 3 else np.array([0.0]) + + # Compute offsets for each origin + x_offsets: Dict[float, int] = {} + off = 0 + for k in sorted_x_keys: + x_offsets[k] = off + off += x_chunks[k][0] + + y_offsets: Dict[float, int] = {} + off = 0 + for k in sorted_y_keys: + y_offsets[k] = off + off += y_chunks[k][0] + + z_offsets: Dict[float, int] = {} + off = 0 + for k in sorted_z_keys: + z_offsets[k] = off + off += z_chunks[k][0] + + # Get all variable names from first processor + varnames = list(proc_data[0][1].variables.keys()) + + # Allocate global arrays + nx = len(global_x) + ny = len(global_y) + nz = len(global_z) + + global_vars: Dict[str, np.ndarray] = {} + for vn in varnames: + if ndim == 3: + global_vars[vn] = np.zeros((nx, ny, nz)) + elif ndim == 2: + global_vars[vn] = np.zeros((nx, ny)) + else: + global_vars[vn] = np.zeros(nx) + + # Place each processor's data at the correct offset + for rank, pd, x_cc, y_cc, z_cc in proc_centers: + x_key = round(x_cc[0], 12) + y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 + z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 + + xi = x_offsets[x_key] + yi = y_offsets[y_key] if ndim >= 2 else 0 + zi = z_offsets[z_key] if ndim >= 3 else 0 + + for vn, data in pd.variables.items(): + if vn not in global_vars: + continue + if ndim == 3: + global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1, zi:zi + pd.p + 1] = data + elif ndim == 2: + global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1] = data + else: + global_vars[vn][xi:xi + pd.m + 1] = data + + return AssembledData( + ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, + variables=global_vars, + ) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py new file mode 100644 index 0000000000..12f21d549f --- /dev/null +++ b/toolchain/mfc/viz/renderer.py @@ -0,0 +1,228 @@ +""" +Image and video rendering for MFC visualization. + +Produces PNG images (1D line plots, 2D colormaps) and MP4 videos +from assembled MFC data. Uses matplotlib with the Agg backend +for headless rendering. +""" + +import os +import subprocess +from typing import Optional, List + +import numpy as np + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.colors import LogNorm # noqa: E402 + + +def render_1d(x_cc, data, varname, step, output, **opts): + """Render a 1D line plot and save as PNG.""" + fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 6))) + ax.plot(x_cc, data, linewidth=1.5) + ax.set_xlabel('x') + ax.set_ylabel(varname) + ax.set_title(f'{varname} (step {step})') + + vmin = opts.get('vmin') + vmax = opts.get('vmax') + if vmin is not None or vmax is not None: + ax.set_ylim(vmin, vmax) + + fig.tight_layout() + fig.savefig(output, dpi=opts.get('dpi', 150)) + plt.close(fig) + + +def render_2d(x_cc, y_cc, data, varname, step, output, **opts): + """Render a 2D colormap via pcolormesh and save as PNG.""" + fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 8))) + + cmap = opts.get('cmap', 'viridis') + vmin = opts.get('vmin') + vmax = opts.get('vmax') + log_scale = opts.get('log_scale', False) + + norm = None + if log_scale: + 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) + norm = LogNorm(vmin=lo, vmax=hi) + vmin = None + vmax = None + + # data shape is (nx, ny), pcolormesh expects (ny, nx) when using x_cc, y_cc + pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, + norm=norm, shading='auto') + fig.colorbar(pcm, ax=ax, label=varname) + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_title(f'{varname} (step {step})') + ax.set_aspect('equal', adjustable='box') + + fig.tight_layout() + fig.savefig(output, dpi=opts.get('dpi', 150)) + plt.close(fig) + + +def render_3d_slice(assembled, varname, step, output, slice_axis='z', + slice_index=None, slice_value=None, **opts): + """Extract a 2D slice from 3D data and render as a colormap.""" + data_3d = assembled.variables[varname] + + axis_map = {'x': 0, 'y': 1, 'z': 2} + axis_idx = axis_map[slice_axis] + + coords = [assembled.x_cc, assembled.y_cc, assembled.z_cc] + coord_along = coords[axis_idx] + + if slice_index is not None: + idx = slice_index + elif slice_value is not None: + idx = int(np.argmin(np.abs(coord_along - slice_value))) + else: + idx = len(coord_along) // 2 + + idx = max(0, min(idx, len(coord_along) - 1)) + + if axis_idx == 0: + sliced = data_3d[idx, :, :] + x_plot, y_plot = assembled.y_cc, assembled.z_cc + xlabel, ylabel = 'y', 'z' + elif axis_idx == 1: + sliced = data_3d[:, idx, :] + x_plot, y_plot = assembled.x_cc, assembled.z_cc + xlabel, ylabel = 'x', 'z' + else: + sliced = data_3d[:, :, idx] + x_plot, y_plot = assembled.x_cc, assembled.y_cc + xlabel, ylabel = 'x', 'y' + + fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 8))) + + cmap = opts.get('cmap', 'viridis') + vmin = opts.get('vmin') + vmax = opts.get('vmax') + log_scale = opts.get('log_scale', False) + + norm = None + if log_scale: + lo = vmin if vmin is not None else np.nanmin(sliced[sliced > 0]) if np.any(sliced > 0) else 1e-10 + hi = vmax if vmax is not None else np.nanmax(sliced) + norm = LogNorm(vmin=lo, vmax=hi) + vmin = None + vmax = None + + # sliced shape depends on axis: need to transpose appropriately + pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, + vmax=vmax, norm=norm, shading='auto') + fig.colorbar(pcm, ax=ax, label=varname) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + slice_coord = coord_along[idx] + ax.set_title(f'{varname} (step {step}, {slice_axis}={slice_coord:.4g})') + ax.set_aspect('equal', adjustable='box') + + fig.tight_layout() + fig.savefig(output, dpi=opts.get('dpi', 150)) + plt.close(fig) + + +def render_mp4(case_dir, varname, steps, output, fps=10, + read_func=None, **opts): + """ + Generate an MP4 video by iterating over timesteps. + + Args: + case_dir: Path to the case directory. + varname: Variable name to plot. + steps: List of timestep integers. + output: Output MP4 file path. + fps: Frames per second. + read_func: Callable(step) -> AssembledData for loading each frame. + **opts: Rendering options (cmap, vmin, vmax, dpi, log_scale, figsize, + slice_axis, slice_index, slice_value). + """ + if read_func is None: + raise ValueError("read_func must be provided for MP4 rendering") + + if not steps: + raise ValueError("No timesteps provided for MP4 generation") + + # Pre-compute vmin/vmax from first and last frames if not provided + auto_vmin = opts.get('vmin') + auto_vmax = opts.get('vmax') + + if auto_vmin is None or auto_vmax is None: + sample_steps = [steps[0]] + if len(steps) > 1: + sample_steps.append(steps[-1]) + if len(steps) > 2: + sample_steps.append(steps[len(steps) // 2]) + + all_mins, all_maxs = [], [] + for s in sample_steps: + ad = read_func(s) + d = ad.variables.get(varname) + if d is not None: + all_mins.append(np.nanmin(d)) + all_maxs.append(np.nanmax(d)) + + if auto_vmin is None and all_mins: + opts['vmin'] = min(all_mins) + if auto_vmax is None and all_maxs: + opts['vmax'] = max(all_maxs) + + # Write frames as PNGs to a temp directory + viz_dir = os.path.join(case_dir, 'viz', '_frames') + os.makedirs(viz_dir, exist_ok=True) + + try: + from tqdm import tqdm # pylint: disable=import-outside-toplevel + step_iter = tqdm(steps, desc='Rendering frames') + except ImportError: + step_iter = steps + + for i, step in enumerate(step_iter): + assembled = read_func(step) + frame_path = os.path.join(viz_dir, f'{i:06d}.png') + + if assembled.ndim == 1: + render_1d(assembled.x_cc, assembled.variables[varname], + varname, step, frame_path, **opts) + elif assembled.ndim == 2: + render_2d(assembled.x_cc, assembled.y_cc, + assembled.variables[varname], + varname, step, frame_path, **opts) + elif assembled.ndim == 3: + render_3d_slice(assembled, varname, step, frame_path, **opts) + + # Combine PNGs into MP4 using ffmpeg + frame_pattern = os.path.join(viz_dir, '%06d.png') + ffmpeg_cmd = [ + 'ffmpeg', '-y', + '-framerate', str(fps), + '-i', frame_pattern, + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', + output, + ] + + try: + subprocess.run(ffmpeg_cmd, check=True, capture_output=True) + except FileNotFoundError: + print(f"ffmpeg not found. Frames saved to {viz_dir}/") + print(f"To create video manually: ffmpeg -framerate {fps} -i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") + return + except subprocess.CalledProcessError as e: + print(f"ffmpeg failed: {e.stderr.decode()}") + print(f"Frames saved to {viz_dir}/") + return + + # Clean up frames + for fname in os.listdir(viz_dir): + os.remove(os.path.join(viz_dir, fname)) + os.rmdir(viz_dir) diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py new file mode 100644 index 0000000000..5ed2b61156 --- /dev/null +++ b/toolchain/mfc/viz/silo_reader.py @@ -0,0 +1,256 @@ +""" +Silo-HDF5 reader for MFC post-processed output. + +Silo files produced by MFC are valid HDF5 underneath. This reader +uses h5py to navigate the HDF5 tree and extract mesh coordinates +and variable arrays. + +Requires: h5py (optional dependency). +""" + +import os +from typing import Dict, List, Optional, Tuple + +import numpy as np + +from .reader import AssembledData, ProcessorData + +try: + import h5py + HAS_H5PY = True +except ImportError: + HAS_H5PY = False + + +def _check_h5py(): + if not HAS_H5PY: + raise ImportError( + "h5py is required to read Silo-HDF5 files.\n" + "Install it with: pip install h5py\n" + "Or re-run post_process with format=2 to produce binary output." + ) + + +def _find_mesh_and_vars(h5file): + """Navigate the HDF5 tree to find mesh coordinates and variables.""" + mesh_coords = {} + variables = {} + + # Silo stores data in a nested structure. Common patterns: + # // contains coordinate arrays + # Variables are stored at the top level or in subdirectories + for key in h5file.keys(): + obj = h5file[key] + if isinstance(obj, h5py.Dataset): + variables[key] = np.array(obj) + elif isinstance(obj, h5py.Group): + # Check for mesh data + for subkey in obj.keys(): + subobj = obj[subkey] + if isinstance(subobj, h5py.Dataset): + full_key = f"{key}/{subkey}" + arr = np.array(subobj) + if subkey in ('coord0', 'coord1', 'coord2'): + mesh_coords[subkey] = arr + elif subkey.startswith('_coord'): + mesh_coords[subkey] = arr + else: + variables[subkey] = arr + + return mesh_coords, variables + + +def read_silo_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: + """ + Read a single Silo-HDF5 file. + + Args: + path: Path to the .silo file. + var_filter: If given, only load this variable. + + Returns: + ProcessorData with grid and variable data. + """ + _check_h5py() + + with h5py.File(path, 'r') as f: + mesh_coords, raw_vars = _find_mesh_and_vars(f) + + # Extract coordinates + x_cb = mesh_coords.get('coord0', np.array([0.0, 1.0])) + y_cb = mesh_coords.get('coord1', np.array([0.0])) + z_cb = mesh_coords.get('coord2', np.array([0.0])) + + m = len(x_cb) - 2 if len(x_cb) > 1 else 0 + n = len(y_cb) - 2 if len(y_cb) > 1 else 0 + p = len(z_cb) - 2 if len(z_cb) > 1 else 0 + + variables = {} + for name, data in raw_vars.items(): + if var_filter is not None and name != var_filter: + continue + variables[name] = data.astype(np.float64) + + return ProcessorData(m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables) + + +def discover_timesteps_silo(case_dir: str) -> List[int]: + """Return sorted list of available timesteps from silo_hdf5/ directory.""" + p0_dir = os.path.join(case_dir, 'silo_hdf5', 'p0') + if not os.path.isdir(p0_dir): + return [] + steps = set() + for dname in os.listdir(p0_dir): + if dname.startswith('t_step='): + try: + steps.add(int(dname.split('=')[1])) + except (ValueError, IndexError): + pass + return sorted(steps) + + +def assemble_silo(case_dir: str, step: int, + var: Optional[str] = None) -> AssembledData: + """ + Read and assemble multi-processor Silo-HDF5 data for a given timestep. + """ + _check_h5py() + + base = os.path.join(case_dir, 'silo_hdf5') + ranks = [] + for entry in os.listdir(base): + if entry.startswith('p') and entry[1:].isdigit(): + ranks.append(int(entry[1:])) + ranks.sort() + + if not ranks: + raise FileNotFoundError(f"No processor directories in {base}") + + proc_data: List[Tuple[int, ProcessorData]] = [] + for rank in ranks: + silo_dir = os.path.join(base, f'p{rank}', f't_step={step}') + if not os.path.isdir(silo_dir): + continue + silo_file = os.path.join(silo_dir, f'{step}.silo') + if not os.path.isfile(silo_file): + # Try finding any .silo file in the directory + for f in os.listdir(silo_dir): + if f.endswith('.silo'): + silo_file = os.path.join(silo_dir, f) + break + if not os.path.isfile(silo_file): + continue + pdata = read_silo_file(silo_file, var_filter=var) + proc_data.append((rank, pdata)) + + if not proc_data: + raise FileNotFoundError(f"No Silo data found for step {step}") + + # For single processor, return directly + if len(proc_data) == 1: + _, pd = proc_data[0] + 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 + if pd.n > 0: + ndim = 2 + if pd.p > 0: + ndim = 3 + return AssembledData(ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc, + variables=pd.variables) + + # Multi-processor assembly — reuse binary reader's assembly logic + from .reader import assemble as _binary_assemble # noqa: avoid circular at module level + # Since silo files have the same ProcessorData structure, we can + # adapt the binary assembler. For now, use a simplified version. + + sample = proc_data[0][1] + ndim = 1 + if sample.n > 0: + ndim = 2 + if sample.p > 0: + ndim = 3 + + proc_centers = [] + for rank, pd in proc_data: + 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]) + proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) + + # Build global coordinates by sorting and concatenating unique chunks + x_chunks = {} + y_chunks = {} + z_chunks = {} + + for rank, pd, x_cc, y_cc, z_cc in proc_centers: + x_key = round(x_cc[0], 12) + y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 + z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 + if x_key not in x_chunks: + x_chunks[x_key] = (len(x_cc), x_cc) + if y_key not in y_chunks: + y_chunks[y_key] = (len(y_cc), y_cc) + if z_key not in z_chunks: + z_chunks[z_key] = (len(z_cc), z_cc) + + sorted_x_keys = sorted(x_chunks.keys()) + sorted_y_keys = sorted(y_chunks.keys()) + sorted_z_keys = sorted(z_chunks.keys()) + + global_x = np.concatenate([x_chunks[k][1] for k in sorted_x_keys]) + global_y = np.concatenate([y_chunks[k][1] for k in sorted_y_keys]) if ndim >= 2 else np.array([0.0]) + global_z = np.concatenate([z_chunks[k][1] for k in sorted_z_keys]) if ndim >= 3 else np.array([0.0]) + + x_offsets = {} + off = 0 + for k in sorted_x_keys: + x_offsets[k] = off + off += x_chunks[k][0] + + y_offsets = {} + off = 0 + for k in sorted_y_keys: + y_offsets[k] = off + off += y_chunks[k][0] + + z_offsets = {} + off = 0 + for k in sorted_z_keys: + z_offsets[k] = off + off += z_chunks[k][0] + + varnames = list(proc_data[0][1].variables.keys()) + nx, ny, nz = len(global_x), len(global_y), len(global_z) + + global_vars = {} + for vn in varnames: + if ndim == 3: + global_vars[vn] = np.zeros((nx, ny, nz)) + elif ndim == 2: + global_vars[vn] = np.zeros((nx, ny)) + else: + global_vars[vn] = np.zeros(nx) + + for rank, pd, x_cc, y_cc, z_cc in proc_centers: + x_key = round(x_cc[0], 12) + y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 + z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 + + xi = x_offsets[x_key] + yi = y_offsets[y_key] if ndim >= 2 else 0 + zi = z_offsets[z_key] if ndim >= 3 else 0 + + for vn, data in pd.variables.items(): + if vn not in global_vars: + continue + if ndim == 3: + global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1, zi:zi + pd.p + 1] = data + elif ndim == 2: + global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1] = data + else: + global_vars[vn][xi:xi + pd.m + 1] = data + + return AssembledData(ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, + variables=global_vars) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py new file mode 100644 index 0000000000..e83499e3a5 --- /dev/null +++ b/toolchain/mfc/viz/viz.py @@ -0,0 +1,200 @@ +""" +Main entry point for the ``./mfc.sh viz`` command. + +Dispatches to reader + renderer based on CLI arguments. +""" + +import os +import sys + +from mfc.state import ARG +from mfc.printer import cons + + +def _parse_steps(step_arg, available_steps): + """ + Parse the --step argument into a list of timestep integers. + + Formats: + - Single int: "1000" + - Range: "0:10000:500" (start:end:stride) + - "all": all available timesteps + """ + if step_arg is None or step_arg == 'all': + return available_steps + + if ':' in str(step_arg): + parts = str(step_arg).split(':') + start = int(parts[0]) + end = int(parts[1]) + stride = int(parts[2]) if len(parts) > 2 else 1 + requested = list(range(start, end + 1, stride)) + return [s for s in requested if s in set(available_steps)] + + return [int(step_arg)] + + +def viz(): + """Main viz command dispatcher.""" + from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel + from .renderer import render_1d, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel + + case_dir = ARG('input') + if case_dir is None: + cons.print("[bold red]Error:[/bold red] Please specify a case directory.") + sys.exit(1) + + # Resolve case directory + if not os.path.isdir(case_dir): + cons.print(f"[bold red]Error:[/bold red] Directory not found: {case_dir}") + sys.exit(1) + + # Auto-detect or use specified format + fmt_arg = ARG('format') + if fmt_arg: + fmt = fmt_arg + else: + fmt = discover_format(case_dir) + + cons.print(f"[bold]Format:[/bold] {fmt}") + + # Handle --list-steps + if ARG('list_steps'): + steps = discover_timesteps(case_dir, fmt) + if not steps: + cons.print("[yellow]No timesteps found.[/yellow]") + else: + cons.print(f"[bold]Available timesteps ({len(steps)}):[/bold]") + # Print in columns + line = "" + for i, s in enumerate(steps): + line += f"{s:>8}" + if (i + 1) % 10 == 0: + cons.print(line) + line = "" + if line: + cons.print(line) + return + + # Handle --list-vars (requires --step) + if ARG('list_vars'): + step_arg = ARG('step') + steps = discover_timesteps(case_dir, fmt) + if not steps: + cons.print("[bold red]Error:[/bold red] No timesteps found.") + sys.exit(1) + + if step_arg is None: + step = steps[0] + cons.print(f"[dim]Using first available timestep: {step}[/dim]") + else: + step = int(step_arg) + + if fmt == 'silo': + from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + assembled = assemble_silo(case_dir, step) + else: + assembled = assemble(case_dir, step, fmt) + + varnames = sorted(assembled.variables.keys()) + cons.print(f"[bold]Available variables ({len(varnames)}):[/bold]") + for vn in varnames: + data = assembled.variables[vn] + cons.print(f" {vn:<20s} min={data.min():.6g} max={data.max():.6g}") + return + + # For rendering, --var and --step are required + varname = ARG('var') + step_arg = ARG('step') + + if varname is None: + cons.print("[bold red]Error:[/bold red] --var is required for rendering. " + "Use --list-vars to see available variables.") + sys.exit(1) + + steps = discover_timesteps(case_dir, fmt) + if not steps: + cons.print("[bold red]Error:[/bold red] No timesteps found.") + sys.exit(1) + + requested_steps = _parse_steps(step_arg, steps) + if not requested_steps: + cons.print(f"[bold red]Error:[/bold red] No matching timesteps for --step {step_arg}") + sys.exit(1) + + # Collect rendering options + render_opts = {} + cmap = ARG('cmap') + if cmap: + render_opts['cmap'] = cmap + vmin = ARG('vmin') + if vmin is not None: + render_opts['vmin'] = float(vmin) + vmax = ARG('vmax') + if vmax is not None: + render_opts['vmax'] = float(vmax) + dpi = ARG('dpi') + if dpi is not None: + render_opts['dpi'] = int(dpi) + if ARG('log_scale'): + render_opts['log_scale'] = True + + slice_axis = ARG('slice_axis') + slice_index = ARG('slice_index') + slice_value = ARG('slice_value') + if slice_axis: + render_opts['slice_axis'] = slice_axis + if slice_index is not None: + render_opts['slice_index'] = int(slice_index) + if slice_value is not None: + render_opts['slice_value'] = float(slice_value) + + # Choose read function based on format + def read_step(step): + if fmt == 'silo': + from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + return assemble_silo(case_dir, step, var=varname) + return assemble(case_dir, step, fmt, var=varname) + + # Create output directory + output_base = ARG('output') + if output_base is None: + output_base = os.path.join(case_dir, 'viz') + os.makedirs(output_base, exist_ok=True) + + # MP4 mode + if ARG('mp4'): + fps = ARG('fps') or 10 + mp4_path = os.path.join(output_base, f'{varname}.mp4') + cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") + render_mp4(case_dir, varname, requested_steps, mp4_path, + fps=int(fps), read_func=read_step, **render_opts) + cons.print(f"[bold green]Done:[/bold green] {mp4_path}") + return + + # Single or multiple PNG frames + try: + from tqdm import tqdm # pylint: disable=import-outside-toplevel + step_iter = tqdm(requested_steps, desc='Rendering') + except ImportError: + step_iter = requested_steps + + for step in step_iter: + assembled = read_step(step) + output_path = os.path.join(output_base, f'{varname}_{step}.png') + + if assembled.ndim == 1: + render_1d(assembled.x_cc, assembled.variables[varname], + varname, step, output_path, **render_opts) + elif assembled.ndim == 2: + render_2d(assembled.x_cc, assembled.y_cc, + assembled.variables[varname], + varname, step, output_path, **render_opts) + elif assembled.ndim == 3: + render_3d_slice(assembled, varname, step, output_path, **render_opts) + + if len(requested_steps) == 1: + cons.print(f"[bold green]Saved:[/bold green] {output_path}") + + if len(requested_steps) > 1: + cons.print(f"[bold green]Saved {len(requested_steps)} frames to:[/bold green] {output_base}/") diff --git a/toolchain/mfc/viz.py b/toolchain/mfc/viz_legacy.py similarity index 100% rename from toolchain/mfc/viz.py rename to toolchain/mfc/viz_legacy.py From 97f3bc7ff27881c184934734e7b660ed9632950d Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 15:29:03 -0500 Subject: [PATCH 002/102] Fix viz: validate variable name and fix lint/spelling issues Validate that the requested variable exists before rendering, showing available variables on error instead of a KeyError traceback. Also fix pylint warnings and typos checker false positives. Co-Authored-By: Claude Opus 4.6 --- .typos.toml | 1 + toolchain/mfc/cli/commands.py | 2 +- toolchain/mfc/viz/reader.py | 12 +++--------- toolchain/mfc/viz/renderer.py | 17 ++++++++--------- toolchain/mfc/viz/silo_reader.py | 10 +++------- toolchain/mfc/viz/viz.py | 16 +++++++++++++++- 6 files changed, 31 insertions(+), 27 deletions(-) diff --git a/.typos.toml b/.typos.toml index 432385eef0..6123410cb1 100644 --- a/.typos.toml +++ b/.typos.toml @@ -28,6 +28,7 @@ choises = "choises" # appears in comment explaining validation purpose ordr = "ordr" # typo for "order" in "weno_ordr" - tests param suggestions unknwn = "unknwn" # typo for "unknown" - tests unknown param detection tru = "tru" # typo for "true" in "when_tru" - tests dependency keys +PNGs = "PNGs" [files] extend-exclude = ["docs/documentation/references*", "docs/references.bib", "tests/", "toolchain/cce_simulation_workgroup_256.sh", "build-docs/"] diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index b58ad4bcf9..6eb4c50236 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -960,7 +960,7 @@ ), Argument( name="mp4", - help="Generate an MP4 video instead of individual PNGs.", + help="Generate an MP4 video instead of individual images.", action=ArgAction.STORE_TRUE, default=False, ), diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 426126874e..7c74019919 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -90,7 +90,7 @@ def _read_record_endian(f, endian: str) -> bytes: return payload -def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: +def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: # pylint: disable=too-many-locals,too-many-statements """ Read a single MFC binary post-process file. @@ -118,7 +118,7 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa elif n > 0: n_vals = (m + 2) + (n + 2) else: - n_vals = (m + 2) + n_vals = m + 2 # Auto-detect grid precision from record size bytes_per_val = grid_bytes / n_vals @@ -261,7 +261,7 @@ def _is_1d(case_dir: str) -> bool: return os.path.isdir(os.path.join(case_dir, 'binary', 'root')) -def assemble(case_dir: str, step: int, fmt: str = 'binary', +def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals,too-many-statements var: Optional[str] = None) -> AssembledData: """ Read and assemble multi-processor data for a given timestep. @@ -319,12 +319,6 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0]) proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - # Build sorted unique coordinate sets to determine global ordering - # Sort processors by their coordinate origins - all_x_origins = sorted(set(c[2][0] for c in proc_centers)) - all_y_origins = sorted(set(c[3][0] for c in proc_centers)) if ndim >= 2 else [0.0] - all_z_origins = sorted(set(c[4][0] for c in proc_centers)) if ndim >= 3 else [0.0] - # Build global coordinate arrays # For each unique origin in each dimension, accumulate sizes x_chunks: Dict[float, Tuple[int, np.ndarray]] = {} diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 12f21d549f..8b49555501 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -8,17 +8,16 @@ import os import subprocess -from typing import Optional, List import numpy as np import matplotlib matplotlib.use('Agg') -import matplotlib.pyplot as plt # noqa: E402 -from matplotlib.colors import LogNorm # noqa: E402 +import matplotlib.pyplot as plt # pylint: disable=wrong-import-position +from matplotlib.colors import LogNorm # pylint: disable=wrong-import-position -def render_1d(x_cc, data, varname, step, output, **opts): +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))) ax.plot(x_cc, data, linewidth=1.5) @@ -36,7 +35,7 @@ def render_1d(x_cc, data, varname, step, output, **opts): plt.close(fig) -def render_2d(x_cc, y_cc, data, varname, step, output, **opts): +def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals """Render a 2D colormap via pcolormesh and save as PNG.""" fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 8))) @@ -67,7 +66,7 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): plt.close(fig) -def render_3d_slice(assembled, varname, step, output, slice_axis='z', +def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches slice_index=None, slice_value=None, **opts): """Extract a 2D slice from 3D data and render as a colormap.""" data_3d = assembled.variables[varname] @@ -130,7 +129,7 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', plt.close(fig) -def render_mp4(case_dir, varname, steps, output, fps=10, +def render_mp4(case_dir, varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements read_func=None, **opts): """ Generate an MP4 video by iterating over timesteps. @@ -175,7 +174,7 @@ def render_mp4(case_dir, varname, steps, output, fps=10, if auto_vmax is None and all_maxs: opts['vmax'] = max(all_maxs) - # Write frames as PNGs to a temp directory + # Write frames as images to a temp directory viz_dir = os.path.join(case_dir, 'viz', '_frames') os.makedirs(viz_dir, exist_ok=True) @@ -199,7 +198,7 @@ def render_mp4(case_dir, varname, steps, output, fps=10, elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, frame_path, **opts) - # Combine PNGs into MP4 using ffmpeg + # Combine frames into MP4 using ffmpeg frame_pattern = os.path.join(viz_dir, '%06d.png') ffmpeg_cmd = [ 'ffmpeg', '-y', diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 5ed2b61156..68980b65d6 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -9,7 +9,7 @@ """ import os -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple import numpy as np @@ -48,7 +48,6 @@ def _find_mesh_and_vars(h5file): for subkey in obj.keys(): subobj = obj[subkey] if isinstance(subobj, h5py.Dataset): - full_key = f"{key}/{subkey}" arr = np.array(subobj) if subkey in ('coord0', 'coord1', 'coord2'): mesh_coords[subkey] = arr @@ -109,7 +108,7 @@ def discover_timesteps_silo(case_dir: str) -> List[int]: return sorted(steps) -def assemble_silo(case_dir: str, step: int, +def assemble_silo(case_dir: str, step: int, # pylint: disable=too-many-locals,too-many-statements var: Optional[str] = None) -> AssembledData: """ Read and assemble multi-processor Silo-HDF5 data for a given timestep. @@ -160,10 +159,7 @@ def assemble_silo(case_dir: str, step: int, return AssembledData(ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc, variables=pd.variables) - # Multi-processor assembly — reuse binary reader's assembly logic - from .reader import assemble as _binary_assemble # noqa: avoid circular at module level - # Since silo files have the same ProcessorData structure, we can - # adapt the binary assembler. For now, use a simplified version. + # Multi-processor assembly — simplified version of binary reader's logic sample = proc_data[0][1] ndim = 1 diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index e83499e3a5..61872a0876 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -34,7 +34,7 @@ def _parse_steps(step_arg, available_steps): return [int(step_arg)] -def viz(): +def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches """Main viz command dispatcher.""" from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel from .renderer import render_1d, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel @@ -156,6 +156,20 @@ def read_step(step): return assemble_silo(case_dir, step, var=varname) return assemble(case_dir, step, fmt, var=varname) + # Validate variable name by reading the first timestep (without var filter) + def read_step_all_vars(step): + if fmt == 'silo': + from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + return assemble_silo(case_dir, step) + return assemble(case_dir, step, fmt) + + test_assembled = read_step_all_vars(requested_steps[0]) + if varname not in test_assembled.variables: + avail = sorted(test_assembled.variables.keys()) + cons.print(f"[bold red]Error:[/bold red] Variable '{varname}' not found.") + cons.print(f"[bold]Available variables:[/bold] {', '.join(avail)}") + sys.exit(1) + # Create output directory output_base = ARG('output') if output_base is None: From 4d6cc7ae596dbf275b3a959559e60ee79bce41f7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 18:25:57 -0500 Subject: [PATCH 003/102] Fix MP4 frame directory to use output path instead of case directory render_mp4 was writing temp frames to case_dir/viz/_frames/ even when --output pointed elsewhere. Now writes frames next to the output file. Also removed unused case_dir parameter from render_mp4 and return success/failure status so the caller can skip the "Done" message when ffmpeg is unavailable. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 16 +++++++++------- toolchain/mfc/viz/viz.py | 7 ++++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 8b49555501..0bb040ec30 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -129,13 +129,12 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: plt.close(fig) -def render_mp4(case_dir, varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements +def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements read_func=None, **opts): """ Generate an MP4 video by iterating over timesteps. Args: - case_dir: Path to the case directory. varname: Variable name to plot. steps: List of timestep integers. output: Output MP4 file path. @@ -174,8 +173,9 @@ def render_mp4(case_dir, varname, steps, output, fps=10, # pylint: disable=too- if auto_vmax is None and all_maxs: opts['vmax'] = max(all_maxs) - # Write frames as images to a temp directory - viz_dir = os.path.join(case_dir, 'viz', '_frames') + # Write frames as images to a temp directory next to the output file + output_dir = os.path.dirname(os.path.abspath(output)) + viz_dir = os.path.join(output_dir, '_frames') os.makedirs(viz_dir, exist_ok=True) try: @@ -214,14 +214,16 @@ def render_mp4(case_dir, varname, steps, output, fps=10, # pylint: disable=too- subprocess.run(ffmpeg_cmd, check=True, capture_output=True) except FileNotFoundError: print(f"ffmpeg not found. Frames saved to {viz_dir}/") - print(f"To create video manually: ffmpeg -framerate {fps} -i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") - return + print(f"To create video manually: ffmpeg -framerate {fps} " + f"-i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") + return False except subprocess.CalledProcessError as e: print(f"ffmpeg failed: {e.stderr.decode()}") print(f"Frames saved to {viz_dir}/") - return + return False # Clean up frames for fname in os.listdir(viz_dir): os.remove(os.path.join(viz_dir, fname)) os.rmdir(viz_dir) + return True diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 61872a0876..272ef1620d 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -181,9 +181,10 @@ def read_step_all_vars(step): fps = ARG('fps') or 10 mp4_path = os.path.join(output_base, f'{varname}.mp4') cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") - render_mp4(case_dir, varname, requested_steps, mp4_path, - fps=int(fps), read_func=read_step, **render_opts) - cons.print(f"[bold green]Done:[/bold green] {mp4_path}") + success = render_mp4(varname, requested_steps, mp4_path, + fps=int(fps), read_func=read_step, **render_opts) + if success: + cons.print(f"[bold green]Done:[/bold green] {mp4_path}") return # Single or multiple PNG frames From a1e5515ea52cea9029f3d0fd8adcd61faf7d3405 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 19:19:49 -0500 Subject: [PATCH 004/102] Fix Silo-HDF5 reader to parse Named Datatype structure correctly The silo_reader was looking for coordinate/variable data as HDF5 Groups and Datasets, but MFC's Silo files store objects as HDF5 Named Datatypes with a compound "silo" attribute containing metadata (mesh name, data paths, dimensions). Actual data arrays live under the .silo/ group. Rewrite the reader to: - Find mesh by silo_type=130 (DB_QUADMESH) on Named Datatypes - Find variables by silo_type=501 (DB_QUADVAR) on Named Datatypes - Resolve coord0/coord1/value0 paths from silo attribute to .silo/ datasets - Fix timestep discovery to match actual file naming (.silo) - Clean up multi-processor assembly logic Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/reader.py | 8 +- toolchain/mfc/viz/silo_reader.py | 334 ++++++++++++++++++------------- 2 files changed, 198 insertions(+), 144 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 7c74019919..3d1ad83220 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -229,11 +229,11 @@ def discover_timesteps(case_dir: str, fmt: str) -> List[int]: p0_dir = os.path.join(case_dir, 'silo_hdf5', 'p0') if os.path.isdir(p0_dir): steps = set() - for dname in os.listdir(p0_dir): - if dname.startswith('t_step='): + for fname in os.listdir(p0_dir): + if fname.endswith('.silo') and not fname.startswith('collection'): try: - steps.add(int(dname.split('=')[1])) - except (ValueError, IndexError): + steps.add(int(fname[:-5])) + except ValueError: pass return sorted(steps) diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 68980b65d6..393b7054d0 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -1,15 +1,18 @@ """ Silo-HDF5 reader for MFC post-processed output. -Silo files produced by MFC are valid HDF5 underneath. This reader -uses h5py to navigate the HDF5 tree and extract mesh coordinates -and variable arrays. +Silo files produced by MFC are valid HDF5 underneath. Each Silo object +is stored as an HDF5 Named Datatype whose ``silo`` compound attribute +carries the metadata (mesh name, data-array path, dimensions, etc.). +Actual data lives in numbered datasets under the ``.silo/`` group. + +This reader uses h5py to navigate that structure. Requires: h5py (optional dependency). """ import os -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import numpy as np @@ -17,10 +20,15 @@ try: import h5py + HAS_H5PY = True except ImportError: HAS_H5PY = False +# Silo type constants (from silo.h) +_DB_QUADMESH = 130 +_DB_QUADVAR = 501 + def _check_h5py(): if not HAS_H5PY: @@ -31,94 +39,131 @@ def _check_h5py(): ) -def _find_mesh_and_vars(h5file): - """Navigate the HDF5 tree to find mesh coordinates and variables.""" - mesh_coords = {} - variables = {} - - # Silo stores data in a nested structure. Common patterns: - # // contains coordinate arrays - # Variables are stored at the top level or in subdirectories - for key in h5file.keys(): - obj = h5file[key] - if isinstance(obj, h5py.Dataset): - variables[key] = np.array(obj) - elif isinstance(obj, h5py.Group): - # Check for mesh data - for subkey in obj.keys(): - subobj = obj[subkey] - if isinstance(subobj, h5py.Dataset): - arr = np.array(subobj) - if subkey in ('coord0', 'coord1', 'coord2'): - mesh_coords[subkey] = arr - elif subkey.startswith('_coord'): - mesh_coords[subkey] = arr - else: - variables[subkey] = arr - - return mesh_coords, variables - - -def read_silo_file(path: str, var_filter: Optional[str] = None) -> ProcessorData: +def _read_silo_object(h5file, name): + """Read a Silo Named-Datatype object and return its ``silo`` attribute.""" + obj = h5file[name] + if not isinstance(obj, h5py.Datatype): + return None + if "silo" not in obj.attrs: + return None + return obj.attrs["silo"] + + +def _resolve_path(h5file, path_bytes): + """Resolve a silo internal path (e.g. b'/.silo/#000003') to a dataset.""" + path = path_bytes.decode() if isinstance(path_bytes, bytes) else str(path_bytes) + return np.array(h5file[path]) + + +def read_silo_file( # pylint: disable=too-many-locals + path: str, var_filter: Optional[str] = None +) -> ProcessorData: """ - Read a single Silo-HDF5 file. + Read a single Silo-HDF5 file produced by MFC post_process. Args: - path: Path to the .silo file. - var_filter: If given, only load this variable. + path: Path to the ``.silo`` file. + var_filter: If given, only load this variable (case-sensitive). Returns: - ProcessorData with grid and variable data. + ProcessorData with grid coordinates and variable arrays. """ _check_h5py() - with h5py.File(path, 'r') as f: - mesh_coords, raw_vars = _find_mesh_and_vars(f) + with h5py.File(path, "r") as f: + # --- locate the mesh ------------------------------------------------ + mesh_name = None + mesh_attr = None + for key, obj in f.items(): + if key in ("..", ".silo"): + continue + if not isinstance(obj, h5py.Datatype): + continue + silo_type = obj.attrs.get("silo_type") + if silo_type is not None and int(silo_type) == _DB_QUADMESH: + mesh_name = key + mesh_attr = obj.attrs["silo"] + break + + if mesh_attr is None: + raise ValueError(f"No rectilinear mesh found in {path}") + + ndims = int(mesh_attr["ndims"]) + coord_paths = [] + for i in range(ndims): + coord_paths.append(mesh_attr[f"coord{i}"]) + + coords = [_resolve_path(f, cp) for cp in coord_paths] + + x_cb = coords[0] + y_cb = coords[1] if ndims >= 2 else np.array([0.0]) + z_cb = coords[2] if ndims >= 3 else np.array([0.0]) + + # Grid dimensions: node counts minus 1 give cell counts + m = len(x_cb) - 1 + n = (len(y_cb) - 1) if ndims >= 2 else 0 + p = (len(z_cb) - 1) if ndims >= 3 else 0 + + # --- locate variables ------------------------------------------------ + variables: Dict[str, np.ndarray] = {} + for key, obj in f.items(): + if key in ("..", ".silo", mesh_name): + continue + if not isinstance(obj, h5py.Datatype): + continue + silo_type = obj.attrs.get("silo_type") + if silo_type is None or int(silo_type) != _DB_QUADVAR: + continue - # Extract coordinates - x_cb = mesh_coords.get('coord0', np.array([0.0, 1.0])) - y_cb = mesh_coords.get('coord1', np.array([0.0])) - z_cb = mesh_coords.get('coord2', np.array([0.0])) + # Apply variable filter + if var_filter is not None and key != var_filter: + continue - m = len(x_cb) - 2 if len(x_cb) > 1 else 0 - n = len(y_cb) - 2 if len(y_cb) > 1 else 0 - p = len(z_cb) - 2 if len(z_cb) > 1 else 0 + attr = obj.attrs["silo"] + data_path = attr["value0"] + data = _resolve_path(f, data_path).astype(np.float64) - variables = {} - for name, data in raw_vars.items(): - if var_filter is not None and name != var_filter: - continue - variables[name] = data.astype(np.float64) + # Silo stores zone-centered data as (ny, nx) for 2-D — but MFC's + # DBPUTQV1 call passes the array in Fortran column-major order, + # which HDF5 writes row-major. The resulting shape in the file + # is (dims[0], dims[1]) = (nx, ny). We keep it that way so it + # matches the binary reader's (m, n) convention. + variables[key] = data - return ProcessorData(m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables) + return ProcessorData( + m=m, n=n, p=p, x_cb=x_cb, y_cb=y_cb, z_cb=z_cb, variables=variables + ) def discover_timesteps_silo(case_dir: str) -> List[int]: - """Return sorted list of available timesteps from silo_hdf5/ directory.""" - p0_dir = os.path.join(case_dir, 'silo_hdf5', 'p0') + """Return sorted list of available timesteps from ``silo_hdf5/`` directory.""" + p0_dir = os.path.join(case_dir, "silo_hdf5", "p0") if not os.path.isdir(p0_dir): return [] steps = set() - for dname in os.listdir(p0_dir): - if dname.startswith('t_step='): + for fname in os.listdir(p0_dir): + if fname.endswith(".silo") and not fname.startswith("collection"): try: - steps.add(int(dname.split('=')[1])) - except (ValueError, IndexError): + steps.add(int(fname[:-5])) + except ValueError: pass return sorted(steps) -def assemble_silo(case_dir: str, step: int, # pylint: disable=too-many-locals,too-many-statements - var: Optional[str] = None) -> AssembledData: +def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-many-branches + case_dir: str, + step: int, + var: Optional[str] = None, +) -> AssembledData: """ Read and assemble multi-processor Silo-HDF5 data for a given timestep. """ _check_h5py() - base = os.path.join(case_dir, 'silo_hdf5') - ranks = [] + base = os.path.join(case_dir, "silo_hdf5") + ranks: List[int] = [] for entry in os.listdir(base): - if entry.startswith('p') and entry[1:].isdigit(): + if entry.startswith("p") and entry[1:].isdigit(): ranks.append(int(entry[1:])) ranks.sort() @@ -127,16 +172,7 @@ def assemble_silo(case_dir: str, step: int, # pylint: disable=too-many-locals,t proc_data: List[Tuple[int, ProcessorData]] = [] for rank in ranks: - silo_dir = os.path.join(base, f'p{rank}', f't_step={step}') - if not os.path.isdir(silo_dir): - continue - silo_file = os.path.join(silo_dir, f'{step}.silo') - if not os.path.isfile(silo_file): - # Try finding any .silo file in the directory - for f in os.listdir(silo_dir): - if f.endswith('.silo'): - silo_file = os.path.join(silo_dir, f) - break + silo_file = os.path.join(base, f"p{rank}", f"{step}.silo") if not os.path.isfile(silo_file): continue pdata = read_silo_file(silo_file, var_filter=var) @@ -145,82 +181,91 @@ def assemble_silo(case_dir: str, step: int, # pylint: disable=too-many-locals,t if not proc_data: raise FileNotFoundError(f"No Silo data found for step {step}") - # For single processor, return directly + # --- single processor — fast path ------------------------------------ if len(proc_data) == 1: _, pd = proc_data[0] 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 - if pd.n > 0: - ndim = 2 - if pd.p > 0: - ndim = 3 - return AssembledData(ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc, - variables=pd.variables) - - # Multi-processor assembly — simplified version of binary reader's logic + 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) + return AssembledData( + ndim=ndim, + x_cc=x_cc, + y_cc=y_cc, + z_cc=z_cc, + variables=pd.variables, + ) + # --- multi-processor assembly ---------------------------------------- sample = proc_data[0][1] - ndim = 1 - if sample.n > 0: - ndim = 2 - if sample.p > 0: - ndim = 3 + ndim = 1 + (sample.n > 0) + (sample.p > 0) - proc_centers = [] + proc_centers: list = [] for rank, pd in proc_data: 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]) + 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]) + ) proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - # Build global coordinates by sorting and concatenating unique chunks - x_chunks = {} - y_chunks = {} - z_chunks = {} - - for rank, pd, x_cc, y_cc, z_cc in proc_centers: - x_key = round(x_cc[0], 12) - y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 - z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 - if x_key not in x_chunks: - x_chunks[x_key] = (len(x_cc), x_cc) - if y_key not in y_chunks: - y_chunks[y_key] = (len(y_cc), y_cc) - if z_key not in z_chunks: - z_chunks[z_key] = (len(z_cc), z_cc) - - sorted_x_keys = sorted(x_chunks.keys()) - sorted_y_keys = sorted(y_chunks.keys()) - sorted_z_keys = sorted(z_chunks.keys()) - - global_x = np.concatenate([x_chunks[k][1] for k in sorted_x_keys]) - global_y = np.concatenate([y_chunks[k][1] for k in sorted_y_keys]) if ndim >= 2 else np.array([0.0]) - global_z = np.concatenate([z_chunks[k][1] for k in sorted_z_keys]) if ndim >= 3 else np.array([0.0]) - - x_offsets = {} + # Build global coordinate arrays from unique chunks + x_chunks: dict = {} + y_chunks: dict = {} + z_chunks: dict = {} + + for _rank, _pd, x_cc, y_cc, z_cc in proc_centers: + xk = round(float(x_cc[0]), 12) + yk = round(float(y_cc[0]), 12) if ndim >= 2 else 0.0 + zk = round(float(z_cc[0]), 12) if ndim >= 3 else 0.0 + if xk not in x_chunks: + x_chunks[xk] = x_cc + if yk not in y_chunks: + y_chunks[yk] = y_cc + if zk not in z_chunks: + z_chunks[zk] = z_cc + + global_x = np.concatenate([x_chunks[k] for k in sorted(x_chunks)]) + global_y = ( + np.concatenate([y_chunks[k] for k in sorted(y_chunks)]) + if ndim >= 2 + else np.array([0.0]) + ) + global_z = ( + np.concatenate([z_chunks[k] for k in sorted(z_chunks)]) + if ndim >= 3 + else np.array([0.0]) + ) + + # Compute offsets for each chunk + x_offsets: dict = {} off = 0 - for k in sorted_x_keys: + for k in sorted(x_chunks): x_offsets[k] = off - off += x_chunks[k][0] + off += len(x_chunks[k]) - y_offsets = {} + y_offsets: dict = {} off = 0 - for k in sorted_y_keys: + for k in sorted(y_chunks): y_offsets[k] = off - off += y_chunks[k][0] + off += len(y_chunks[k]) - z_offsets = {} + z_offsets: dict = {} off = 0 - for k in sorted_z_keys: + for k in sorted(z_chunks): z_offsets[k] = off - off += z_chunks[k][0] + off += len(z_chunks[k]) varnames = list(proc_data[0][1].variables.keys()) nx, ny, nz = len(global_x), len(global_y), len(global_z) - global_vars = {} + global_vars: Dict[str, np.ndarray] = {} for vn in varnames: if ndim == 3: global_vars[vn] = np.zeros((nx, ny, nz)) @@ -229,24 +274,33 @@ def assemble_silo(case_dir: str, step: int, # pylint: disable=too-many-locals,t else: global_vars[vn] = np.zeros(nx) - for rank, pd, x_cc, y_cc, z_cc in proc_centers: - x_key = round(x_cc[0], 12) - y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 - z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 + for _rank, pd, x_cc, y_cc, z_cc in proc_centers: + xk = round(float(x_cc[0]), 12) + yk = round(float(y_cc[0]), 12) if ndim >= 2 else 0.0 + zk = round(float(z_cc[0]), 12) if ndim >= 3 else 0.0 - xi = x_offsets[x_key] - yi = y_offsets[y_key] if ndim >= 2 else 0 - zi = z_offsets[z_key] if ndim >= 3 else 0 + xi = x_offsets[xk] + yi = y_offsets[yk] if ndim >= 2 else 0 + zi = z_offsets[zk] if ndim >= 3 else 0 + + lx = len(x_cc) + ly = len(y_cc) if ndim >= 2 else 1 + lz = len(z_cc) if ndim >= 3 else 1 for vn, data in pd.variables.items(): if vn not in global_vars: continue if ndim == 3: - global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1, zi:zi + pd.p + 1] = data + global_vars[vn][xi : xi + lx, yi : yi + ly, zi : zi + lz] = data elif ndim == 2: - global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1] = data + global_vars[vn][xi : xi + lx, yi : yi + ly] = data else: - global_vars[vn][xi:xi + pd.m + 1] = data - - return AssembledData(ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, - variables=global_vars) + global_vars[vn][xi : xi + lx] = data + + return AssembledData( + ndim=ndim, + x_cc=global_x, + y_cc=global_y, + z_cc=global_z, + variables=global_vars, + ) From a9fb1666a3d044491d95d93880773f45aafef6ec Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 19:50:19 -0500 Subject: [PATCH 005/102] Fix multi-processor assembly and Silo data ordering Three issues fixed: 1. Silo reader: reinterpret HDF5 data from C row-major to Fortran column-major order so data[i,j,k] maps to (x_i, y_j, z_k) 2. Multi-processor assembly: use per-cell searchsorted + np.ix_ indexing instead of contiguous block placement, correctly handling ghost/buffer cell overlap between processors 3. Renderer: fall back to GIF via Pillow when ffmpeg is unavailable Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/reader.py | 87 +++++++++--------------------- toolchain/mfc/viz/renderer.py | 43 +++++++++++---- toolchain/mfc/viz/silo_reader.py | 93 ++++++++++---------------------- 3 files changed, 84 insertions(+), 139 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 3d1ad83220..4cc794786e 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -319,58 +319,22 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0]) proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - # Build global coordinate arrays - # For each unique origin in each dimension, accumulate sizes - x_chunks: Dict[float, Tuple[int, np.ndarray]] = {} - y_chunks: Dict[float, Tuple[int, np.ndarray]] = {} - z_chunks: Dict[float, Tuple[int, np.ndarray]] = {} - - for rank, pd, x_cc, y_cc, z_cc in proc_centers: - x_key = round(x_cc[0], 12) - y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 - z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 - if x_key not in x_chunks: - x_chunks[x_key] = (len(x_cc), x_cc) - if y_key not in y_chunks: - y_chunks[y_key] = (len(y_cc), y_cc) - if z_key not in z_chunks: - z_chunks[z_key] = (len(z_cc), z_cc) - - # Build global coordinate arrays by concatenating sorted chunks - sorted_x_keys = sorted(x_chunks.keys()) - sorted_y_keys = sorted(y_chunks.keys()) - sorted_z_keys = sorted(z_chunks.keys()) - - global_x = np.concatenate([x_chunks[k][1] for k in sorted_x_keys]) - global_y = np.concatenate([y_chunks[k][1] for k in sorted_y_keys]) if ndim >= 2 else np.array([0.0]) - global_z = np.concatenate([z_chunks[k][1] for k in sorted_z_keys]) if ndim >= 3 else np.array([0.0]) - - # Compute offsets for each origin - x_offsets: Dict[float, int] = {} - off = 0 - for k in sorted_x_keys: - x_offsets[k] = off - off += x_chunks[k][0] - - y_offsets: Dict[float, int] = {} - off = 0 - for k in sorted_y_keys: - y_offsets[k] = off - off += y_chunks[k][0] - - z_offsets: Dict[float, int] = {} - off = 0 - for k in sorted_z_keys: - z_offsets[k] = off - off += z_chunks[k][0] - - # Get all variable names from first processor - varnames = list(proc_data[0][1].variables.keys()) + # Build unique sorted global coordinate arrays (handles ghost overlap) + all_x = np.concatenate([xc for _, _, xc, _, _ in proc_centers]) + global_x = np.unique(np.round(all_x, 12)) + if ndim >= 2: + all_y = np.concatenate([yc for _, _, _, yc, _ in proc_centers]) + global_y = np.unique(np.round(all_y, 12)) + else: + global_y = np.array([0.0]) + if ndim >= 3: + all_z = np.concatenate([zc for _, _, _, _, zc in proc_centers]) + global_z = np.unique(np.round(all_z, 12)) + else: + global_z = np.array([0.0]) - # Allocate global arrays - nx = len(global_x) - ny = len(global_y) - nz = len(global_z) + varnames = list(proc_data[0][1].variables.keys()) + nx, ny, nz = len(global_x), len(global_y), len(global_z) global_vars: Dict[str, np.ndarray] = {} for vn in varnames: @@ -381,25 +345,22 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t else: global_vars[vn] = np.zeros(nx) - # Place each processor's data at the correct offset - for rank, pd, x_cc, y_cc, z_cc in proc_centers: - x_key = round(x_cc[0], 12) - y_key = round(y_cc[0], 12) if ndim >= 2 else 0.0 - z_key = round(z_cc[0], 12) if ndim >= 3 else 0.0 - - xi = x_offsets[x_key] - yi = y_offsets[y_key] if ndim >= 2 else 0 - zi = z_offsets[z_key] if ndim >= 3 else 0 + # Place each processor's data using per-cell coordinate lookup + # (handles ghost/buffer cell overlap between processors) + for _rank, pd, x_cc, y_cc, z_cc in proc_centers: + xi = np.searchsorted(global_x, np.round(x_cc, 12)) + yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0]) + zi = np.searchsorted(global_z, np.round(z_cc, 12)) if ndim >= 3 else np.array([0]) for vn, data in pd.variables.items(): if vn not in global_vars: continue if ndim == 3: - global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1, zi:zi + pd.p + 1] = data + global_vars[vn][np.ix_(xi, yi, zi)] = data elif ndim == 2: - global_vars[vn][xi:xi + pd.m + 1, yi:yi + pd.n + 1] = data + global_vars[vn][np.ix_(xi, yi)] = data else: - global_vars[vn][xi:xi + pd.m + 1] = data + global_vars[vn][xi] = data return AssembledData( ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 0bb040ec30..11402e7ff1 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -198,7 +198,7 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, frame_path, **opts) - # Combine frames into MP4 using ffmpeg + # Combine frames into MP4 using ffmpeg, or fall back to GIF via Pillow frame_pattern = os.path.join(viz_dir, '%06d.png') ffmpeg_cmd = [ 'ffmpeg', '-y', @@ -210,20 +210,41 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum output, ] + success = False try: subprocess.run(ffmpeg_cmd, check=True, capture_output=True) + success = True except FileNotFoundError: - print(f"ffmpeg not found. Frames saved to {viz_dir}/") - print(f"To create video manually: ffmpeg -framerate {fps} " - f"-i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") - return False + pass except subprocess.CalledProcessError as e: print(f"ffmpeg failed: {e.stderr.decode()}") - print(f"Frames saved to {viz_dir}/") - return False + + if not success: + # Fall back to GIF via Pillow + gif_output = output.rsplit('.', 1)[0] + '.gif' + try: + from PIL import Image # pylint: disable=import-outside-toplevel + frames = [] + frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) + for fname in frame_files: + img = Image.open(os.path.join(viz_dir, fname)) + frames.append(img.copy()) + img.close() + if frames: + duration = max(int(1000 / fps), 1) + frames[0].save(gif_output, save_all=True, append_images=frames[1:], + duration=duration, loop=0) + output = gif_output + success = True + print(f"ffmpeg not found; saved GIF to {gif_output}") + except ImportError: + print(f"Neither ffmpeg nor Pillow available. Frames saved to {viz_dir}/") + print(f"To create video: ffmpeg -framerate {fps} " + f"-i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") # Clean up frames - for fname in os.listdir(viz_dir): - os.remove(os.path.join(viz_dir, fname)) - os.rmdir(viz_dir) - return True + if success: + for fname in os.listdir(viz_dir): + os.remove(os.path.join(viz_dir, fname)) + os.rmdir(viz_dir) + return success diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 393b7054d0..d3ed2899c3 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -123,11 +123,12 @@ def read_silo_file( # pylint: disable=too-many-locals data_path = attr["value0"] data = _resolve_path(f, data_path).astype(np.float64) - # Silo stores zone-centered data as (ny, nx) for 2-D — but MFC's - # DBPUTQV1 call passes the array in Fortran column-major order, - # which HDF5 writes row-major. The resulting shape in the file - # is (dims[0], dims[1]) = (nx, ny). We keep it that way so it - # matches the binary reader's (m, n) convention. + # MFC's DBPUTQV1 passes the Fortran column-major array as a + # flat buffer. HDF5 stores it row-major. Reinterpret the + # bytes in Fortran order so data[i,j,k] = value at (x_i,y_j,z_k), + # matching the binary reader convention. + if data.ndim >= 2: + data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F') variables[key] = data return ProcessorData( @@ -204,6 +205,7 @@ def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-ma sample = proc_data[0][1] ndim = 1 + (sample.n > 0) + (sample.p > 0) + # Compute cell centers for each processor proc_centers: list = [] for rank, pd in proc_data: x_cc = (pd.x_cb[:-1] + pd.x_cb[1:]) / 2.0 @@ -215,52 +217,19 @@ def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-ma ) proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - # Build global coordinate arrays from unique chunks - x_chunks: dict = {} - y_chunks: dict = {} - z_chunks: dict = {} - - for _rank, _pd, x_cc, y_cc, z_cc in proc_centers: - xk = round(float(x_cc[0]), 12) - yk = round(float(y_cc[0]), 12) if ndim >= 2 else 0.0 - zk = round(float(z_cc[0]), 12) if ndim >= 3 else 0.0 - if xk not in x_chunks: - x_chunks[xk] = x_cc - if yk not in y_chunks: - y_chunks[yk] = y_cc - if zk not in z_chunks: - z_chunks[zk] = z_cc - - global_x = np.concatenate([x_chunks[k] for k in sorted(x_chunks)]) - global_y = ( - np.concatenate([y_chunks[k] for k in sorted(y_chunks)]) - if ndim >= 2 - else np.array([0.0]) - ) - global_z = ( - np.concatenate([z_chunks[k] for k in sorted(z_chunks)]) - if ndim >= 3 - else np.array([0.0]) - ) - - # Compute offsets for each chunk - x_offsets: dict = {} - off = 0 - for k in sorted(x_chunks): - x_offsets[k] = off - off += len(x_chunks[k]) - - y_offsets: dict = {} - off = 0 - for k in sorted(y_chunks): - y_offsets[k] = off - off += len(y_chunks[k]) - - z_offsets: dict = {} - off = 0 - for k in sorted(z_chunks): - z_offsets[k] = off - off += len(z_chunks[k]) + # Build unique sorted global coordinate arrays (handles ghost overlap) + all_x = np.concatenate([xc for _, _, xc, _, _ in proc_centers]) + global_x = np.unique(np.round(all_x, 12)) + if ndim >= 2: + all_y = np.concatenate([yc for _, _, _, yc, _ in proc_centers]) + global_y = np.unique(np.round(all_y, 12)) + else: + global_y = np.array([0.0]) + if ndim >= 3: + all_z = np.concatenate([zc for _, _, _, _, zc in proc_centers]) + global_z = np.unique(np.round(all_z, 12)) + else: + global_z = np.array([0.0]) varnames = list(proc_data[0][1].variables.keys()) nx, ny, nz = len(global_x), len(global_y), len(global_z) @@ -274,28 +243,22 @@ def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-ma else: global_vars[vn] = np.zeros(nx) + # Place each processor's data using per-cell coordinate lookup + # (handles ghost/buffer cell overlap between processors) for _rank, pd, x_cc, y_cc, z_cc in proc_centers: - xk = round(float(x_cc[0]), 12) - yk = round(float(y_cc[0]), 12) if ndim >= 2 else 0.0 - zk = round(float(z_cc[0]), 12) if ndim >= 3 else 0.0 - - xi = x_offsets[xk] - yi = y_offsets[yk] if ndim >= 2 else 0 - zi = z_offsets[zk] if ndim >= 3 else 0 - - lx = len(x_cc) - ly = len(y_cc) if ndim >= 2 else 1 - lz = len(z_cc) if ndim >= 3 else 1 + xi = np.searchsorted(global_x, np.round(x_cc, 12)) + yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0]) + zi = np.searchsorted(global_z, np.round(z_cc, 12)) if ndim >= 3 else np.array([0]) for vn, data in pd.variables.items(): if vn not in global_vars: continue if ndim == 3: - global_vars[vn][xi : xi + lx, yi : yi + ly, zi : zi + lz] = data + global_vars[vn][np.ix_(xi, yi, zi)] = data elif ndim == 2: - global_vars[vn][xi : xi + lx, yi : yi + ly] = data + global_vars[vn][np.ix_(xi, yi)] = data else: - global_vars[vn][xi : xi + lx] = data + global_vars[vn][xi] = data return AssembledData( ndim=ndim, From b6e4c2f77479031202581b5c30dce28b2e8cf810 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 19:58:55 -0500 Subject: [PATCH 006/102] Use imageio-ffmpeg for MP4 rendering instead of system ffmpeg Adds imageio and imageio-ffmpeg as dependencies, which bundles a self-contained ffmpeg binary. Replaces subprocess ffmpeg call and Pillow GIF fallback with imageio's get_writer API. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 50 ++++++++--------------------------- toolchain/pyproject.toml | 4 +++ 2 files changed, 15 insertions(+), 39 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 11402e7ff1..69d761edbc 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -7,7 +7,6 @@ """ import os -import subprocess import numpy as np @@ -198,49 +197,22 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, frame_path, **opts) - # Combine frames into MP4 using ffmpeg, or fall back to GIF via Pillow - frame_pattern = os.path.join(viz_dir, '%06d.png') - ffmpeg_cmd = [ - 'ffmpeg', '-y', - '-framerate', str(fps), - '-i', frame_pattern, - '-c:v', 'libx264', - '-pix_fmt', 'yuv420p', - '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', - output, - ] + # Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg) + frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) success = False try: - subprocess.run(ffmpeg_cmd, check=True, capture_output=True) + import imageio # pylint: disable=import-outside-toplevel + writer = imageio.get_writer(output, fps=fps, codec='libx264', + pixelformat='yuv420p', macro_block_size=2) + for fname in frame_files: + writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) + writer.close() success = True - except FileNotFoundError: + except ImportError: pass - except subprocess.CalledProcessError as e: - print(f"ffmpeg failed: {e.stderr.decode()}") - - if not success: - # Fall back to GIF via Pillow - gif_output = output.rsplit('.', 1)[0] + '.gif' - try: - from PIL import Image # pylint: disable=import-outside-toplevel - frames = [] - frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) - for fname in frame_files: - img = Image.open(os.path.join(viz_dir, fname)) - frames.append(img.copy()) - img.close() - if frames: - duration = max(int(1000 / fps), 1) - frames[0].save(gif_output, save_all=True, append_images=frames[1:], - duration=duration, loop=0) - output = gif_output - success = True - print(f"ffmpeg not found; saved GIF to {gif_output}") - except ImportError: - print(f"Neither ffmpeg nor Pillow available. Frames saved to {viz_dir}/") - print(f"To create video: ffmpeg -framerate {fps} " - f"-i {frame_pattern} -c:v libx264 -pix_fmt yuv420p {output}") + except Exception as exc: # pylint: disable=broad-except + print(f"imageio MP4 write failed: {exc}") # Clean up frames if success: diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 53e2140290..559c194ac1 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -37,6 +37,10 @@ dependencies = [ "seaborn", "matplotlib", + # Visualization (video rendering) + "imageio", + "imageio-ffmpeg", + # Chemistry "cantera>=3.1.0", #"pyrometheus == 1.0.5", From e008f85a657c96693dccff52b866c9f82e5842d8 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 20:26:17 -0500 Subject: [PATCH 007/102] Add documentation for ./mfc.sh viz command Updates visualization.md with comprehensive CLI viz docs covering basic usage, timestep selection, rendering options, 3D slicing, video generation, and format selection. Adds viz references to getting-started, running, case, and troubleshooting pages. Co-Authored-By: Claude Opus 4.6 --- docs/documentation/case.md | 2 +- docs/documentation/getting-started.md | 18 ++++ docs/documentation/running.md | 8 ++ docs/documentation/troubleshooting.md | 42 +++++++++ docs/documentation/visualization.md | 130 +++++++++++++++++++++++++- 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/docs/documentation/case.md b/docs/documentation/case.md index 356f6d90fc..2f884ab94b 100644 --- a/docs/documentation/case.md +++ b/docs/documentation/case.md @@ -651,7 +651,7 @@ To restart the simulation from $k$-th time step, see @ref running "Restarting Ca The table lists formatted database output parameters. The parameters define variables that are outputted from simulation and file types and formats of data as well as options for post-processing. -- `format` specifies the choice of the file format of data file outputted by MFC by an integer of 1 and 2. `format = 1` and `2` correspond to Silo-HDF5 format and binary format, respectively. +- `format` specifies the choice of the file format of data file outputted by MFC by an integer of 1 and 2. `format = 1` and `2` correspond to Silo-HDF5 format and binary format, respectively. Both formats are supported by `./mfc.sh viz` (see @ref visualization "Flow Visualization"). Silo-HDF5 requires the h5py Python package; binary has no extra dependencies. - `precision` specifies the choice of the floating-point format of the data file outputted by MFC by an integer of 1 and 2. `precision = 1` and `2` correspond to single-precision and double-precision formats, respectively. diff --git a/docs/documentation/getting-started.md b/docs/documentation/getting-started.md index abb4c7d55b..101a569116 100644 --- a/docs/documentation/getting-started.md +++ b/docs/documentation/getting-started.md @@ -204,6 +204,24 @@ MFC is **unit-agnostic**: the solver performs no internal unit conversions. What The only requirement is **consistency** — all inputs must use the same unit system. Note that some parameters use **transformed stored forms** rather than standard physical values (e.g., `gamma` expects \f$1/(\gamma-1)\f$, not \f$\gamma\f$ itself). See @ref sec-stored-forms for details. +## Visualizing Results + +After running post_process, visualize the output directly from the command line: + +```shell +# List available variables +./mfc.sh viz examples/2D_shockbubble/ --list-vars --step 0 + +# Render a pressure snapshot +./mfc.sh viz examples/2D_shockbubble/ --var pres --step 1000 + +# Generate a video +./mfc.sh viz examples/2D_shockbubble/ --var pres --step all --mp4 +``` + +Output images and videos are saved to the `viz/` subdirectory of the case. +For more options, see @ref visualization "Flow Visualization" or run `./mfc.sh viz -h`. + ## Helpful Tools ### Parameter Lookup diff --git a/docs/documentation/running.md b/docs/documentation/running.md index 9039ab465f..cd663634fd 100644 --- a/docs/documentation/running.md +++ b/docs/documentation/running.md @@ -73,6 +73,14 @@ using 4 cores: ./mfc.sh run examples/2D_shockbubble/case.py -t simulation post_process -n 4 ``` +- Visualizing post-processed output: + +```shell +./mfc.sh viz examples/2D_shockbubble/ --var pres --step 1000 +``` + +See @ref visualization "Flow Visualization" for the full set of visualization options. + --- ## Running on GPUs diff --git a/docs/documentation/troubleshooting.md b/docs/documentation/troubleshooting.md index e272897986..9e7dd2c468 100644 --- a/docs/documentation/troubleshooting.md +++ b/docs/documentation/troubleshooting.md @@ -37,6 +37,7 @@ This guide covers debugging tools, common issues, and troubleshooting workflows ./mfc.sh run case.py -v # Run with verbose output ./mfc.sh test --only # Run a specific test ./mfc.sh clean # Clean and start fresh +./mfc.sh viz case_dir/ --list-vars --step 0 # Inspect post-processed data ``` --- @@ -457,6 +458,47 @@ Common issues: --- +## Visualization Issues + +### "No 'binary/' or 'silo_hdf5/' directory found" + +**Cause:** Post-processing has not been run, or the case directory path is wrong. + +**Fix:** +1. Run post_process first: + ```bash + ./mfc.sh run case.py -t post_process + ``` +2. Verify the path points to the case directory (containing `binary/` or `silo_hdf5/`) + +### "Variable 'X' not found" + +**Cause:** The requested variable was not written during post-processing. + +**Fix:** +1. List available variables: + ```bash + ./mfc.sh viz case_dir/ --list-vars --step 0 + ``` +2. Ensure your case file enables the desired output (e.g., ``prim_vars_wrt = 'T'``, ``cons_vars_wrt = 'T'``) + +### "h5py is required to read Silo-HDF5 files" + +**Cause:** The case was post-processed with `format=1` (Silo-HDF5) but `h5py` is not installed. + +**Fix:** +- Install h5py: `pip install h5py` +- Or re-run post_process with `format=2` in your case file to produce binary output + +### Visualization looks wrong or has artifacts + +**Possible causes and fixes:** +1. **Color range:** Try setting explicit `--vmin` and `--vmax` values +2. **Wrong variable:** Use `--list-vars` to check available variables +3. **3D slice position:** Adjust `--slice-axis` and `--slice-value` to view the correct plane + +--- + ## Getting Help If you can't resolve an issue: diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 2f5ed308d0..220b1b790a 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -2,13 +2,133 @@ # Flow visualization -A post-processed database in Silo-HDF5 format can be visualized and analyzed using Paraview and VisIt. -After the post-processing of simulation data (see section @ref running "Running"), a directory named `silo_hdf5` contains a silo-HDF5 database. -Here, `silo_hdf5/` includes a directory named `root/` that contains index files for flow field data at each saved time step. +After running `post_process` on a simulation (see @ref running "Running"), MFC produces output in either Silo-HDF5 format (`format=1`) or binary format (`format=2`). +These can be visualized using MFC's built-in CLI tool or external tools like ParaView and VisIt. -### Visualizing with Paraview +--- -Paraview is an open-source interactive parallel visualization and graphical analysis tool for viewing scientific data. +## Quick visualization with `./mfc.sh viz` + +MFC includes a built-in visualization command that renders PNG images and MP4 videos directly from post-processed output — no external GUI tools needed. + +### Basic usage + +```bash +# Plot pressure at timestep 1000 +./mfc.sh viz case_dir/ --var pres --step 1000 + +# Plot density at all available timesteps +./mfc.sh viz case_dir/ --var rho --step all +``` + +The command auto-detects the output format (binary or Silo-HDF5) and dimensionality (1D, 2D, or 3D). +Output images are saved to `case_dir/viz/` by default. + +### Exploring available data + +Before plotting, you can inspect what data is available: + +```bash +# List all available timesteps +./mfc.sh viz case_dir/ --list-steps + +# List all available variables at a given timestep +./mfc.sh viz case_dir/ --list-vars --step 0 +``` + +### Timestep selection + +The `--step` argument accepts several formats: + +| Format | Example | Description | +|--------|---------|-------------| +| Single | `--step 1000` | One timestep | +| Range | `--step 0:10000:500` | Start:end:stride | +| All | `--step all` | Every available timestep | + +### Rendering options + +Customize the appearance of plots: + +```bash +# Custom colormap and color range +./mfc.sh viz case_dir/ --var rho --step 1000 --cmap RdBu --vmin 0.5 --vmax 2.0 + +# Higher resolution +./mfc.sh viz case_dir/ --var pres --step 500 --dpi 300 + +# Logarithmic color scale +./mfc.sh viz case_dir/ --var schlieren --step 1000 --log-scale +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `--cmap` | Matplotlib colormap name | `viridis` | +| `--vmin` | Minimum color scale value | auto | +| `--vmax` | Maximum color scale value | auto | +| `--dpi` | Image resolution (dots per inch) | 150 | +| `--log-scale` | Use logarithmic color scale | off | +| `--output` | Output directory for images | `case_dir/viz/` | + +### 3D slicing + +For 3D simulations, `viz` extracts a 2D slice for plotting. +By default, it slices at the midplane along the z-axis: + +```bash +# Default z-midplane slice +./mfc.sh viz case_dir/ --var pres --step 500 + +# Slice along the x-axis at x=0.25 +./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x --slice-value 0.25 + +# Slice by array index +./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis y --slice-index 50 +``` + +### Video generation + +Generate MP4 videos from a range of timesteps: + +```bash +# Basic video (10 fps) +./mfc.sh viz case_dir/ --var pres --step 0:10000:100 --mp4 + +# Custom frame rate +./mfc.sh viz case_dir/ --var schlieren --step all --mp4 --fps 24 + +# Video with fixed color range +./mfc.sh viz case_dir/ --var rho --step 0:5000:50 --mp4 --vmin 0.1 --vmax 1.0 +``` + +Videos are saved as `case_dir/viz/.mp4`. +The color range is automatically computed from the first, middle, and last frames unless `--vmin`/`--vmax` are specified. + +### Format selection + +The output format is auto-detected from the case directory. +To override: + +```bash +./mfc.sh viz case_dir/ --var pres --step 0 --format binary +./mfc.sh viz case_dir/ --var pres --step 0 --format silo +``` + +> [!NOTE] +> Reading Silo-HDF5 files requires the `h5py` Python package. +> If it is not installed, you will see a clear error message with installation instructions. +> Alternatively, use `format=2` (binary) in your case file to produce binary output, which has no extra dependencies. + +### Complete option reference + +Run `./mfc.sh viz -h` for a full list of options. + +--- + +## Visualizing with ParaView + +ParaView is an open-source interactive parallel visualization and graphical analysis tool for viewing scientific data. +Post-processed data in Silo-HDF5 format (`format=1`) can be opened directly in ParaView. Paraview 5.11.0 has been confirmed to work with the MFC databases for some parallel environments. Nevertheless, the installation and configuration of Paraview can be environment-dependent and are left to the user. From 9f7da71a64b1c831f94c1a99b55d25b23f583e97 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 20:58:54 -0500 Subject: [PATCH 008/102] Address code review feedback from CodeRabbit - Extract shared assembly helper (assemble_from_proc_data) to deduplicate multi-processor assembly logic between reader.py and silo_reader.py - Remove dead code: unused read_record() and _read_silo_object() - Remove duplicate discover_timesteps_silo (reader.discover_timesteps handles both) - Fix renderer.py: use try/finally for writer.close(), report missing imageio - Fix viz.py: validate single-int --step against available timesteps, report error when MP4 generation fails - Fix silo_reader.py: defensive check for "silo" attribute, clarify Fortran-order reinterpretation assumption in comment - Document m/n/p convention difference in ProcessorData docstring - Pin lower-bound versions for imageio>=2.33, imageio-ffmpeg>=0.5.0 - Use integer arithmetic for precision auto-detection - Add warnings for skipped processor files during assembly Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 2 +- toolchain/mfc/viz/reader.py | 149 ++++++++++++++++------------ toolchain/mfc/viz/renderer.py | 13 ++- toolchain/mfc/viz/silo_reader.py | 121 ++-------------------- toolchain/mfc/viz/viz.py | 8 +- toolchain/pyproject.toml | 4 +- 6 files changed, 113 insertions(+), 184 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 220b1b790a..dad2ef2d6f 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -9,7 +9,7 @@ These can be visualized using MFC's built-in CLI tool or external tools like Par ## Quick visualization with `./mfc.sh viz` -MFC includes a built-in visualization command that renders PNG images and MP4 videos directly from post-processed output — no external GUI tools needed. +MFC includes a built-in visualization command that renders images and videos directly from post-processed output — no external GUI tools needed. ### Basic usage diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 4cc794786e..9932a3934a 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -24,7 +24,14 @@ @dataclass class ProcessorData: - """Data from a single processor file.""" + """Data from a single processor file. + + m, n, p follow the Fortran header convention: x_cb has m+2 elements, + data arrays have (m+1) cells per dimension. The silo reader uses + m = len(x_cb) - 1 (= number of cells) which differs by one, but + assembly code only uses x_cb lengths and n > 0 / p > 0 for + dimensionality, so both conventions work correctly. + """ m: int n: int p: int @@ -44,23 +51,6 @@ class AssembledData: variables: Dict[str, np.ndarray] = field(default_factory=dict) -def read_record(f) -> bytes: - """Read one Fortran unformatted record, returning the payload bytes.""" - raw = f.read(4) - if len(raw) < 4: - raise EOFError("Unexpected end of file reading record marker") - rec_len = struct.unpack('i', raw)[0] - if rec_len < 0: - raise ValueError(f"Invalid record length: {rec_len}") - payload = f.read(rec_len) - if len(payload) < rec_len: - raise EOFError("Unexpected end of file reading record payload") - f.read(4) # trailing marker - return payload - def _detect_endianness(path: str) -> str: """Detect endianness from the first record marker (should be 16 for header).""" @@ -121,12 +111,12 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa n_vals = m + 2 # Auto-detect grid precision from record size - bytes_per_val = grid_bytes / n_vals - if abs(bytes_per_val - 8.0) < 0.5: + if grid_bytes == n_vals * 8: grid_dtype = np.dtype(f'{endian}f8') - elif abs(bytes_per_val - 4.0) < 0.5: + elif grid_bytes == n_vals * 4: grid_dtype = np.dtype(f'{endian}f4') else: + bytes_per_val = grid_bytes / n_vals if n_vals else 0 raise ValueError( f"Cannot determine grid precision: {grid_bytes} bytes for {n_vals} values " f"({bytes_per_val:.1f} bytes/value)" @@ -161,12 +151,12 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa # Auto-detect variable data precision from record size data_bytes = len(var_raw) - NAME_LEN - var_bpv = data_bytes / data_size - if abs(var_bpv - 8.0) < 0.5: + if data_bytes == data_size * 8: var_dtype = np.dtype(f'{endian}f8') - elif abs(var_bpv - 4.0) < 0.5: + elif data_bytes == data_size * 4: var_dtype = np.dtype(f'{endian}f4') else: + var_bpv = data_bytes / data_size if data_size else 0 raise ValueError( f"Cannot determine variable precision for '{varname}': " f"{data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)" @@ -261,55 +251,34 @@ def _is_1d(case_dir: str) -> bool: return os.path.isdir(os.path.join(case_dir, 'binary', 'root')) -def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals,too-many-statements - var: Optional[str] = None) -> AssembledData: +def assemble_from_proc_data( # pylint: disable=too-many-locals + proc_data: List[Tuple[int, ProcessorData]], +) -> AssembledData: """ - Read and assemble multi-processor data for a given timestep. + Assemble multi-processor data into a single global grid. - For 1D, reads the root file directly. - For 2D/3D, reads all processor files and assembles into global arrays. + Shared helper used by both binary and silo assembly paths. + Handles ghost/buffer cell overlap between processors by using + per-cell coordinate lookup (np.unique + np.searchsorted + np.ix_). """ - if fmt != 'binary': - raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.") + if not proc_data: + raise ValueError("No processor data to assemble") - # 1D case: read root file directly - if _is_1d(case_dir): - root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat') - if not os.path.isfile(root_path): - raise FileNotFoundError(f"Root file not found: {root_path}") - pdata = read_binary_file(root_path, var_filter=var) - x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0 + # Single processor — fast path + if len(proc_data) == 1: + _, pd = proc_data[0] + 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) return AssembledData( - ndim=1, x_cc=x_cc, - y_cc=np.array([0.0]), z_cc=np.array([0.0]), - variables=pdata.variables, + ndim=ndim, x_cc=x_cc, y_cc=y_cc, z_cc=z_cc, + variables=pd.variables, ) - # Multi-dimensional: read all processor files - ranks = _discover_processors(case_dir, fmt) - if not ranks: - raise FileNotFoundError(f"No processor directories found in {case_dir}/binary/") - - # Read all processor data - proc_data: List[Tuple[int, ProcessorData]] = [] - for rank in ranks: - fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') - if not os.path.isfile(fpath): - continue - pdata = read_binary_file(fpath, var_filter=var) - if pdata.m == 0 and pdata.n == 0 and pdata.p == 0: - continue - proc_data.append((rank, pdata)) - - if not proc_data: - raise FileNotFoundError(f"No valid processor data found for step {step}") - - ndim = 1 + # Multi-processor assembly sample = proc_data[0][1] - if sample.n > 0: - ndim = 2 - if sample.p > 0: - ndim = 3 + ndim = 1 + (sample.n > 0) + (sample.p > 0) # Compute cell centers for each processor proc_centers = [] @@ -346,7 +315,6 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t global_vars[vn] = np.zeros(nx) # Place each processor's data using per-cell coordinate lookup - # (handles ghost/buffer cell overlap between processors) for _rank, pd, x_cc, y_cc, z_cc in proc_centers: xi = np.searchsorted(global_x, np.round(x_cc, 12)) yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0]) @@ -366,3 +334,52 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t ndim=ndim, x_cc=global_x, y_cc=global_y, z_cc=global_z, variables=global_vars, ) + + +def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=too-many-locals + var: Optional[str] = None) -> AssembledData: + """ + Read and assemble multi-processor data for a given timestep. + + For 1D, reads the root file directly. + For 2D/3D, reads all processor files and assembles into global arrays. + """ + if fmt != 'binary': + raise ValueError(f"Format '{fmt}' not supported by binary reader. Use silo_reader.") + + # 1D case: read root file directly + if _is_1d(case_dir): + root_path = os.path.join(case_dir, 'binary', 'root', f'{step}.dat') + if not os.path.isfile(root_path): + raise FileNotFoundError(f"Root file not found: {root_path}") + pdata = read_binary_file(root_path, var_filter=var) + x_cc = (pdata.x_cb[:-1] + pdata.x_cb[1:]) / 2.0 + return AssembledData( + ndim=1, x_cc=x_cc, + y_cc=np.array([0.0]), z_cc=np.array([0.0]), + variables=pdata.variables, + ) + + # Multi-dimensional: read all processor files + ranks = _discover_processors(case_dir, fmt) + if not ranks: + raise FileNotFoundError(f"No processor directories found in {case_dir}/binary/") + + proc_data: List[Tuple[int, ProcessorData]] = [] + for rank in ranks: + fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') + if not os.path.isfile(fpath): + import warnings # pylint: disable=import-outside-toplevel + 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: + import warnings # pylint: disable=import-outside-toplevel + warnings.warn(f"Processor p{rank} has zero dimensions, skipping") + continue + proc_data.append((rank, pdata)) + + if not proc_data: + raise FileNotFoundError(f"No valid processor data found for step {step}") + + return assemble_from_proc_data(proc_data) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 69d761edbc..104292e228 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -203,16 +203,23 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum success = False try: import imageio # pylint: disable=import-outside-toplevel + except ImportError: + print("imageio is not installed. Install it with: pip install imageio imageio-ffmpeg") + print(f"Frames saved to {viz_dir}/") + return False + + writer = None + try: writer = imageio.get_writer(output, fps=fps, codec='libx264', pixelformat='yuv420p', macro_block_size=2) for fname in frame_files: writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) - writer.close() success = True - except ImportError: - pass except Exception as exc: # pylint: disable=broad-except print(f"imageio MP4 write failed: {exc}") + finally: + if writer is not None: + writer.close() # Clean up frames if success: diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index d3ed2899c3..322a42a922 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -16,7 +16,7 @@ import numpy as np -from .reader import AssembledData, ProcessorData +from .reader import AssembledData, ProcessorData, assemble_from_proc_data try: import h5py @@ -39,15 +39,6 @@ def _check_h5py(): ) -def _read_silo_object(h5file, name): - """Read a Silo Named-Datatype object and return its ``silo`` attribute.""" - obj = h5file[name] - if not isinstance(obj, h5py.Datatype): - return None - if "silo" not in obj.attrs: - return None - return obj.attrs["silo"] - def _resolve_path(h5file, path_bytes): """Resolve a silo internal path (e.g. b'/.silo/#000003') to a dataset.""" @@ -81,6 +72,8 @@ def read_silo_file( # pylint: disable=too-many-locals continue silo_type = obj.attrs.get("silo_type") if silo_type is not None and int(silo_type) == _DB_QUADMESH: + if "silo" not in obj.attrs: + continue mesh_name = key mesh_attr = obj.attrs["silo"] break @@ -119,6 +112,8 @@ def read_silo_file( # pylint: disable=too-many-locals if var_filter is not None and key != var_filter: continue + if "silo" not in obj.attrs: + continue attr = obj.attrs["silo"] data_path = attr["value0"] data = _resolve_path(f, data_path).astype(np.float64) @@ -127,6 +122,9 @@ def read_silo_file( # pylint: disable=too-many-locals # flat buffer. HDF5 stores it row-major. Reinterpret the # bytes in Fortran order so data[i,j,k] = value at (x_i,y_j,z_k), # matching the binary reader convention. + # 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. if data.ndim >= 2: data = np.ascontiguousarray(data).ravel().reshape(data.shape, order='F') variables[key] = data @@ -136,22 +134,7 @@ def read_silo_file( # pylint: disable=too-many-locals ) -def discover_timesteps_silo(case_dir: str) -> List[int]: - """Return sorted list of available timesteps from ``silo_hdf5/`` directory.""" - p0_dir = os.path.join(case_dir, "silo_hdf5", "p0") - if not os.path.isdir(p0_dir): - return [] - steps = set() - for fname in os.listdir(p0_dir): - if fname.endswith(".silo") and not fname.startswith("collection"): - try: - steps.add(int(fname[:-5])) - except ValueError: - pass - return sorted(steps) - - -def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-many-branches +def assemble_silo( case_dir: str, step: int, var: Optional[str] = None, @@ -182,88 +165,4 @@ def assemble_silo( # pylint: disable=too-many-locals,too-many-statements,too-ma if not proc_data: raise FileNotFoundError(f"No Silo data found for step {step}") - # --- single processor — fast path ------------------------------------ - if len(proc_data) == 1: - _, pd = proc_data[0] - 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) - return AssembledData( - ndim=ndim, - x_cc=x_cc, - y_cc=y_cc, - z_cc=z_cc, - variables=pd.variables, - ) - - # --- multi-processor assembly ---------------------------------------- - sample = proc_data[0][1] - ndim = 1 + (sample.n > 0) + (sample.p > 0) - - # Compute cell centers for each processor - proc_centers: list = [] - for rank, pd in proc_data: - 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]) - ) - proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - - # Build unique sorted global coordinate arrays (handles ghost overlap) - all_x = np.concatenate([xc for _, _, xc, _, _ in proc_centers]) - global_x = np.unique(np.round(all_x, 12)) - if ndim >= 2: - all_y = np.concatenate([yc for _, _, _, yc, _ in proc_centers]) - global_y = np.unique(np.round(all_y, 12)) - else: - global_y = np.array([0.0]) - if ndim >= 3: - all_z = np.concatenate([zc for _, _, _, _, zc in proc_centers]) - global_z = np.unique(np.round(all_z, 12)) - else: - global_z = np.array([0.0]) - - varnames = list(proc_data[0][1].variables.keys()) - nx, ny, nz = len(global_x), len(global_y), len(global_z) - - global_vars: Dict[str, np.ndarray] = {} - for vn in varnames: - if ndim == 3: - global_vars[vn] = np.zeros((nx, ny, nz)) - elif ndim == 2: - global_vars[vn] = np.zeros((nx, ny)) - else: - global_vars[vn] = np.zeros(nx) - - # Place each processor's data using per-cell coordinate lookup - # (handles ghost/buffer cell overlap between processors) - for _rank, pd, x_cc, y_cc, z_cc in proc_centers: - xi = np.searchsorted(global_x, np.round(x_cc, 12)) - yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0]) - zi = np.searchsorted(global_z, np.round(z_cc, 12)) if ndim >= 3 else np.array([0]) - - for vn, data in pd.variables.items(): - if vn not in global_vars: - continue - if ndim == 3: - global_vars[vn][np.ix_(xi, yi, zi)] = data - elif ndim == 2: - global_vars[vn][np.ix_(xi, yi)] = data - else: - global_vars[vn][xi] = data - - return AssembledData( - ndim=ndim, - x_cc=global_x, - y_cc=global_y, - z_cc=global_z, - variables=global_vars, - ) + return assemble_from_proc_data(proc_data) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 272ef1620d..ee9b3d4a44 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -31,7 +31,10 @@ def _parse_steps(step_arg, available_steps): requested = list(range(start, end + 1, stride)) return [s for s in requested if s in set(available_steps)] - return [int(step_arg)] + single = int(step_arg) + if available_steps and single not in set(available_steps): + return [] + return [single] def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches @@ -185,6 +188,9 @@ def read_step_all_vars(step): fps=int(fps), read_func=read_step, **render_opts) if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") + else: + cons.print(f"[bold red]Error:[/bold red] Failed to generate {mp4_path}. " + "Ensure imageio and imageio-ffmpeg are installed.") return # Single or multiple PNG frames diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 559c194ac1..1e78da1c24 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -38,8 +38,8 @@ dependencies = [ "matplotlib", # Visualization (video rendering) - "imageio", - "imageio-ffmpeg", + "imageio>=2.33", + "imageio-ffmpeg>=0.5.0", # Chemistry "cantera>=3.1.0", From c5e683ef66cfa020fc3d30891271248551dd0bc2 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 21:52:17 -0500 Subject: [PATCH 009/102] Fix --step input validation and clarify inclusive range in docs Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 2 +- toolchain/mfc/viz/viz.py | 33 +++++++++++++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index dad2ef2d6f..da8587fc03 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -43,7 +43,7 @@ The `--step` argument accepts several formats: | Format | Example | Description | |--------|---------|-------------| | Single | `--step 1000` | One timestep | -| Range | `--step 0:10000:500` | Start:end:stride | +| Range | `--step 0:10000:500` | Start:end:stride (inclusive) | | All | `--step all` | Every available timestep | ### Rendering options diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index ee9b3d4a44..754cd70d31 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -23,15 +23,21 @@ def _parse_steps(step_arg, available_steps): if step_arg is None or step_arg == 'all': return available_steps - if ':' in str(step_arg): - parts = str(step_arg).split(':') - start = int(parts[0]) - end = int(parts[1]) - stride = int(parts[2]) if len(parts) > 2 else 1 - requested = list(range(start, end + 1, stride)) - return [s for s in requested if s in set(available_steps)] - - single = int(step_arg) + try: + if ':' in str(step_arg): + parts = str(step_arg).split(':') + start = int(parts[0]) + end = int(parts[1]) + stride = int(parts[2]) if len(parts) > 2 else 1 + requested = list(range(start, end + 1, stride)) + return [s for s in requested if s in set(available_steps)] + + single = int(step_arg) + except ValueError: + cons.print(f"[bold red]Error:[/bold red] Invalid --step value '{step_arg}'. " + "Expected an integer, a range (start:end:stride), or 'all'.") + sys.exit(1) + if available_steps and single not in set(available_steps): return [] return [single] @@ -87,11 +93,16 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print("[bold red]Error:[/bold red] No timesteps found.") sys.exit(1) - if step_arg is None: + if step_arg is None or step_arg == 'all': step = steps[0] cons.print(f"[dim]Using first available timestep: {step}[/dim]") else: - step = int(step_arg) + try: + step = int(step_arg) + except ValueError: + cons.print(f"[bold red]Error:[/bold red] Invalid --step value '{step_arg}'. " + "Expected an integer or 'all'.") + sys.exit(1) if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel From a239780bd9784e8f83205edea2a710fee1a53d8e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 22:00:32 -0500 Subject: [PATCH 010/102] Guard LogNorm against non-positive data in log-scale rendering Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 104292e228..531843e24e 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -47,6 +47,10 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab if log_scale: 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 norm = LogNorm(vmin=lo, vmax=hi) vmin = None vmax = None @@ -109,6 +113,10 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: if log_scale: lo = vmin if vmin is not None else np.nanmin(sliced[sliced > 0]) if np.any(sliced > 0) else 1e-10 hi = vmax if vmax is not None else np.nanmax(sliced) + if hi <= 0: + hi = 1.0 + if lo <= 0 or lo >= hi: + lo = hi * 1e-10 norm = LogNorm(vmin=lo, vmax=hi) vmin = None vmax = None From a4940dcc735eee11b2339b38b429685ccd15e898 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 22:28:30 -0500 Subject: [PATCH 011/102] Harden readers and CLI error handling - Validate Fortran record length is non-negative - Clip searchsorted indices to prevent out-of-bounds in assembly - Check silo_hdf5 directory exists before listing - Catch discover_format FileNotFoundError for clean CLI output Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/reader.py | 8 +++++--- toolchain/mfc/viz/silo_reader.py | 2 ++ toolchain/mfc/viz/viz.py | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 9932a3934a..1ca4d5dd3e 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -73,6 +73,8 @@ def _read_record_endian(f, endian: str) -> bytes: if len(raw) < 4: raise EOFError("Unexpected end of file reading record marker") rec_len = struct.unpack(f'{endian}i', raw)[0] + if rec_len < 0: + raise ValueError(f"Invalid Fortran record length: {rec_len}") payload = f.read(rec_len) if len(payload) < rec_len: raise EOFError("Unexpected end of file reading record payload") @@ -316,9 +318,9 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals # Place each processor's data using per-cell coordinate lookup for _rank, pd, x_cc, y_cc, z_cc in proc_centers: - xi = np.searchsorted(global_x, np.round(x_cc, 12)) - yi = np.searchsorted(global_y, np.round(y_cc, 12)) if ndim >= 2 else np.array([0]) - zi = np.searchsorted(global_z, np.round(z_cc, 12)) if ndim >= 3 else np.array([0]) + xi = np.clip(np.searchsorted(global_x, np.round(x_cc, 12)), 0, nx - 1) + yi = np.clip(np.searchsorted(global_y, np.round(y_cc, 12)), 0, ny - 1) if ndim >= 2 else np.array([0]) + zi = np.clip(np.searchsorted(global_z, np.round(z_cc, 12)), 0, nz - 1) if ndim >= 3 else np.array([0]) for vn, data in pd.variables.items(): if vn not in global_vars: diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 322a42a922..789788e5d9 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -145,6 +145,8 @@ def assemble_silo( _check_h5py() base = os.path.join(case_dir, "silo_hdf5") + if not os.path.isdir(base): + raise FileNotFoundError(f"Silo-HDF5 directory not found: {base}") ranks: List[int] = [] for entry in os.listdir(base): if entry.startswith("p") and entry[1:].isdigit(): diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 754cd70d31..e920ee3a78 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -63,7 +63,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc if fmt_arg: fmt = fmt_arg else: - fmt = discover_format(case_dir) + try: + fmt = discover_format(case_dir) + except FileNotFoundError as exc: + cons.print(f"[bold red]Error:[/bold red] {exc}") + sys.exit(1) cons.print(f"[bold]Format:[/bold] {fmt}") From ae3e48d4b4d02667d652f7c682c835d36d03b136 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 22:41:31 -0500 Subject: [PATCH 012/102] Add viz to CLI reference doc generation categories Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/cli/docs_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolchain/mfc/cli/docs_gen.py b/toolchain/mfc/cli/docs_gen.py index a91213a7be..989ea6a612 100644 --- a/toolchain/mfc/cli/docs_gen.py +++ b/toolchain/mfc/cli/docs_gen.py @@ -243,7 +243,7 @@ def generate_cli_reference(schema: CLISchema) -> str: # Command categories core_commands = ["build", "run", "test", "clean", "validate"] - utility_commands = ["new", "params", "packer", "completion", "generate", "help"] + utility_commands = ["new", "viz", "params", "packer", "completion", "generate", "help"] dev_commands = ["lint", "format", "spelling", "precheck", "count", "count_diff"] ci_commands = ["bench", "bench_diff"] other_commands = ["load", "interactive"] From cbfd7495dd0217a06a79ee0d4f75b204de8b537e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 23:11:15 -0500 Subject: [PATCH 013/102] Harden binary reader: EOF check, header validation, varname union, stacklevel Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/reader.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 1ca4d5dd3e..d9f2548b4d 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -56,6 +56,8 @@ def _detect_endianness(path: str) -> str: """Detect endianness from the first record marker (should be 16 for header).""" with open(path, 'rb') as f: raw = f.read(4) + if len(raw) < 4: + raise EOFError(f"File too short to detect endianness: {path}") le = struct.unpack(' ProcessorDa # Record 1: header [m, n, p, dbvars] — 4 int32 hdr = _read_record_endian(f, endian) m, n, p, dbvars = struct.unpack(f'{endian}4i', hdr) + if m < 0 or n < 0 or p < 0 or dbvars < 0: + raise ValueError( + f"Invalid header in {path}: m={m}, n={n}, p={p}, dbvars={dbvars}" + ) # Record 2: grid coordinates — all in one record grid_raw = _read_record_endian(f, endian) @@ -304,7 +310,7 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals else: global_z = np.array([0.0]) - varnames = list(proc_data[0][1].variables.keys()) + varnames = sorted({vn for _, pd in proc_data for vn in pd.variables}) nx, ny, nz = len(global_x), len(global_y), len(global_z) global_vars: Dict[str, np.ndarray] = {} @@ -372,12 +378,12 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') if not os.path.isfile(fpath): import warnings # pylint: disable=import-outside-toplevel - 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) continue proc_data.append((rank, pdata)) From 838c8268f172429843cd15307fca526401cf50ce Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 23:22:52 -0500 Subject: [PATCH 014/102] Fix MP4 opts mutation, frame cleanup safety, silo skip warning - Copy opts dict in render_mp4 to avoid mutating caller's dict - Only delete generated frame files during cleanup, not all files - Add missing-file warning in silo reader (consistent with binary reader) - Fix render_mp4 comment and _resolve_path docstring accuracy Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 17 ++++++++++++----- toolchain/mfc/viz/silo_reader.py | 4 +++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 531843e24e..ae6920af00 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -156,7 +156,9 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum if not steps: raise ValueError("No timesteps provided for MP4 generation") - # Pre-compute vmin/vmax from first and last frames if not provided + opts = dict(opts) # avoid mutating the caller's dict + + # Pre-compute vmin/vmax from first, middle, and last frames if not provided auto_vmin = opts.get('vmin') auto_vmax = opts.get('vmax') @@ -229,9 +231,14 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum if writer is not None: writer.close() - # Clean up frames + # Clean up only the frames we created if success: - for fname in os.listdir(viz_dir): - os.remove(os.path.join(viz_dir, fname)) - os.rmdir(viz_dir) + for fname in frame_files: + fpath = os.path.join(viz_dir, fname) + if os.path.isfile(fpath): + os.remove(fpath) + try: + os.rmdir(viz_dir) + except OSError: + pass # directory not empty (pre-existing files) return success diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 789788e5d9..7292c8630d 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -41,7 +41,7 @@ def _check_h5py(): def _resolve_path(h5file, path_bytes): - """Resolve a silo internal path (e.g. b'/.silo/#000003') to a dataset.""" + """Resolve a silo internal path (e.g. b'/.silo/#000003') and return its data as a numpy array.""" path = path_bytes.decode() if isinstance(path_bytes, bytes) else str(path_bytes) return np.array(h5file[path]) @@ -160,6 +160,8 @@ def assemble_silo( 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"Processor file not found, skipping: {silo_file}", stacklevel=2) continue pdata = read_silo_file(silo_file, var_filter=var) proc_data.append((rank, pdata)) From 938fc747ec41b276f646ea9da2f96db9428f73ee Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 22 Feb 2026 10:00:03 -0500 Subject: [PATCH 015/102] Harden viz: record validation, error handling, and robustness - Validate Fortran trailing record markers to detect file corruption - Add ndim else clauses to catch unsupported dimensionality - Narrow broad except Exception to specific types in MP4 writer - Exit with code 1 on MP4 generation failure - Clean stale frames from _frames/ before rendering - Validate --format argument against supported formats - Respect log-scale in MP4 auto-range sampling - Validate slice_axis parameter in render_3d_slice - Show available timestep range on --step mismatch - Handle per-step exceptions in PNG rendering loop - Improve docstrings for ProcessorData, _detect_endianness, render_mp4 Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/reader.py | 34 ++++++++++++++++++++++++++-------- toolchain/mfc/viz/renderer.py | 31 ++++++++++++++++++++++++++++--- toolchain/mfc/viz/viz.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index d9f2548b4d..9c3ea04430 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -26,11 +26,14 @@ class ProcessorData: """Data from a single processor file. - m, n, p follow the Fortran header convention: x_cb has m+2 elements, - data arrays have (m+1) cells per dimension. The silo reader uses - m = len(x_cb) - 1 (= number of cells) which differs by one, but - assembly code only uses x_cb lengths and n > 0 / p > 0 for - dimensionality, so both conventions work correctly. + m, n, p store dimension metadata but their exact semantics differ + between readers: + - Binary: m = Fortran header value, x_cb has m+2 elements, + cell count is m+1. + - Silo: m = cell count = len(x_cb) - 1. + Assembly code intentionally avoids using m/n/p for array sizing — + it derives everything from x_cb/y_cb/z_cb lengths. If future code + needs m directly, this discrepancy must be resolved. """ m: int n: int @@ -53,7 +56,11 @@ class AssembledData: def _detect_endianness(path: str) -> str: - """Detect endianness from the first record marker (should be 16 for header).""" + """Detect endianness from the first record marker. + + The header record contains 4 int32s (m, n, p, dbvars) = 16 bytes, + so the leading Fortran record marker must be 16. + """ with open(path, 'rb') as f: raw = f.read(4) if len(raw) < 4: @@ -80,7 +87,15 @@ def _read_record_endian(f, endian: str) -> bytes: payload = f.read(rec_len) if len(payload) < rec_len: raise EOFError("Unexpected end of file reading record payload") - f.read(4) # trailing marker + trail = f.read(4) + if len(trail) < 4: + raise EOFError("Unexpected end of file reading trailing record marker") + trail_len = struct.unpack(f'{endian}i', trail)[0] + if trail_len != rec_len: + raise ValueError( + f"Fortran record marker mismatch: leading={rec_len}, trailing={trail_len}. " + "File may be corrupted." + ) return payload @@ -197,6 +212,9 @@ def discover_format(case_dir: str) -> str: def discover_timesteps(case_dir: str, fmt: str) -> List[int]: """Return sorted list of available timesteps.""" + if fmt not in ('binary', 'silo'): + raise ValueError(f"Unknown format '{fmt}'. Supported: 'binary', 'silo'.") + if fmt == 'binary': # Check root/ first (1D), then p0/ root_dir = os.path.join(case_dir, 'binary', 'root') @@ -235,7 +253,7 @@ def discover_timesteps(case_dir: str, fmt: str) -> List[int]: pass return sorted(steps) - return [] + return [] # no timestep files found in expected directories def _discover_processors(case_dir: str, fmt: str) -> List[int]: diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index ae6920af00..c1e4eab7d7 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -75,6 +75,10 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: data_3d = assembled.variables[varname] axis_map = {'x': 0, 'y': 1, 'z': 2} + if slice_axis not in axis_map: + raise ValueError( + f"Invalid slice_axis '{slice_axis}'. Must be one of: 'x', 'y', 'z'." + ) axis_idx = axis_map[slice_axis] coords = [assembled.x_cc, assembled.y_cc, assembled.z_cc] @@ -149,6 +153,10 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum read_func: Callable(step) -> AssembledData for loading each frame. **opts: Rendering options (cmap, vmin, vmax, dpi, log_scale, figsize, slice_axis, slice_index, slice_value). + + Returns: + True if the MP4 was successfully written, False on failure + (e.g., missing imageio dependency or encoding error). """ if read_func is None: raise ValueError("read_func must be provided for MP4 rendering") @@ -170,12 +178,19 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum sample_steps.append(steps[len(steps) // 2]) all_mins, all_maxs = [], [] + log_scale = opts.get('log_scale', False) for s in sample_steps: ad = read_func(s) d = ad.variables.get(varname) if d is not None: - all_mins.append(np.nanmin(d)) - all_maxs.append(np.nanmax(d)) + if log_scale: + pos = d[d > 0] + if pos.size > 0: + all_mins.append(np.nanmin(pos)) + all_maxs.append(np.nanmax(pos)) + else: + all_mins.append(np.nanmin(d)) + all_maxs.append(np.nanmax(d)) if auto_vmin is None and all_mins: opts['vmin'] = min(all_mins) @@ -185,6 +200,11 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum # Write frames as images to a temp directory next to the output file output_dir = os.path.dirname(os.path.abspath(output)) viz_dir = os.path.join(output_dir, '_frames') + # Clean stale frames from any interrupted previous run + if os.path.isdir(viz_dir): + for stale in os.listdir(viz_dir): + if stale.endswith('.png'): + os.remove(os.path.join(viz_dir, stale)) os.makedirs(viz_dir, exist_ok=True) try: @@ -206,6 +226,11 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum varname, step, frame_path, **opts) elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, frame_path, **opts) + else: + raise ValueError( + f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " + "Expected 1, 2, or 3." + ) # Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg) frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) @@ -225,7 +250,7 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum for fname in frame_files: writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) success = True - except Exception as exc: # pylint: disable=broad-except + except (OSError, ValueError, RuntimeError) as exc: print(f"imageio MP4 write failed: {exc}") finally: if writer is not None: diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index e920ee3a78..3d92edc0bf 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -61,6 +61,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc # Auto-detect or use specified format fmt_arg = ARG('format') if fmt_arg: + if fmt_arg not in ('binary', 'silo'): + cons.print(f"[bold red]Error:[/bold red] Unknown format '{fmt_arg}'. " + "Supported formats: 'binary', 'silo'.") + sys.exit(1) fmt = fmt_arg else: try: @@ -138,6 +142,9 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc requested_steps = _parse_steps(step_arg, steps) if not requested_steps: cons.print(f"[bold red]Error:[/bold red] No matching timesteps for --step {step_arg}") + if steps: + cons.print(f"[bold]Available range:[/bold] {steps[0]} to {steps[-1]} " + f"({len(steps)} timesteps)") sys.exit(1) # Collect rendering options @@ -206,6 +213,7 @@ def read_step_all_vars(step): else: cons.print(f"[bold red]Error:[/bold red] Failed to generate {mp4_path}. " "Ensure imageio and imageio-ffmpeg are installed.") + sys.exit(1) return # Single or multiple PNG frames @@ -215,8 +223,15 @@ def read_step_all_vars(step): except ImportError: step_iter = requested_steps + failures = [] for step in step_iter: - assembled = read_step(step) + try: + assembled = read_step(step) + except (FileNotFoundError, EOFError, ValueError) as exc: + cons.print(f"[yellow]Warning:[/yellow] Skipping step {step}: {exc}") + failures.append(step) + continue + output_path = os.path.join(output_base, f'{varname}_{step}.png') if assembled.ndim == 1: @@ -228,9 +243,18 @@ def read_step_all_vars(step): varname, step, output_path, **render_opts) elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, output_path, **render_opts) + else: + cons.print(f"[yellow]Warning:[/yellow] Unsupported ndim={assembled.ndim} " + f"for step {step}, skipping.") + failures.append(step) + continue if len(requested_steps) == 1: cons.print(f"[bold green]Saved:[/bold green] {output_path}") - if len(requested_steps) > 1: - cons.print(f"[bold green]Saved {len(requested_steps)} frames to:[/bold green] {output_base}/") + rendered = len(requested_steps) - len(failures) + if failures: + cons.print(f"[yellow]Warning:[/yellow] {len(failures)} step(s) failed: " + f"{failures[:10]}{'...' if len(failures) > 10 else ''}") + if rendered > 1: + cons.print(f"[bold green]Saved {rendered} frames to:[/bold green] {output_base}/") From 33721e81d7b5518218816c107ee2ebe2d05012e5 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 22 Feb 2026 10:21:44 -0500 Subject: [PATCH 016/102] Guard LogNorm against NaN data, harden frame cleanup - Add np.isfinite() checks to LogNorm guards in both 2D and 3D renderers so all-NaN data doesn't crash matplotlib - Wrap stale-frame and post-encode cleanup in try/except OSError so locked or missing files don't abort rendering Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index c1e4eab7d7..c0588b818e 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -47,9 +47,9 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab if log_scale: 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: + if not np.isfinite(hi) or hi <= 0: hi = 1.0 - if lo <= 0 or lo >= hi: + if not np.isfinite(lo) or lo <= 0 or lo >= hi: lo = hi * 1e-10 norm = LogNorm(vmin=lo, vmax=hi) vmin = None @@ -117,9 +117,9 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: if log_scale: lo = vmin if vmin is not None else np.nanmin(sliced[sliced > 0]) if np.any(sliced > 0) else 1e-10 hi = vmax if vmax is not None else np.nanmax(sliced) - if hi <= 0: + if not np.isfinite(hi) or hi <= 0: hi = 1.0 - if lo <= 0 or lo >= hi: + if not np.isfinite(lo) or lo <= 0 or lo >= hi: lo = hi * 1e-10 norm = LogNorm(vmin=lo, vmax=hi) vmin = None @@ -204,7 +204,10 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum if os.path.isdir(viz_dir): for stale in os.listdir(viz_dir): if stale.endswith('.png'): - os.remove(os.path.join(viz_dir, stale)) + try: + os.remove(os.path.join(viz_dir, stale)) + except OSError: + pass os.makedirs(viz_dir, exist_ok=True) try: @@ -260,8 +263,10 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum if success: for fname in frame_files: fpath = os.path.join(viz_dir, fname) - if os.path.isfile(fpath): + try: os.remove(fpath) + except OSError: + pass try: os.rmdir(viz_dir) except OSError: From 010e3de187b88c3967b83c1edfc20ef5b6ef24f7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Mon, 23 Feb 2026 09:44:34 -0500 Subject: [PATCH 017/102] Fix KeyError in MP4 rendering and require --step for render Two fixes in the viz command: 1. Use .get() instead of direct dict access in the MP4 frame loop to gracefully skip timesteps where the variable is missing, preventing a crash mid-render. 2. Require --step argument for rendering instead of silently rendering all timesteps, which could fill disk and take very long. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 7 +++++-- toolchain/mfc/viz/viz.py | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index c0588b818e..fa7c41c6e7 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -218,14 +218,17 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum for i, step in enumerate(step_iter): assembled = read_func(step) + var_data = assembled.variables.get(varname) + if var_data is None: + continue frame_path = os.path.join(viz_dir, f'{i:06d}.png') if assembled.ndim == 1: - render_1d(assembled.x_cc, assembled.variables[varname], + render_1d(assembled.x_cc, var_data, varname, step, frame_path, **opts) elif assembled.ndim == 2: render_2d(assembled.x_cc, assembled.y_cc, - assembled.variables[varname], + var_data, varname, step, frame_path, **opts) elif assembled.ndim == 3: render_3d_slice(assembled, varname, step, frame_path, **opts) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 3d92edc0bf..850f7a97af 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -134,6 +134,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc "Use --list-vars to see available variables.") sys.exit(1) + if step_arg is None: + cons.print("[bold red]Error:[/bold red] --step is required for rendering. " + "Use --list-steps to see available timesteps, or pass --step all.") + sys.exit(1) + steps = discover_timesteps(case_dir, fmt) if not steps: cons.print("[bold red]Error:[/bold red] No timesteps found.") From 870857157acc4229b25a552dc1c7054f575aec2c Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Mon, 23 Feb 2026 21:31:13 -0500 Subject: [PATCH 018/102] Use MFCException instead of sys.exit, remove imageio from deps - Replace all 13 sys.exit(1) calls in viz.py with raise MFCException so errors follow the standard toolchain error-handling pattern - Remove imageio/imageio-ffmpeg from mandatory pyproject.toml deps since they are already guarded by try/except ImportError in renderer - Narrow __checks() CMake skip to just "viz" (the only new command) Co-Authored-By: Claude Opus 4.6 --- toolchain/main.py | 2 +- toolchain/mfc/viz/viz.py | 63 ++++++++++++++++------------------------ toolchain/pyproject.toml | 4 --- 3 files changed, 26 insertions(+), 43 deletions(-) diff --git a/toolchain/main.py b/toolchain/main.py index 61df696faf..d024fb46d9 100644 --- a/toolchain/main.py +++ b/toolchain/main.py @@ -125,7 +125,7 @@ def __print_greeting(): def __checks(): - if ARG("command") in ("viz", "params", "completion", "help"): + if ARG("command") == "viz": return if not does_command_exist("cmake"): raise MFCException("CMake is required to build MFC but couldn't be located on your system. Please ensure it installed and discoverable (e.g in your system's $PATH).") diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 850f7a97af..3d2387aa64 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -5,9 +5,9 @@ """ import os -import sys from mfc.state import ARG +from mfc.common import MFCException from mfc.printer import cons @@ -33,10 +33,9 @@ def _parse_steps(step_arg, available_steps): return [s for s in requested if s in set(available_steps)] single = int(step_arg) - except ValueError: - cons.print(f"[bold red]Error:[/bold red] Invalid --step value '{step_arg}'. " - "Expected an integer, a range (start:end:stride), or 'all'.") - sys.exit(1) + except ValueError as exc: + raise MFCException(f"Invalid --step value '{step_arg}'. " + "Expected an integer, a range (start:end:stride), or 'all'.") from exc if available_steps and single not in set(available_steps): return [] @@ -50,28 +49,24 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc case_dir = ARG('input') if case_dir is None: - cons.print("[bold red]Error:[/bold red] Please specify a case directory.") - sys.exit(1) + raise MFCException("Please specify a case directory.") # Resolve case directory if not os.path.isdir(case_dir): - cons.print(f"[bold red]Error:[/bold red] Directory not found: {case_dir}") - sys.exit(1) + raise MFCException(f"Directory not found: {case_dir}") # Auto-detect or use specified format fmt_arg = ARG('format') if fmt_arg: if fmt_arg not in ('binary', 'silo'): - cons.print(f"[bold red]Error:[/bold red] Unknown format '{fmt_arg}'. " - "Supported formats: 'binary', 'silo'.") - sys.exit(1) + raise MFCException(f"Unknown format '{fmt_arg}'. " + "Supported formats: 'binary', 'silo'.") fmt = fmt_arg else: try: fmt = discover_format(case_dir) except FileNotFoundError as exc: - cons.print(f"[bold red]Error:[/bold red] {exc}") - sys.exit(1) + raise MFCException(str(exc)) from exc cons.print(f"[bold]Format:[/bold] {fmt}") @@ -98,8 +93,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') steps = discover_timesteps(case_dir, fmt) if not steps: - cons.print("[bold red]Error:[/bold red] No timesteps found.") - sys.exit(1) + raise MFCException("No timesteps found.") if step_arg is None or step_arg == 'all': step = steps[0] @@ -107,10 +101,9 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc else: try: step = int(step_arg) - except ValueError: - cons.print(f"[bold red]Error:[/bold red] Invalid --step value '{step_arg}'. " - "Expected an integer or 'all'.") - sys.exit(1) + except ValueError as exc: + raise MFCException(f"Invalid --step value '{step_arg}'. " + "Expected an integer or 'all'.") from exc if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel @@ -130,27 +123,23 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') if varname is None: - cons.print("[bold red]Error:[/bold red] --var is required for rendering. " - "Use --list-vars to see available variables.") - sys.exit(1) + raise MFCException("--var is required for rendering. " + "Use --list-vars to see available variables.") if step_arg is None: - cons.print("[bold red]Error:[/bold red] --step is required for rendering. " - "Use --list-steps to see available timesteps, or pass --step all.") - sys.exit(1) + raise MFCException("--step is required for rendering. " + "Use --list-steps to see available timesteps, or pass --step all.") steps = discover_timesteps(case_dir, fmt) if not steps: - cons.print("[bold red]Error:[/bold red] No timesteps found.") - sys.exit(1) + raise MFCException("No timesteps found.") requested_steps = _parse_steps(step_arg, steps) if not requested_steps: - cons.print(f"[bold red]Error:[/bold red] No matching timesteps for --step {step_arg}") + msg = f"No matching timesteps for --step {step_arg}" if steps: - cons.print(f"[bold]Available range:[/bold] {steps[0]} to {steps[-1]} " - f"({len(steps)} timesteps)") - sys.exit(1) + msg += f". Available range: {steps[0]} to {steps[-1]} ({len(steps)} timesteps)" + raise MFCException(msg) # Collect rendering options render_opts = {} @@ -196,9 +185,8 @@ def read_step_all_vars(step): test_assembled = read_step_all_vars(requested_steps[0]) if varname not in test_assembled.variables: avail = sorted(test_assembled.variables.keys()) - cons.print(f"[bold red]Error:[/bold red] Variable '{varname}' not found.") - cons.print(f"[bold]Available variables:[/bold] {', '.join(avail)}") - sys.exit(1) + raise MFCException(f"Variable '{varname}' not found. " + f"Available variables: {', '.join(avail)}") # Create output directory output_base = ARG('output') @@ -216,9 +204,8 @@ def read_step_all_vars(step): if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") else: - cons.print(f"[bold red]Error:[/bold red] Failed to generate {mp4_path}. " - "Ensure imageio and imageio-ffmpeg are installed.") - sys.exit(1) + raise MFCException(f"Failed to generate {mp4_path}. " + "Ensure imageio and imageio-ffmpeg are installed.") return # Single or multiple PNG frames diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 1e78da1c24..53e2140290 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -37,10 +37,6 @@ dependencies = [ "seaborn", "matplotlib", - # Visualization (video rendering) - "imageio>=2.33", - "imageio-ffmpeg>=0.5.0", - # Chemistry "cantera>=3.1.0", #"pyrometheus == 1.0.5", From 06e3dc80a7b18e5d9df922e3f16bfe4b407dfe28 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 15:24:38 -0500 Subject: [PATCH 019/102] Fix frame cleanup race, value0 guard, and clean up warnings imports renderer.py: - Use tempfile.mkdtemp instead of hardcoded _frames/ dir to prevent concurrent-run conflicts when two instances render to the same output dir - Always clean up temporary frame files (move cleanup into finally block so frames are removed even if imageio write fails) silo_reader.py: - Add guard for missing 'value0' key in Silo attr (avoids cryptic KeyError on malformed Silo files; emits a warning and skips the variable) - Move `import warnings` to module scope (remove duplicate inline imports) reader.py: - Move `import warnings` to module scope (remove duplicate inline imports) Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/reader.py | 3 +-- toolchain/mfc/viz/renderer.py | 26 +++++++------------------- toolchain/mfc/viz/silo_reader.py | 7 +++++-- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 9c3ea04430..aac642a0ad 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -13,6 +13,7 @@ import os import struct +import warnings from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple @@ -395,12 +396,10 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t for rank in ranks: fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') 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) continue proc_data.append((rank, pdata)) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index fa7c41c6e7..a013854b81 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -7,9 +7,9 @@ """ import os +import tempfile import numpy as np - import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # pylint: disable=wrong-import-position @@ -197,18 +197,10 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum if auto_vmax is None and all_maxs: opts['vmax'] = max(all_maxs) - # Write frames as images to a temp directory next to the output file + # Write frames to a unique temp directory to avoid concurrent-run conflicts output_dir = os.path.dirname(os.path.abspath(output)) - viz_dir = os.path.join(output_dir, '_frames') - # Clean stale frames from any interrupted previous run - if os.path.isdir(viz_dir): - for stale in os.listdir(viz_dir): - if stale.endswith('.png'): - try: - os.remove(os.path.join(viz_dir, stale)) - except OSError: - pass - os.makedirs(viz_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) + viz_dir = tempfile.mkdtemp(dir=output_dir, prefix='_frames_') try: from tqdm import tqdm # pylint: disable=import-outside-toplevel @@ -246,7 +238,6 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum import imageio # pylint: disable=import-outside-toplevel except ImportError: print("imageio is not installed. Install it with: pip install imageio imageio-ffmpeg") - print(f"Frames saved to {viz_dir}/") return False writer = None @@ -261,17 +252,14 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum finally: if writer is not None: writer.close() - - # Clean up only the frames we created - if success: + # Always clean up temporary frame files for fname in frame_files: - fpath = os.path.join(viz_dir, fname) try: - os.remove(fpath) + os.remove(os.path.join(viz_dir, fname)) except OSError: pass try: os.rmdir(viz_dir) except OSError: - pass # directory not empty (pre-existing files) + pass return success diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 7292c8630d..4bd13e96ce 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -12,6 +12,7 @@ """ import os +import warnings from typing import Dict, List, Optional, Tuple import numpy as np @@ -115,7 +116,10 @@ def read_silo_file( # pylint: disable=too-many-locals if "silo" not in obj.attrs: continue attr = obj.attrs["silo"] - data_path = attr["value0"] + data_path = attr.get("value0") + if data_path is None: + warnings.warn(f"Variable '{key}' missing 'value0' in silo attr, skipping", stacklevel=2) + continue data = _resolve_path(f, data_path).astype(np.float64) # MFC's DBPUTQV1 passes the Fortran column-major array as a @@ -160,7 +164,6 @@ def assemble_silo( 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"Processor file not found, skipping: {silo_file}", stacklevel=2) continue pdata = read_silo_file(silo_file, var_filter=var) From 2adf5858a39da30d331436b9787ceae4693d246a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 16:02:29 -0500 Subject: [PATCH 020/102] Replace deprecated imageio.get_writer with imageio.mimwrite imageio.get_writer is deprecated in imageio v3. Switch to imageio.mimwrite, which is the recommended higher-level API for writing video files and works in both imageio v2 and v3. Also removes the manual writer.close() call since mimwrite handles resource cleanup internally. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index a013854b81..ef25e5bbe1 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -240,18 +240,16 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum print("imageio is not installed. Install it with: pip install imageio imageio-ffmpeg") return False - writer = None try: - writer = imageio.get_writer(output, fps=fps, codec='libx264', - pixelformat='yuv420p', macro_block_size=2) - for fname in frame_files: - writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) + imageio.mimwrite( + output, + [imageio.imread(os.path.join(viz_dir, fname)) for fname in frame_files], + fps=fps, codec='libx264', pixelformat='yuv420p', macro_block_size=2, + ) success = True except (OSError, ValueError, RuntimeError) as exc: print(f"imageio MP4 write failed: {exc}") finally: - if writer is not None: - writer.close() # Always clean up temporary frame files for fname in frame_files: try: From 99b5b75368d49e8672ca362571a6a586f10f418f Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 18:15:39 -0500 Subject: [PATCH 021/102] Add h5py/imageio deps, cmap completion, quick-start guide, simplify imports - Add h5py, imageio, imageio-ffmpeg to required deps in pyproject.toml - Remove optional h5py guard in silo_reader.py (now always installed) - Import imageio.v2 at module level in renderer.py (hard dep, no fallback) - Add --cmap tab completion with 17 colormaps to commands.py - Add quick-start guide in viz.py when no action flags are specified Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 6 ++++++ toolchain/mfc/viz/renderer.py | 9 +++------ toolchain/mfc/viz/silo_reader.py | 21 +-------------------- toolchain/mfc/viz/viz.py | 19 +++++++++++++++++++ toolchain/pyproject.toml | 5 +++++ 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 6eb4c50236..ba50cab2e6 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -911,6 +911,12 @@ type=str, default=None, metavar="CMAP", + completion=Completion(type=CompletionType.CHOICES, choices=[ + "viridis", "plasma", "magma", "inferno", "cividis", + "hot", "cool", "jet", "rainbow", "turbo", + "RdBu", "seismic", "bwr", "coolwarm", + "gray", "bone", "pink", + ]), ), Argument( name="vmin", diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index ef25e5bbe1..1fa526e389 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -10,6 +10,9 @@ import tempfile import numpy as np + +import imageio.v2 as imageio + import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # pylint: disable=wrong-import-position @@ -234,12 +237,6 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) success = False - try: - import imageio # pylint: disable=import-outside-toplevel - except ImportError: - print("imageio is not installed. Install it with: pip install imageio imageio-ffmpeg") - return False - try: imageio.mimwrite( output, diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 4bd13e96ce..05f3c92c01 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -7,39 +7,22 @@ Actual data lives in numbered datasets under the ``.silo/`` group. This reader uses h5py to navigate that structure. - -Requires: h5py (optional dependency). """ import os import warnings from typing import Dict, List, Optional, Tuple +import h5py import numpy as np from .reader import AssembledData, ProcessorData, assemble_from_proc_data -try: - import h5py - - HAS_H5PY = True -except ImportError: - HAS_H5PY = False - # Silo type constants (from silo.h) _DB_QUADMESH = 130 _DB_QUADVAR = 501 -def _check_h5py(): - if not HAS_H5PY: - raise ImportError( - "h5py is required to read Silo-HDF5 files.\n" - "Install it with: pip install h5py\n" - "Or re-run post_process with format=2 to produce binary output." - ) - - def _resolve_path(h5file, path_bytes): """Resolve a silo internal path (e.g. b'/.silo/#000003') and return its data as a numpy array.""" @@ -60,7 +43,6 @@ def read_silo_file( # pylint: disable=too-many-locals Returns: ProcessorData with grid coordinates and variable arrays. """ - _check_h5py() with h5py.File(path, "r") as f: # --- locate the mesh ------------------------------------------------ @@ -146,7 +128,6 @@ def assemble_silo( """ Read and assemble multi-processor Silo-HDF5 data for a given timestep. """ - _check_h5py() base = os.path.join(case_dir, "silo_hdf5") if not os.path.isdir(base): diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 3d2387aa64..dcf3b9ef9f 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -70,6 +70,25 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f"[bold]Format:[/bold] {fmt}") + # Quick guide when no action is specified + if not ARG('list_steps') and not ARG('list_vars') and ARG('var') is None: + cons.print() + d = case_dir + cons.print("[bold]Quick start:[/bold]") + cons.print(f" [green]./mfc.sh viz {d} --list-steps[/green]" + " [dim]see available timesteps[/dim]") + cons.print(f" [green]./mfc.sh viz {d} --list-vars --step 0[/green]" + " [dim]see available variables[/dim]") + cons.print(f" [green]./mfc.sh viz {d} --var pres --step 0[/green]" + " [dim]render a PNG[/dim]") + cons.print(f" [green]./mfc.sh viz {d} --var pres --step all --mp4[/green]" + " [dim]render an MP4[/dim]") + cons.print(f" [green]./mfc.sh viz {d} --var pres --step 0 --slice-axis z[/green]" + " [dim]3D midplane slice[/dim]") + cons.print() + cons.print("[dim]Run [bold]./mfc.sh viz --help[/bold] for all options.[/dim]") + return + # Handle --list-steps if ARG('list_steps'): steps = discover_timesteps(case_dir, fmt) diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 53e2140290..86e435a36b 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -37,6 +37,11 @@ dependencies = [ "seaborn", "matplotlib", + # Visualization + "h5py", + "imageio>=2.33", + "imageio-ffmpeg>=0.5.0", + # Chemistry "cantera>=3.1.0", #"pyrometheus == 1.0.5", From ff89b90e37b42bdd2cd1b7c8123f54a1a7281556 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 19:40:26 -0500 Subject: [PATCH 022/102] Suppress ffmpeg diagnostic noise in MP4 output Pass ffmpeg_log_level='error' to imageio.mimwrite so the rawvideo probesize warning is not printed for short videos. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 1fa526e389..b129f2e207 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -242,6 +242,7 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum output, [imageio.imread(os.path.join(viz_dir, fname)) for fname in frame_files], fps=fps, codec='libx264', pixelformat='yuv420p', macro_block_size=2, + ffmpeg_log_level='error', ) success = True except (OSError, ValueError, RuntimeError) as exc: From c8bd1a5ae37cdd11fad539e645ba3476d8dec10f Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 19:59:21 -0500 Subject: [PATCH 023/102] Fix AttributeError: numpy.void has no .get(), use [] indexing numpy structured array elements (numpy.void) don't support .get(); access fields with attr["value0"] inside a try/except instead. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/silo_reader.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 05f3c92c01..31cf03951e 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -98,8 +98,9 @@ def read_silo_file( # pylint: disable=too-many-locals if "silo" not in obj.attrs: continue attr = obj.attrs["silo"] - data_path = attr.get("value0") - if data_path is None: + try: + data_path = attr["value0"] + except (KeyError, ValueError): warnings.warn(f"Variable '{key}' missing 'value0' in silo attr, skipping", stacklevel=2) continue data = _resolve_path(f, data_path).astype(np.float64) From 12a71f067ee4c31d1bc8b1940413635817eec26a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 20:11:47 -0500 Subject: [PATCH 024/102] Expand --cmap completion to full matplotlib colormap list Replace the 17-entry stub with all ~88 standard matplotlib colormaps (no _r reversed variants), organized by category. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index ba50cab2e6..026260cd10 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -912,10 +912,29 @@ default=None, metavar="CMAP", completion=Completion(type=CompletionType.CHOICES, choices=[ - "viridis", "plasma", "magma", "inferno", "cividis", - "hot", "cool", "jet", "rainbow", "turbo", - "RdBu", "seismic", "bwr", "coolwarm", - "gray", "bone", "pink", + # Perceptually uniform sequential + "viridis", "plasma", "inferno", "magma", "cividis", + # Diverging + "RdBu", "RdYlBu", "RdYlGn", "RdGy", "coolwarm", "bwr", "seismic", + "PiYG", "PRGn", "BrBG", "PuOr", "Spectral", + # Sequential + "Blues", "Greens", "Oranges", "Reds", "Purples", "Greys", + "YlOrRd", "YlOrBr", "YlGn", "YlGnBu", "GnBu", "BuGn", + "BuPu", "PuBu", "PuBuGn", "PuRd", "RdPu", + # Sequential 2 + "hot", "afmhot", "gist_heat", "copper", + "bone", "gray", "pink", "spring", "summer", "autumn", "winter", "cool", + "binary", "gist_yarg", "gist_gray", + # Cyclic + "twilight", "twilight_shifted", "hsv", + # Qualitative + "tab10", "tab20", "tab20b", "tab20c", + "Set1", "Set2", "Set3", "Paired", "Accent", "Dark2", "Pastel1", "Pastel2", + # Miscellaneous + "turbo", "jet", "rainbow", "nipy_spectral", "gist_ncar", + "gist_rainbow", "gist_stern", "gist_earth", "ocean", "terrain", + "gnuplot", "gnuplot2", "CMRmap", "cubehelix", "brg", "flag", "prism", + "berlin", "managua", "vanimo", "Wistia", ]), ), Argument( From 4b65c1315b514a5810a385f3d7475ed2a4c76b47 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 21:03:28 -0500 Subject: [PATCH 025/102] Add --interactive mode: Dash web UI for 3D/2D/1D visualization New file: toolchain/mfc/viz/interactive.py - Dark-themed Dash app (Catppuccin Mocha palette) - Viz modes: Slice (x/y/z + position slider), Isosurface (min/max + surface count + caps toggle), Volume (opacity + shell count + isomin/isomax); 2D heatmap and 1D line - Play/Pause with FPS slider and loop toggle via dcc.Interval - Colormap picker (48 options), log scale, vmin/vmax + auto reset - Camera angle preserved across updates (uirevision=mode) - Server-side cache avoids re-reading the same step twice viz.py: --interactive defaults --step to all; dispatches before PNG/MP4 commands.py: add --interactive / -i and --port flags Usage: ./mfc.sh viz case_dir/ --var pres --interactive ./mfc.sh viz case_dir/ --var pres --interactive --port 8080 Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 15 + toolchain/mfc/viz/interactive.py | 593 +++++++++++++++++++++++++++++++ toolchain/mfc/viz/viz.py | 14 +- 3 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 toolchain/mfc/viz/interactive.py diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 026260cd10..becea9c69a 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -1017,6 +1017,20 @@ default=False, dest="log_scale", ), + Argument( + name="interactive", + short="i", + help="Launch an interactive Dash web UI instead of saving PNG/MP4.", + action=ArgAction.STORE_TRUE, + default=False, + ), + Argument( + name="port", + help="Port for the interactive web server (default: 8050).", + type=int, + default=8050, + metavar="PORT", + ), ], examples=[ Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Plot pressure at step 1000"), @@ -1031,6 +1045,7 @@ ("--list-vars", "List available variables"), ("--list-steps", "List available timesteps"), ("--mp4", "Generate MP4 video"), + ("--interactive / -i", "Launch interactive Dash web UI"), ("--cmap NAME", "Matplotlib colormap"), ("--slice-axis x|y|z", "Axis for 3D slice"), ], diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py new file mode 100644 index 0000000000..758e8a1a0f --- /dev/null +++ b/toolchain/mfc/viz/interactive.py @@ -0,0 +1,593 @@ +""" +Interactive Dash-based visualization for MFC post-processed data. + +Launched via ``./mfc.sh viz --var --step all --interactive``. +Opens a dark-themed web UI in your browser (or via SSH tunnel) with live +controls for slice position, isosurface thresholds, volume opacity, +colormap, log scale, vmin/vmax, and timestep playback. +""" + +from typing import List, Callable + +import numpy as np +import plotly.graph_objects as go +from dash import Dash, dcc, html, Input, Output, State, callback_context, no_update + +from mfc.printer import cons + +# --------------------------------------------------------------------------- +# Colormaps available in the picker +# --------------------------------------------------------------------------- +_CMAPS = [ + "viridis", "plasma", "inferno", "magma", "cividis", + "turbo", "jet", "rainbow", "nipy_spectral", + "RdBu", "RdYlBu", "RdYlGn", "coolwarm", "bwr", "seismic", "Spectral", + "hot", "afmhot", "gist_heat", "copper", + "bone", "gray", "spring", "summer", "autumn", "winter", "cool", "pink", + "Blues", "Greens", "Oranges", "Reds", "Purples", "Greys", + "twilight", "twilight_shifted", "hsv", + "tab10", "tab20", "terrain", "ocean", "gist_earth", + "gnuplot", "gnuplot2", "CMRmap", "cubehelix", + "berlin", "managua", "vanimo", "Wistia", +] + +# --------------------------------------------------------------------------- +# Catppuccin Mocha palette +# --------------------------------------------------------------------------- +_BG = '#181825' +_SURF = '#1e1e2e' +_OVER = '#313244' +_BORD = '#45475a' +_TEXT = '#cdd6f4' +_SUB = '#a6adc8' +_MUTED = '#6c7086' +_ACCENT = '#cba6f7' +_GREEN = '#a6e3a1' +_RED = '#f38ba8' +_BLUE = '#89b4fa' +_TEAL = '#94e2d5' +_YELLOW = '#f9e2af' + +# --------------------------------------------------------------------------- +# Server-side data cache {step -> AssembledData} +# --------------------------------------------------------------------------- +_cache: dict = {} + + +def _load(step: int, read_func: Callable): + if step not in _cache: + _cache[step] = read_func(step) + return _cache[step] + + +# --------------------------------------------------------------------------- +# Layout helpers +# --------------------------------------------------------------------------- + +def _section(title, *children): + return html.Div([ + html.Div(title, style={ + 'fontSize': '10px', 'fontWeight': 'bold', + 'textTransform': 'uppercase', 'letterSpacing': '0.08em', + 'color': _MUTED, 'borderBottom': f'1px solid {_OVER}', + 'paddingBottom': '4px', 'marginTop': '16px', 'marginBottom': '6px', + }), + *children, + ]) + + +def _lbl(text): + return html.Div(text, style={ + 'fontSize': '11px', 'color': _SUB, + 'marginBottom': '2px', 'marginTop': '6px', + }) + + +def _slider(sid, lo, hi, step, val, marks=None): + return dcc.Slider( + id=sid, min=lo, max=hi, step=step, value=val, + marks=marks or {}, updatemode='drag', + tooltip={'placement': 'bottom', 'always_visible': True}, + ) + + +def _btn(bid, label, color=_TEXT): + return html.Button(label, id=bid, n_clicks=0, style={ + 'flex': '1', 'padding': '5px 8px', 'fontSize': '12px', + 'backgroundColor': _OVER, 'color': color, + 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'cursor': 'pointer', 'fontFamily': 'monospace', + }) + + +def _num(sid, placeholder='auto'): + return dcc.Input( + id=sid, type='number', placeholder=placeholder, debounce=True, + style={ + 'width': '100%', 'backgroundColor': _OVER, 'color': _TEXT, + 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'padding': '4px 6px', 'fontSize': '12px', 'fontFamily': 'monospace', + 'boxSizing': 'border-box', + }, + ) + + +# --------------------------------------------------------------------------- +# 3D figure builder +# --------------------------------------------------------------------------- + +def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements + log_fn, cmin, cmax, cbar_title, + slice_axis, slice_pos, + iso_min_frac, iso_max_frac, iso_n, iso_caps, + vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac): + """Return (trace, title) for a 3D assembled dataset.""" + cbar = dict( + title=dict(text=cbar_title, font=dict(color=_TEXT)), + tickfont=dict(color=_TEXT), + ) + rng = cmax - cmin if cmax > cmin else 1.0 + + if mode == 'slice': + axis_coords = {'x': ad.x_cc, 'y': ad.y_cc, 'z': ad.z_cc} + coords = axis_coords[slice_axis] + coord_val = coords[0] + (coords[-1] - coords[0]) * slice_pos + idx = int(np.clip(np.argmin(np.abs(coords - coord_val)), 0, len(coords) - 1)) + actual = float(coords[idx]) + + if slice_axis == 'x': + sliced = log_fn(raw[idx, :, :]) # (ny, nz) + YY, ZZ = np.meshgrid(ad.y_cc, ad.z_cc, indexing='ij') + trace = go.Surface( + x=np.full_like(YY, actual), y=YY, z=ZZ, + surfacecolor=sliced, cmin=cmin, cmax=cmax, + colorscale=cmap, colorbar=cbar, showscale=True, + ) + elif slice_axis == 'y': + sliced = log_fn(raw[:, idx, :]) # (nx, nz) + XX, ZZ = np.meshgrid(ad.x_cc, ad.z_cc, indexing='ij') + trace = go.Surface( + x=XX, y=np.full_like(XX, actual), z=ZZ, + surfacecolor=sliced, cmin=cmin, cmax=cmax, + colorscale=cmap, colorbar=cbar, showscale=True, + ) + else: # z + sliced = log_fn(raw[:, :, idx]) # (nx, ny) + XX, YY = np.meshgrid(ad.x_cc, ad.y_cc, indexing='ij') + trace = go.Surface( + x=XX, y=YY, z=np.full_like(XX, actual), + surfacecolor=sliced, cmin=cmin, cmax=cmax, + colorscale=cmap, colorbar=cbar, showscale=True, + ) + title = f'{varname} · {slice_axis} = {actual:.4g} · step {step}' + + elif mode == 'isosurface': + X3, Y3, Z3 = np.meshgrid(ad.x_cc, ad.y_cc, ad.z_cc, indexing='ij') + vf = log_fn(raw.ravel()) + ilo = cmin + rng * iso_min_frac + ihi = cmin + rng * max(iso_max_frac, iso_min_frac + 0.01) + caps = dict(x_show=iso_caps, y_show=iso_caps, z_show=iso_caps) + trace = go.Isosurface( + x=X3.ravel(), y=Y3.ravel(), z=Z3.ravel(), value=vf, + isomin=ilo, isomax=ihi, surface_count=int(iso_n), + colorscale=cmap, cmin=cmin, cmax=cmax, + caps=caps, colorbar=cbar, + ) + title = f'{varname} · {int(iso_n)} isosurfaces · step {step}' + + else: # volume + X3, Y3, Z3 = np.meshgrid(ad.x_cc, ad.y_cc, ad.z_cc, indexing='ij') + vf = log_fn(raw.ravel()) + vlo = cmin + rng * vol_min_frac + vhi = cmin + rng * max(vol_max_frac, vol_min_frac + 0.01) + trace = go.Volume( + x=X3.ravel(), y=Y3.ravel(), z=Z3.ravel(), value=vf, + isomin=vlo, isomax=vhi, + opacity=float(vol_opacity), surface_count=int(vol_nsurf), + colorscale=cmap, colorbar=cbar, + ) + title = f'{varname} · volume · step {step}' + + return trace, title + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def run_interactive( # pylint: disable=too-many-locals,too-many-statements + varname: str, + steps: List[int], + read_func: Callable, + port: int = 8050, +): + """Launch the interactive Dash visualization server.""" + app = Dash( + __name__, + title=f'MFC viz · {varname}', + suppress_callback_exceptions=True, + ) + + # Load first step to know dimensionality and initial range + init = _load(steps[0], read_func) + ndim = init.ndim + d0 = init.variables[varname] + pos0 = d0[d0 > 0] if np.any(d0 > 0) else d0 + init_min = float(np.nanmin(pos0)) + init_max = float(np.nanmax(d0)) + + step_opts = [{'label': str(s), 'value': s} for s in steps] + cmap_opts = [{'label': c, 'value': c} for c in _CMAPS] + + if ndim == 3: + mode_opts = [ + {'label': ' Slice', 'value': 'slice'}, + {'label': ' Isosurface', 'value': 'isosurface'}, + {'label': ' Volume', 'value': 'volume'}, + ] + elif ndim == 2: + mode_opts = [{'label': ' Heatmap', 'value': 'heatmap'}] + else: + mode_opts = [{'label': ' Line', 'value': 'line'}] + default_mode = mode_opts[0]['value'] + + # ------------------------------------------------------------------ + # Sidebar layout + # ------------------------------------------------------------------ + sidebar = html.Div([ + + # Header + html.Div('MFC viz', style={ + 'fontSize': '16px', 'fontWeight': 'bold', 'color': _ACCENT, + }), + html.Div( + f'var: {varname} · {ndim}D · {len(steps)} step{"s" if len(steps) != 1 else ""}', + style={'fontSize': '11px', 'color': _MUTED}, + ), + + # ── Timestep ────────────────────────────────────────────────── + _section('Timestep', + dcc.Dropdown( + id='step-sel', options=step_opts, value=steps[0], clearable=False, + style={'fontSize': '12px', 'backgroundColor': _OVER, + 'border': f'1px solid {_BORD}'}, + ), + html.Div([ + _btn('play-btn', '▶ Play', _GREEN), + html.Div(style={'width': '6px'}), + _btn('stop-btn', '■ Stop', _RED), + ], style={'display': 'flex', 'marginTop': '6px'}), + _lbl('Playback speed (fps)'), + _slider('fps-sl', 0.5, 10, 0.5, 2, + marks={0.5: '0.5', 2: '2', 5: '5', 10: '10'}), + dcc.Checklist( + id='loop-chk', + options=[{'label': ' Loop', 'value': 'loop'}], value=['loop'], + style={'fontSize': '12px', 'color': _SUB, 'marginTop': '4px'}, + ), + ), + + # ── Viz mode ────────────────────────────────────────────────── + _section('Viz Mode', + dcc.RadioItems( + id='mode-sel', options=mode_opts, value=default_mode, + style={'fontSize': '12px', 'color': _TEXT}, + inputStyle={'marginRight': '6px', 'cursor': 'pointer'}, + labelStyle={'display': 'block', 'marginBottom': '5px', 'cursor': 'pointer'}, + ), + ), + + # ── Slice ───────────────────────────────────────────────────── + html.Div(id='ctrl-slice', children=[ + _section('Slice', + _lbl('Axis'), + dcc.RadioItems( + id='slice-axis', options=['x', 'y', 'z'], value='z', + inline=True, style={'fontSize': '12px', 'color': _TEXT}, + inputStyle={'marginRight': '4px'}, + labelStyle={'marginRight': '14px'}, + ), + _lbl('Position (0 = start, 1 = end)'), + _slider('slice-pos', 0.0, 1.0, 0.01, 0.5, + marks={0: '0', 0.5: '½', 1: '1'}), + ), + ]), + + # ── Isosurface ──────────────────────────────────────────────── + html.Div(id='ctrl-iso', style={'display': 'none'}, children=[ + _section('Isosurface', + _lbl('Min threshold (fraction of color range)'), + _slider('iso-min', 0.0, 1.0, 0.01, 0.2, + marks={0: '0', 0.5: '0.5', 1: '1'}), + _lbl('Max threshold (fraction of color range)'), + _slider('iso-max', 0.0, 1.0, 0.01, 0.8, + marks={0: '0', 0.5: '0.5', 1: '1'}), + _lbl('Number of isosurfaces'), + _slider('iso-n', 1, 10, 1, 3, + marks={1: '1', 3: '3', 5: '5', 10: '10'}), + dcc.Checklist( + id='iso-caps', + options=[{'label': ' Show end-caps', 'value': 'caps'}], value=[], + style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, + ), + ), + ]), + + # ── Volume ──────────────────────────────────────────────────── + html.Div(id='ctrl-vol', style={'display': 'none'}, children=[ + _section('Volume', + _lbl('Opacity per shell'), + _slider('vol-opacity', 0.01, 0.5, 0.01, 0.1, + marks={0.01: '0', 0.25: '.25', 0.5: '.5'}), + _lbl('Number of shells'), + _slider('vol-nsurf', 3, 30, 1, 15, + marks={3: '3', 15: '15', 30: '30'}), + _lbl('Isomin (fraction of color range)'), + _slider('vol-min', 0.0, 1.0, 0.01, 0.0, + marks={0: '0', 0.5: '0.5', 1: '1'}), + _lbl('Isomax (fraction of color range)'), + _slider('vol-max', 0.0, 1.0, 0.01, 1.0, + marks={0: '0', 0.5: '0.5', 1: '1'}), + ), + ]), + + # ── Color ───────────────────────────────────────────────────── + _section('Color', + _lbl('Colormap'), + dcc.Dropdown( + id='cmap-sel', options=cmap_opts, value='viridis', clearable=False, + style={'fontSize': '12px', 'backgroundColor': _OVER, + 'border': f'1px solid {_BORD}'}, + ), + dcc.Checklist( + id='log-chk', + options=[{'label': ' Log scale', 'value': 'log'}], value=[], + style={'fontSize': '12px', 'color': _SUB, 'marginTop': '6px'}, + ), + html.Div([ + html.Div([_lbl('vmin'), _num('vmin-inp')], + style={'flex': 1, 'marginRight': '6px'}), + html.Div([_lbl('vmax'), _num('vmax-inp')], + style={'flex': 1}), + ], style={'display': 'flex'}), + html.Button('↺ Auto range', id='reset-btn', n_clicks=0, style={ + 'marginTop': '8px', 'padding': '4px 8px', 'fontSize': '11px', + 'width': '100%', 'backgroundColor': _OVER, 'color': _TEAL, + 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'cursor': 'pointer', 'fontFamily': 'monospace', + }), + ), + + # ── Status ──────────────────────────────────────────────────── + html.Div(id='status-bar', style={ + 'marginTop': 'auto', 'paddingTop': '12px', + 'fontSize': '11px', 'color': _MUTED, + 'borderTop': f'1px solid {_OVER}', 'lineHeight': '1.7', + }), + + ], style={ + 'width': '265px', 'minWidth': '265px', + 'backgroundColor': _SURF, 'padding': '14px', + 'height': '100vh', 'overflowY': 'auto', + 'display': 'flex', 'flexDirection': 'column', + 'fontFamily': 'monospace', 'color': _TEXT, + 'boxSizing': 'border-box', + }) + + app.layout = html.Div([ + sidebar, + html.Div([ + dcc.Graph( + id='viz-graph', style={'height': '100vh'}, + config={ + 'displayModeBar': True, 'scrollZoom': True, + 'modeBarButtonsToRemove': ['select2d', 'lasso2d'], + 'toImageButtonOptions': {'format': 'png', 'scale': 2}, + }, + ), + ], style={'flex': '1', 'overflow': 'hidden', 'backgroundColor': _BG}), + + dcc.Interval(id='play-iv', interval=500, n_intervals=0, disabled=True), + dcc.Store(id='playing-st', data=False), + ], style={ + 'display': 'flex', 'height': '100vh', 'overflow': 'hidden', + 'backgroundColor': _BG, 'fontFamily': 'monospace', + }) + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + @app.callback( + Output('play-iv', 'disabled'), + Output('play-iv', 'interval'), + Output('playing-st', 'data'), + Output('play-btn', 'children'), + Input('play-btn', 'n_clicks'), + Input('stop-btn', 'n_clicks'), + Input('fps-sl', 'value'), + State('playing-st', 'data'), + prevent_initial_call=True, + ) + def _toggle_play(_, __, fps, is_playing): # pylint: disable=unused-argument + iv = max(int(1000 / max(float(fps or 2), 0.1)), 50) + trig = (callback_context.triggered or [{}])[0].get('prop_id', '') + if 'stop-btn' in trig: + return True, iv, False, '▶ Play' + if 'play-btn' in trig: + playing = not is_playing + return not playing, iv, playing, ('⏸ Pause' if playing else '▶ Play') + return not is_playing, iv, is_playing, no_update # fps-only change + + @app.callback( + Output('step-sel', 'value'), + 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): + return steps[0] if ('loop' in (loop_val or [])) else no_update + return steps[nxt] + + @app.callback( + Output('ctrl-slice', 'style'), + Output('ctrl-iso', 'style'), + Output('ctrl-vol', 'style'), + Input('mode-sel', 'value'), + ) + def _toggle_controls(mode): + show, hide = {'display': 'block'}, {'display': 'none'} + return ( + show if mode == 'slice' else hide, + show if mode == 'isosurface' else hide, + show if mode == 'volume' else hide, + ) + + @app.callback( + Output('vmin-inp', 'value'), + Output('vmax-inp', 'value'), + Input('reset-btn', 'n_clicks'), + prevent_initial_call=True, + ) + def _reset_range(_): + return None, None + + @app.callback( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements + Output('viz-graph', 'figure'), + Output('status-bar', 'children'), + Input('step-sel', 'value'), + Input('mode-sel', 'value'), + Input('slice-axis', 'value'), + Input('slice-pos', 'value'), + Input('iso-min', 'value'), + Input('iso-max', 'value'), + Input('iso-n', 'value'), + Input('iso-caps', 'value'), + Input('vol-opacity', 'value'), + Input('vol-nsurf', 'value'), + Input('vol-min', 'value'), + Input('vol-max', 'value'), + Input('cmap-sel', 'value'), + Input('log-chk', 'value'), + Input('vmin-inp', 'value'), + Input('vmax-inp', 'value'), + ) + def _update(step, mode, + slice_axis, slice_pos, + iso_min_frac, iso_max_frac, iso_n, iso_caps, + vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac, + cmap, log_chk, vmin_in, vmax_in): + + ad = _load(step, read_func) + raw = ad.variables[varname] + log = bool(log_chk and 'log' in log_chk) + cmap = cmap or 'viridis' + + # Color range + if vmin_in is not None: + vmin = float(vmin_in) + else: + safe = raw[raw > 0] if log and np.any(raw > 0) else raw + vmin = float(np.nanmin(safe)) + if vmax_in is not None: + vmax = float(vmax_in) + else: + vmax = float(np.nanmax(raw)) + if vmax <= vmin: + vmax = vmin + 1e-10 + + if log: + def _tf(arr): + return np.where(arr > 0, np.log10(np.maximum(arr, 1e-300)), np.nan) + cmin = float(np.log10(max(vmin, 1e-300))) + cmax = float(np.log10(max(vmax, 1e-300))) + cbar_title = f'log\u2081\u2080({varname})' + else: + def _tf(arr): return arr + cmin, cmax = vmin, vmax + cbar_title = varname + + fig = go.Figure() + title = '' + + if ad.ndim == 3: + trace, title = _build_3d( + ad, raw, varname, step, mode, cmap, _tf, cmin, cmax, cbar_title, + slice_axis or 'z', float(slice_pos or 0.5), + float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), + int(iso_n or 3), bool(iso_caps and 'caps' in iso_caps), + float(vol_opacity or 0.1), int(vol_nsurf or 15), + float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), + ) + fig.add_trace(trace) + fig.update_layout(scene=dict( + xaxis=dict(title='x', backgroundcolor=_SURF, + gridcolor=_OVER, color=_TEXT), + yaxis=dict(title='y', backgroundcolor=_SURF, + gridcolor=_OVER, color=_TEXT), + zaxis=dict(title='z', backgroundcolor=_SURF, + gridcolor=_OVER, color=_TEXT), + bgcolor=_BG, aspectmode='data', + )) + + elif ad.ndim == 2: + cbar = dict(title=dict(text=cbar_title, font=dict(color=_TEXT)), + tickfont=dict(color=_TEXT)) + fig.add_trace(go.Heatmap( + x=ad.x_cc, y=ad.y_cc, z=_tf(raw).T, + zmin=cmin, zmax=cmax, colorscale=cmap, colorbar=cbar, + )) + fig.update_layout( + xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, scaleanchor='y'), + yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER), + plot_bgcolor=_BG, + ) + title = f'{varname} · step {step}' + + else: # 1D + plot_y = _tf(raw) if log else raw + fig.add_trace(go.Scatter( + x=ad.x_cc, y=plot_y, mode='lines', + line=dict(color=_ACCENT, width=2), name=varname, + )) + fig.update_layout( + xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER), + yaxis=dict(title=cbar_title, color=_TEXT, gridcolor=_OVER, + range=[cmin, cmax] if (vmin_in or vmax_in) else None), + plot_bgcolor=_BG, + ) + title = f'{varname} · step {step}' + + fig.update_layout( + title=dict(text=title, font=dict(color=_TEXT, size=13, family='monospace')), + paper_bgcolor=_BG, + font=dict(color=_TEXT, family='monospace'), + margin=dict(l=0, r=0, t=36, b=0), + uirevision=mode, # preserve camera angle within a mode + ) + + dmin, dmax = float(np.nanmin(raw)), float(np.nanmax(raw)) + status = html.Div([ + html.Span(f'step {step}', style={'color': _YELLOW}), + html.Span(f' · shape {raw.shape}', style={'color': _MUTED}), + html.Br(), + html.Span('min ', style={'color': _MUTED}), + html.Span(f'{dmin:.4g}', style={'color': _BLUE}), + html.Span(' max ', style={'color': _MUTED}), + html.Span(f'{dmax:.4g}', style={'color': _RED}), + ]) + return fig, status + + # ------------------------------------------------------------------ + cons.print(f'\n[bold green]Interactive viz server:[/bold green] ' + f'[bold]http://localhost:{port}[/bold]') + cons.print(f'[dim]SSH tunnel: ssh -L {port}:localhost:{port} [/dim]') + cons.print('[dim]Ctrl+C to stop.[/dim]\n') + app.run(debug=False, port=port, host='0.0.0.0') diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index dcf3b9ef9f..7acc606274 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -146,8 +146,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc "Use --list-vars to see available variables.") if step_arg is None: - raise MFCException("--step is required for rendering. " - "Use --list-steps to see available timesteps, or pass --step all.") + if ARG('interactive'): + step_arg = 'all' # default to all steps in interactive mode + else: + raise MFCException("--step is required for rendering. " + "Use --list-steps to see available timesteps, or pass --step all.") steps = discover_timesteps(case_dir, fmt) if not steps: @@ -207,6 +210,13 @@ def read_step_all_vars(step): raise MFCException(f"Variable '{varname}' not found. " f"Available variables: {', '.join(avail)}") + # Interactive mode — launch Dash web server + if ARG('interactive'): + from .interactive import run_interactive # pylint: disable=import-outside-toplevel + port = ARG('port') or 8050 + run_interactive(varname, requested_steps, read_step, port=int(port)) + return + # Create output directory output_base = ARG('output') if output_base is None: From d26623c6dfa1d4794623f5b286f97b31e7f7e434 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 21:14:41 -0500 Subject: [PATCH 026/102] Fix interactive viz freezing: switch sliders to updatemode=mouseup Drag mode fires a callback on every pixel of movement, overwhelming the server when data loading + figure build takes >100ms. Mouseup fires only on release, eliminating callback queue buildup. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 758e8a1a0f..61bab588b3 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -86,7 +86,7 @@ def _lbl(text): def _slider(sid, lo, hi, step, val, marks=None): return dcc.Slider( id=sid, min=lo, max=hi, step=step, value=val, - marks=marks or {}, updatemode='drag', + marks=marks or {}, updatemode='mouseup', tooltip={'placement': 'bottom', 'always_visible': True}, ) From 7901c683caa129ed12b1acf99afa1f753e5ae807 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 21:52:13 -0500 Subject: [PATCH 027/102] Fix 3D aspect ratio and add blast wave example Fix aspectmode='manual' with domain-extent aspect ratio so 3D slice views are not collapsed to flat 2D. Add 3D_blast_wave example: a spherical Sod shock tube on a 63x63x63 grid (8 ranks, 2x2x2) with real spatial variation for interactive viz demos. Co-Authored-By: Claude Sonnet 4.6 --- examples/3D_acoustic_support3/case.py | 2 +- examples/3D_blast_wave/case.py | 103 ++++++++++++++++++++++++++ toolchain/mfc/viz/interactive.py | 10 ++- 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 examples/3D_blast_wave/case.py diff --git a/examples/3D_acoustic_support3/case.py b/examples/3D_acoustic_support3/case.py index 5548ce8399..31f6a284d8 100644 --- a/examples/3D_acoustic_support3/case.py +++ b/examples/3D_acoustic_support3/case.py @@ -46,7 +46,7 @@ "bc_z%beg": -6, "bc_z%end": -6, # Formatted Database Files Structure Parameters - "format": 1, + "format": 2, "precision": 2, "prim_vars_wrt": "T", "parallel_io": "T", diff --git a/examples/3D_blast_wave/case.py b/examples/3D_blast_wave/case.py new file mode 100644 index 0000000000..f9fc8b2fa5 --- /dev/null +++ b/examples/3D_blast_wave/case.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +3D spherical blast wave — Sod-like pressure jump on a sphere. +High-pressure sphere (p=1, rho=1) explodes into low-pressure gas (p=0.1, rho=0.125). +Produces a spherical shock front + contact discontinuity + rarefaction fan. + +Grid: 63×63×63 (64 cells/dim). +MPI: 8 ranks (2×2×2) → 32 cells/rank/dim ≥ 25 (WENO5 minimum). +""" +import json +import math + +# Domain: unit cube centred at origin +L = 1.0 +N = 63 # m=n=p → m+1=64 + +# Ideal gas γ=1.4 +gamma = 1.4 + +# Inside sphere (r < R0) +rho_hi, p_hi = 1.0, 1.0 +# Outside sphere +rho_lo, p_lo = 0.125, 0.1 +R0 = 0.15 # sphere radius + +# Shock speed estimate (Rankine-Hugoniot) for CFL +c_lo = math.sqrt(gamma * p_lo / rho_lo) # ~1.058 +Ms = math.sqrt((gamma + 1) / (2 * gamma) * (p_hi / p_lo) + + (gamma - 1) / (2 * gamma)) # ~2.0 +v_shock = Ms * c_lo # ~2.12 +c_hi = math.sqrt(gamma * p_hi / rho_hi) # ~1.183 +max_wave = max(v_shock, c_hi) # ~2.12 + +dx = L / (N + 1) # ~0.01563 +dt = 0.4 * dx / max_wave # ~0.00295 (CFL=0.4) + +Nt = 100 # simulate until t ≈ 0.295 s — shock reaches r ≈ 0.63 +Ns = 10 # save every 10 steps → 11 frames + +print(json.dumps({ + # Logistics + "run_time_info": "T", + + # Domain + "x_domain%beg": -L/2, "x_domain%end": L/2, + "y_domain%beg": -L/2, "y_domain%end": L/2, + "z_domain%beg": -L/2, "z_domain%end": L/2, + "m": N, "n": N, "p": N, + + # Time + "dt": dt, "t_step_start": 0, "t_step_stop": Nt, "t_step_save": Ns, + + # Numerics + "model_eqns": 2, "num_fluids": 1, + "alt_soundspeed": "F", "mpp_lim": "F", "mixture_err": "F", + "time_stepper": 3, "weno_order": 5, + "weno_eps": 1.0e-16, "teno": "T", "teno_CT": 1e-8, + "null_weights": "F", "mp_weno": "F", + "riemann_solver": 2, "wave_speeds": 1, "avg_state": 2, + + # Outflow BCs on all faces + "bc_x%beg": -6, "bc_x%end": -6, + "bc_y%beg": -6, "bc_y%end": -6, + "bc_z%beg": -6, "bc_z%end": -6, + + # Output: binary format for the viz tool + "format": 2, "precision": 2, + "prim_vars_wrt": "T", "parallel_io": "T", + + # Patch 1 — low-pressure background (entire domain) + "num_patches": 2, + "patch_icpp(1)%geometry": 9, + "patch_icpp(1)%x_centroid": 0.0, + "patch_icpp(1)%y_centroid": 0.0, + "patch_icpp(1)%z_centroid": 0.0, + "patch_icpp(1)%length_x": L, + "patch_icpp(1)%length_y": L, + "patch_icpp(1)%length_z": L, + "patch_icpp(1)%vel(1)": 0.0, + "patch_icpp(1)%vel(2)": 0.0, + "patch_icpp(1)%vel(3)": 0.0, + "patch_icpp(1)%pres": p_lo, + "patch_icpp(1)%alpha_rho(1)": rho_lo, + "patch_icpp(1)%alpha(1)": 1.0, + + # Patch 2 — high-pressure sphere at origin + "patch_icpp(2)%geometry": 8, + "patch_icpp(2)%x_centroid": 0.0, + "patch_icpp(2)%y_centroid": 0.0, + "patch_icpp(2)%z_centroid": 0.0, + "patch_icpp(2)%radius": R0, + "patch_icpp(2)%alter_patch(1)": "T", + "patch_icpp(2)%vel(1)": 0.0, + "patch_icpp(2)%vel(2)": 0.0, + "patch_icpp(2)%vel(3)": 0.0, + "patch_icpp(2)%pres": p_hi, + "patch_icpp(2)%alpha_rho(1)": rho_hi, + "patch_icpp(2)%alpha(1)": 1.0, + + # Ideal gas fluid properties + "fluid_pp(1)%gamma": 1.0 / (gamma - 1.0), + "fluid_pp(1)%pi_inf": 0.0, +})) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 61bab588b3..c5c59ee079 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -527,6 +527,12 @@ def _tf(arr): return arr float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), ) fig.add_trace(trace) + # Compute aspect ratio from domain extents so slices (which + # have a constant coordinate on one axis) don't collapse that axis. + dx = float(ad.x_cc[-1] - ad.x_cc[0]) if len(ad.x_cc) > 1 else 1.0 + dy = float(ad.y_cc[-1] - ad.y_cc[0]) if len(ad.y_cc) > 1 else 1.0 + dz = float(ad.z_cc[-1] - ad.z_cc[0]) if len(ad.z_cc) > 1 else 1.0 + max_d = max(dx, dy, dz, 1e-30) fig.update_layout(scene=dict( xaxis=dict(title='x', backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), @@ -534,7 +540,9 @@ def _tf(arr): return arr gridcolor=_OVER, color=_TEXT), zaxis=dict(title='z', backgroundcolor=_SURF, gridcolor=_OVER, color=_TEXT), - bgcolor=_BG, aspectmode='data', + bgcolor=_BG, + aspectmode='manual', + aspectratio=dict(x=dx/max_d, y=dy/max_d, z=dz/max_d), )) elif ad.ndim == 2: From 197d1ca5141b6661ebd97af21553cc799b0df77d Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 21:55:53 -0500 Subject: [PATCH 028/102] Revert example changes from previous commit Restore 3D_acoustic_support3/case.py format back to 1 (silo_hdf5). Remove 3D_blast_wave/ example added for demo purposes. Co-Authored-By: Claude Sonnet 4.6 --- examples/3D_acoustic_support3/case.py | 2 +- examples/3D_blast_wave/case.py | 103 -------------------------- 2 files changed, 1 insertion(+), 104 deletions(-) delete mode 100644 examples/3D_blast_wave/case.py diff --git a/examples/3D_acoustic_support3/case.py b/examples/3D_acoustic_support3/case.py index 31f6a284d8..5548ce8399 100644 --- a/examples/3D_acoustic_support3/case.py +++ b/examples/3D_acoustic_support3/case.py @@ -46,7 +46,7 @@ "bc_z%beg": -6, "bc_z%end": -6, # Formatted Database Files Structure Parameters - "format": 2, + "format": 1, "precision": 2, "prim_vars_wrt": "T", "parallel_io": "T", diff --git a/examples/3D_blast_wave/case.py b/examples/3D_blast_wave/case.py deleted file mode 100644 index f9fc8b2fa5..0000000000 --- a/examples/3D_blast_wave/case.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -""" -3D spherical blast wave — Sod-like pressure jump on a sphere. -High-pressure sphere (p=1, rho=1) explodes into low-pressure gas (p=0.1, rho=0.125). -Produces a spherical shock front + contact discontinuity + rarefaction fan. - -Grid: 63×63×63 (64 cells/dim). -MPI: 8 ranks (2×2×2) → 32 cells/rank/dim ≥ 25 (WENO5 minimum). -""" -import json -import math - -# Domain: unit cube centred at origin -L = 1.0 -N = 63 # m=n=p → m+1=64 - -# Ideal gas γ=1.4 -gamma = 1.4 - -# Inside sphere (r < R0) -rho_hi, p_hi = 1.0, 1.0 -# Outside sphere -rho_lo, p_lo = 0.125, 0.1 -R0 = 0.15 # sphere radius - -# Shock speed estimate (Rankine-Hugoniot) for CFL -c_lo = math.sqrt(gamma * p_lo / rho_lo) # ~1.058 -Ms = math.sqrt((gamma + 1) / (2 * gamma) * (p_hi / p_lo) - + (gamma - 1) / (2 * gamma)) # ~2.0 -v_shock = Ms * c_lo # ~2.12 -c_hi = math.sqrt(gamma * p_hi / rho_hi) # ~1.183 -max_wave = max(v_shock, c_hi) # ~2.12 - -dx = L / (N + 1) # ~0.01563 -dt = 0.4 * dx / max_wave # ~0.00295 (CFL=0.4) - -Nt = 100 # simulate until t ≈ 0.295 s — shock reaches r ≈ 0.63 -Ns = 10 # save every 10 steps → 11 frames - -print(json.dumps({ - # Logistics - "run_time_info": "T", - - # Domain - "x_domain%beg": -L/2, "x_domain%end": L/2, - "y_domain%beg": -L/2, "y_domain%end": L/2, - "z_domain%beg": -L/2, "z_domain%end": L/2, - "m": N, "n": N, "p": N, - - # Time - "dt": dt, "t_step_start": 0, "t_step_stop": Nt, "t_step_save": Ns, - - # Numerics - "model_eqns": 2, "num_fluids": 1, - "alt_soundspeed": "F", "mpp_lim": "F", "mixture_err": "F", - "time_stepper": 3, "weno_order": 5, - "weno_eps": 1.0e-16, "teno": "T", "teno_CT": 1e-8, - "null_weights": "F", "mp_weno": "F", - "riemann_solver": 2, "wave_speeds": 1, "avg_state": 2, - - # Outflow BCs on all faces - "bc_x%beg": -6, "bc_x%end": -6, - "bc_y%beg": -6, "bc_y%end": -6, - "bc_z%beg": -6, "bc_z%end": -6, - - # Output: binary format for the viz tool - "format": 2, "precision": 2, - "prim_vars_wrt": "T", "parallel_io": "T", - - # Patch 1 — low-pressure background (entire domain) - "num_patches": 2, - "patch_icpp(1)%geometry": 9, - "patch_icpp(1)%x_centroid": 0.0, - "patch_icpp(1)%y_centroid": 0.0, - "patch_icpp(1)%z_centroid": 0.0, - "patch_icpp(1)%length_x": L, - "patch_icpp(1)%length_y": L, - "patch_icpp(1)%length_z": L, - "patch_icpp(1)%vel(1)": 0.0, - "patch_icpp(1)%vel(2)": 0.0, - "patch_icpp(1)%vel(3)": 0.0, - "patch_icpp(1)%pres": p_lo, - "patch_icpp(1)%alpha_rho(1)": rho_lo, - "patch_icpp(1)%alpha(1)": 1.0, - - # Patch 2 — high-pressure sphere at origin - "patch_icpp(2)%geometry": 8, - "patch_icpp(2)%x_centroid": 0.0, - "patch_icpp(2)%y_centroid": 0.0, - "patch_icpp(2)%z_centroid": 0.0, - "patch_icpp(2)%radius": R0, - "patch_icpp(2)%alter_patch(1)": "T", - "patch_icpp(2)%vel(1)": 0.0, - "patch_icpp(2)%vel(2)": 0.0, - "patch_icpp(2)%vel(3)": 0.0, - "patch_icpp(2)%pres": p_hi, - "patch_icpp(2)%alpha_rho(1)": rho_hi, - "patch_icpp(2)%alpha(1)": 1.0, - - # Ideal gas fluid properties - "fluid_pp(1)%gamma": 1.0 / (gamma - 1.0), - "fluid_pp(1)%pi_inf": 0.0, -})) From b66dbda0bbba54bb4efdd81138e0fabaee401519 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 21:59:33 -0500 Subject: [PATCH 029/102] Add variable picker to interactive viz UI - Sidebar now has a Variable dropdown listing all available fields; switching it live re-renders the plot with auto-ranged color scale. - --var is now optional in --interactive mode (defaults to first available variable so ./mfc.sh viz --interactive just works). - read_step always loads all variables in interactive mode so the in-server cache serves any variable without re-reading files. - vmin/vmax inputs are cleared automatically when the variable changes. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 42 ++++++++++++++++++++------------ toolchain/mfc/viz/viz.py | 34 +++++++++++++------------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index c5c59ee079..5e41ece0e8 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -208,15 +208,15 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements suppress_callback_exceptions=True, ) - # Load first step to know dimensionality and initial range + # Load first step to know dimensionality and available variables init = _load(steps[0], read_func) ndim = init.ndim - d0 = init.variables[varname] - pos0 = d0[d0 > 0] if np.any(d0 > 0) else d0 - init_min = float(np.nanmin(pos0)) - init_max = float(np.nanmax(d0)) + all_varnames = sorted(init.variables.keys()) + if varname not in all_varnames: + varname = all_varnames[0] if all_varnames else varname step_opts = [{'label': str(s), 'value': s} for s in steps] + var_opts = [{'label': v, 'value': v} for v in all_varnames] cmap_opts = [{'label': c, 'value': c} for c in _CMAPS] if ndim == 3: @@ -241,10 +241,19 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements 'fontSize': '16px', 'fontWeight': 'bold', 'color': _ACCENT, }), html.Div( - f'var: {varname} · {ndim}D · {len(steps)} step{"s" if len(steps) != 1 else ""}', + f'{ndim}D · {len(steps)} step{"s" if len(steps) != 1 else ""}', style={'fontSize': '11px', 'color': _MUTED}, ), + # ── Variable ────────────────────────────────────────────────── + _section('Variable', + dcc.Dropdown( + id='var-sel', options=var_opts, value=varname, clearable=False, + style={'fontSize': '12px', 'backgroundColor': _OVER, + 'border': f'1px solid {_BORD}'}, + ), + ), + # ── Timestep ────────────────────────────────────────────────── _section('Timestep', dcc.Dropdown( @@ -454,14 +463,16 @@ def _toggle_controls(mode): Output('vmin-inp', 'value'), Output('vmax-inp', 'value'), Input('reset-btn', 'n_clicks'), + Input('var-sel', 'value'), prevent_initial_call=True, ) - def _reset_range(_): + def _reset_range(_reset, _var): return None, None @app.callback( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements Output('viz-graph', 'figure'), Output('status-bar', 'children'), + Input('var-sel', 'value'), Input('step-sel', 'value'), Input('mode-sel', 'value'), Input('slice-axis', 'value'), @@ -479,14 +490,15 @@ def _reset_range(_): Input('vmin-inp', 'value'), Input('vmax-inp', 'value'), ) - def _update(step, mode, + def _update(var_sel, step, mode, slice_axis, slice_pos, iso_min_frac, iso_max_frac, iso_n, iso_caps, vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac, cmap, log_chk, vmin_in, vmax_in): + selected_var = var_sel or varname ad = _load(step, read_func) - raw = ad.variables[varname] + raw = ad.variables[selected_var] log = bool(log_chk and 'log' in log_chk) cmap = cmap or 'viridis' @@ -508,18 +520,18 @@ def _tf(arr): return np.where(arr > 0, np.log10(np.maximum(arr, 1e-300)), np.nan) cmin = float(np.log10(max(vmin, 1e-300))) cmax = float(np.log10(max(vmax, 1e-300))) - cbar_title = f'log\u2081\u2080({varname})' + cbar_title = f'log\u2081\u2080({selected_var})' else: def _tf(arr): return arr cmin, cmax = vmin, vmax - cbar_title = varname + cbar_title = selected_var fig = go.Figure() title = '' if ad.ndim == 3: trace, title = _build_3d( - ad, raw, varname, step, mode, cmap, _tf, cmin, cmax, cbar_title, + ad, raw, selected_var, step, mode, cmap, _tf, cmin, cmax, cbar_title, slice_axis or 'z', float(slice_pos or 0.5), float(iso_min_frac or 0.2), float(iso_max_frac or 0.8), int(iso_n or 3), bool(iso_caps and 'caps' in iso_caps), @@ -557,13 +569,13 @@ def _tf(arr): return arr yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER), plot_bgcolor=_BG, ) - title = f'{varname} · step {step}' + title = f'{selected_var} · step {step}' else: # 1D plot_y = _tf(raw) if log else raw fig.add_trace(go.Scatter( x=ad.x_cc, y=plot_y, mode='lines', - line=dict(color=_ACCENT, width=2), name=varname, + line=dict(color=_ACCENT, width=2), name=selected_var, )) fig.update_layout( xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER), @@ -571,7 +583,7 @@ def _tf(arr): return arr range=[cmin, cmax] if (vmin_in or vmax_in) else None), plot_bgcolor=_BG, ) - title = f'{varname} · step {step}' + title = f'{selected_var} · step {step}' fig.update_layout( title=dict(text=title, font=dict(color=_TEXT, size=13, family='monospace')), diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 7acc606274..55ea23a07e 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -71,7 +71,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f"[bold]Format:[/bold] {fmt}") # Quick guide when no action is specified - if not ARG('list_steps') and not ARG('list_vars') and ARG('var') is None: + if not ARG('list_steps') and not ARG('list_vars') and ARG('var') is None \ + and not ARG('interactive'): cons.print() d = case_dir cons.print("[bold]Quick start:[/bold]") @@ -141,7 +142,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc varname = ARG('var') step_arg = ARG('step') - if varname is None: + if varname is None and not ARG('interactive'): raise MFCException("--var is required for rendering. " "Use --list-vars to see available variables.") @@ -190,31 +191,30 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc if slice_value is not None: render_opts['slice_value'] = float(slice_value) - # Choose read function based on format - def read_step(step): - if fmt == 'silo': - from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel - return assemble_silo(case_dir, step, var=varname) - return assemble(case_dir, step, fmt, var=varname) + interactive = ARG('interactive') - # Validate variable name by reading the first timestep (without var filter) - def read_step_all_vars(step): + # Interactive mode always loads all variables (user can switch in UI). + # Non-interactive mode can filter to just the requested variable for speed. + def read_step(step): if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel - return assemble_silo(case_dir, step) - return assemble(case_dir, step, fmt) + return assemble_silo(case_dir, step, var=None if interactive else varname) + return assemble(case_dir, step, fmt, var=None if interactive else varname) - test_assembled = read_step_all_vars(requested_steps[0]) - if varname not in test_assembled.variables: - avail = sorted(test_assembled.variables.keys()) + # Validate variable name / discover available variables + test_assembled = read_step(requested_steps[0]) + avail = sorted(test_assembled.variables.keys()) + if not interactive and varname not in test_assembled.variables: raise MFCException(f"Variable '{varname}' not found. " f"Available variables: {', '.join(avail)}") # Interactive mode — launch Dash web server - if ARG('interactive'): + if interactive: from .interactive import run_interactive # pylint: disable=import-outside-toplevel port = ARG('port') or 8050 - run_interactive(varname, requested_steps, read_step, port=int(port)) + # Default to first available variable if --var was not specified + init_var = varname if varname in avail else (avail[0] if avail else None) + run_interactive(init_var, requested_steps, read_step, port=int(port)) return # Create output directory From 3e0eb8d78d5681d74b7efcb2c1ff6450fe3ed74f Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Tue, 24 Feb 2026 22:50:51 -0500 Subject: [PATCH 030/102] Fix precheck failures in interactive.py Add module-level use-dict-literal pylint disable (Plotly API uses dict() idiomatically), move too-many-arguments disable from decorator to def line so pylint sees it, rename _BORD to _BORDER to pass spell check. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 5e41ece0e8..c77015eb73 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -6,6 +6,7 @@ controls for slice position, isosurface thresholds, volume opacity, colormap, log scale, vmin/vmax, and timestep playback. """ +# pylint: disable=use-dict-literal from typing import List, Callable @@ -37,7 +38,7 @@ _BG = '#181825' _SURF = '#1e1e2e' _OVER = '#313244' -_BORD = '#45475a' +_BORDER = '#45475a' _TEXT = '#cdd6f4' _SUB = '#a6adc8' _MUTED = '#6c7086' @@ -83,7 +84,7 @@ def _lbl(text): }) -def _slider(sid, lo, hi, step, val, marks=None): +def _slider(sid, lo, hi, step, val, marks=None): # pylint: disable=too-many-arguments,too-many-positional-arguments return dcc.Slider( id=sid, min=lo, max=hi, step=step, value=val, marks=marks or {}, updatemode='mouseup', @@ -95,7 +96,7 @@ def _btn(bid, label, color=_TEXT): return html.Button(label, id=bid, n_clicks=0, style={ 'flex': '1', 'padding': '5px 8px', 'fontSize': '12px', 'backgroundColor': _OVER, 'color': color, - 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', 'cursor': 'pointer', 'fontFamily': 'monospace', }) @@ -105,7 +106,7 @@ def _num(sid, placeholder='auto'): id=sid, type='number', placeholder=placeholder, debounce=True, style={ 'width': '100%', 'backgroundColor': _OVER, 'color': _TEXT, - 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', 'padding': '4px 6px', 'fontSize': '12px', 'fontFamily': 'monospace', 'boxSizing': 'border-box', }, @@ -250,7 +251,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements dcc.Dropdown( id='var-sel', options=var_opts, value=varname, clearable=False, style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORD}'}, + 'border': f'1px solid {_BORDER}'}, ), ), @@ -259,7 +260,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements dcc.Dropdown( id='step-sel', options=step_opts, value=steps[0], clearable=False, style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORD}'}, + 'border': f'1px solid {_BORDER}'}, ), html.Div([ _btn('play-btn', '▶ Play', _GREEN), @@ -346,7 +347,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements dcc.Dropdown( id='cmap-sel', options=cmap_opts, value='viridis', clearable=False, style={'fontSize': '12px', 'backgroundColor': _OVER, - 'border': f'1px solid {_BORD}'}, + 'border': f'1px solid {_BORDER}'}, ), dcc.Checklist( id='log-chk', @@ -362,7 +363,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements html.Button('↺ Auto range', id='reset-btn', n_clicks=0, style={ 'marginTop': '8px', 'padding': '4px 8px', 'fontSize': '11px', 'width': '100%', 'backgroundColor': _OVER, 'color': _TEAL, - 'border': f'1px solid {_BORD}', 'borderRadius': '4px', + 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', 'cursor': 'pointer', 'fontFamily': 'monospace', }), ), @@ -469,7 +470,7 @@ def _toggle_controls(mode): def _reset_range(_reset, _var): return None, None - @app.callback( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements + @app.callback( Output('viz-graph', 'figure'), Output('status-bar', 'children'), Input('var-sel', 'value'), @@ -490,7 +491,7 @@ def _reset_range(_reset, _var): Input('vmin-inp', 'value'), Input('vmax-inp', 'value'), ) - def _update(var_sel, step, mode, + def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements slice_axis, slice_pos, iso_min_frac, iso_max_frac, iso_n, iso_caps, vol_opacity, vol_nsurf, vol_min_frac, vol_max_frac, From 7ef8823b0364fbbbd750796b829a86921b102658 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Wed, 25 Feb 2026 20:48:02 -0500 Subject: [PATCH 031/102] Add tiled 1D rendering, grid lines, and adaptive 2D figsize - Omitting --var (or passing --var all) renders all 1D variables in a tiled subplot grid. 2D/3D still requires an explicit --var. - 1D plots now have grid lines and smart scientific notation on y-axis. - 2D/3D figure size auto-adapts to domain aspect ratio instead of using a fixed (10, 8). Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/cli/commands.py | 2 +- toolchain/mfc/viz/renderer.py | 90 +++++++++++++++++++++++++++++++---- toolchain/mfc/viz/viz.py | 43 ++++++++++------- 3 files changed, 109 insertions(+), 26 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index becea9c69a..1819d60af0 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -875,7 +875,7 @@ arguments=[ Argument( name="var", - help="Variable name to visualize (e.g. pres, rho, schlieren).", + help="Variable name to visualize (e.g. pres, rho). Omit or pass 'all' for tiled 1D plots.", type=str, default=None, metavar="VAR", diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index b129f2e207..14134e3a42 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -13,6 +13,8 @@ import imageio.v2 as imageio +import math + import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # pylint: disable=wrong-import-position @@ -26,6 +28,8 @@ def render_1d(x_cc, data, varname, step, output, **opts): # pylint: disable=too ax.set_xlabel('x') ax.set_ylabel(varname) ax.set_title(f'{varname} (step {step})') + ax.grid(True, alpha=0.3) + ax.ticklabel_format(axis='y', style='sci', scilimits=(-3, 4), useMathText=True) vmin = opts.get('vmin') vmax = opts.get('vmax') @@ -37,9 +41,65 @@ def render_1d(x_cc, data, varname, step, output, **opts): # pylint: disable=too plt.close(fig) +def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=too-many-locals + """Render all 1D variables in a tiled subplot grid and save as PNG.""" + varnames = sorted(variables.keys()) + n = len(varnames) + if n == 0: + return + if n == 1: + render_1d(x_cc, variables[varnames[0]], varnames[0], step, output, **opts) + return + + ncols = 2 if n <= 8 else 3 + nrows = math.ceil(n / ncols) + fig_w = 5 * ncols + fig_h = 2.8 * nrows + fig, axes = plt.subplots(nrows, ncols, + figsize=opts.get('figsize', (fig_w, fig_h)), + sharex=True, squeeze=False) + + for idx, vn in enumerate(varnames): + row, col = divmod(idx, ncols) + ax = axes[row][col] + ax.plot(x_cc, variables[vn], linewidth=1.2) + ax.set_ylabel(vn, fontsize=9) + ax.tick_params(labelsize=8) + ax.grid(True, alpha=0.3) + + # Hide unused subplots + for idx in range(n, nrows * ncols): + row, col = divmod(idx, ncols) + axes[row][col].set_visible(False) + + # X-label only on bottom row + for col in range(ncols): + bottom_row = min(nrows - 1, (n - 1) // ncols) if col < (n % ncols or ncols) else nrows - 2 + axes[bottom_row][col].set_xlabel('x', fontsize=9) + + fig.suptitle(f'step {step}', fontsize=11, y=0.99) + fig.tight_layout(rect=[0, 0, 1, 0.97]) + fig.savefig(output, dpi=opts.get('dpi', 150)) + plt.close(fig) + + +def _figsize_for_domain(x_cc, y_cc, base=10): + """Compute figure size that matches the physical domain aspect ratio.""" + dx = float(x_cc[-1] - x_cc[0]) if len(x_cc) > 1 else 1.0 + dy = float(y_cc[-1] - y_cc[0]) if len(y_cc) > 1 else 1.0 + aspect = dy / dx if dx > 0 else 1.0 + # Clamp to avoid extremely tall/wide figures + aspect = max(0.2, min(aspect, 5.0)) + # Extra width for colorbar + fig_w = base + 1.5 + fig_h = max(base * aspect, 3.0) + return (fig_w, fig_h) + + def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals """Render a 2D colormap via pcolormesh and save as PNG.""" - fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 8))) + default_size = _figsize_for_domain(x_cc, y_cc) + fig, ax = plt.subplots(figsize=opts.get('figsize', default_size)) cmap = opts.get('cmap', 'viridis') vmin = opts.get('vmin') @@ -109,7 +169,8 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: x_plot, y_plot = assembled.x_cc, assembled.y_cc xlabel, ylabel = 'x', 'y' - fig, ax = plt.subplots(figsize=opts.get('figsize', (10, 8))) + default_size = _figsize_for_domain(x_plot, y_plot) + fig, ax = plt.subplots(figsize=opts.get('figsize', default_size)) cmap = opts.get('cmap', 'viridis') vmin = opts.get('vmin') @@ -144,16 +205,17 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements - read_func=None, **opts): + read_func=None, tiled=False, **opts): """ Generate an MP4 video by iterating over timesteps. Args: - varname: Variable name to plot. + varname: Variable name to plot (ignored when tiled=True). steps: List of timestep integers. output: Output MP4 file path. fps: Frames per second. read_func: Callable(step) -> AssembledData for loading each frame. + tiled: If True, render all 1D variables in a tiled layout per frame. **opts: Rendering options (cmap, vmin, vmax, dpi, log_scale, figsize, slice_axis, slice_index, slice_value). @@ -170,10 +232,11 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum opts = dict(opts) # avoid mutating the caller's dict # Pre-compute vmin/vmax from first, middle, and last frames if not provided + # (not needed for tiled mode — each subplot auto-scales independently) auto_vmin = opts.get('vmin') auto_vmax = opts.get('vmax') - if auto_vmin is None or auto_vmax is None: + if not tiled and (auto_vmin is None or auto_vmax is None): sample_steps = [steps[0]] if len(steps) > 1: sample_steps.append(steps[-1]) @@ -213,19 +276,28 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum for i, step in enumerate(step_iter): assembled = read_func(step) - var_data = assembled.variables.get(varname) - if var_data is None: - continue frame_path = os.path.join(viz_dir, f'{i:06d}.png') - if assembled.ndim == 1: + if tiled and assembled.ndim == 1: + render_1d_tiled(assembled.x_cc, assembled.variables, + step, frame_path, **opts) + elif assembled.ndim == 1: + var_data = assembled.variables.get(varname) + if var_data is None: + continue render_1d(assembled.x_cc, var_data, varname, step, frame_path, **opts) elif assembled.ndim == 2: + var_data = assembled.variables.get(varname) + if var_data is None: + continue render_2d(assembled.x_cc, assembled.y_cc, var_data, varname, step, frame_path, **opts) elif assembled.ndim == 3: + var_data = assembled.variables.get(varname) + if var_data is None: + continue render_3d_slice(assembled, varname, step, frame_path, **opts) else: raise ValueError( diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 55ea23a07e..903dee7f2a 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -45,7 +45,7 @@ def _parse_steps(step_arg, available_steps): def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches """Main viz command dispatcher.""" from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel - from .renderer import render_1d, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel + from .renderer import render_1d, render_1d_tiled, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel case_dir = ARG('input') if case_dir is None: @@ -72,7 +72,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc # Quick guide when no action is specified if not ARG('list_steps') and not ARG('list_vars') and ARG('var') is None \ - and not ARG('interactive'): + and not ARG('interactive') and ARG('step') is None: cons.print() d = case_dir cons.print("[bold]Quick start:[/bold]") @@ -138,13 +138,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f" {vn:<20s} min={data.min():.6g} max={data.max():.6g}") return - # For rendering, --var and --step are required + # For rendering, --step is required; --var is optional for 1D (shows all) varname = ARG('var') step_arg = ARG('step') - - if varname is None and not ARG('interactive'): - raise MFCException("--var is required for rendering. " - "Use --list-vars to see available variables.") + tiled = varname is None or varname == 'all' if step_arg is None: if ARG('interactive'): @@ -193,18 +190,26 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc interactive = ARG('interactive') - # Interactive mode always loads all variables (user can switch in UI). - # Non-interactive mode can filter to just the requested variable for speed. + # Load all variables when tiled or interactive; filter otherwise. + load_all = tiled or interactive + def read_step(step): if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel - return assemble_silo(case_dir, step, var=None if interactive else varname) - return assemble(case_dir, step, fmt, var=None if interactive else varname) + return assemble_silo(case_dir, step, var=None if load_all else varname) + return assemble(case_dir, step, fmt, var=None if load_all else varname) # Validate variable name / discover available variables test_assembled = read_step(requested_steps[0]) avail = sorted(test_assembled.variables.keys()) - if not interactive and varname not in test_assembled.variables: + + # Tiled mode only works for 1D + if tiled and not interactive: + if test_assembled.ndim != 1: + raise MFCException("--var is required for 2D/3D rendering. " + "Use --list-vars to see available variables.") + + if not tiled and not interactive and varname not in test_assembled.variables: raise MFCException(f"Variable '{varname}' not found. " f"Available variables: {', '.join(avail)}") @@ -226,10 +231,12 @@ def read_step(step): # MP4 mode if ARG('mp4'): fps = ARG('fps') or 10 - mp4_path = os.path.join(output_base, f'{varname}.mp4') + label = 'all' if tiled else varname + mp4_path = os.path.join(output_base, f'{label}.mp4') cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") success = render_mp4(varname, requested_steps, mp4_path, - fps=int(fps), read_func=read_step, **render_opts) + fps=int(fps), read_func=read_step, + tiled=tiled, **render_opts) if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") else: @@ -245,6 +252,7 @@ def read_step(step): step_iter = requested_steps failures = [] + label = 'all' if tiled else varname for step in step_iter: try: assembled = read_step(step) @@ -253,9 +261,12 @@ def read_step(step): failures.append(step) continue - output_path = os.path.join(output_base, f'{varname}_{step}.png') + output_path = os.path.join(output_base, f'{label}_{step}.png') - if assembled.ndim == 1: + if tiled and assembled.ndim == 1: + render_1d_tiled(assembled.x_cc, assembled.variables, + step, output_path, **render_opts) + elif assembled.ndim == 1: render_1d(assembled.x_cc, assembled.variables[varname], varname, step, output_path, **render_opts) elif assembled.ndim == 2: From e5c9c1ab59abf4b90c9c7430cda6654109984db9 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Wed, 25 Feb 2026 21:10:58 -0500 Subject: [PATCH 032/102] Use LaTeX-style fonts and math-mode axis labels in plots Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 14134e3a42..f0d7c24eb8 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -6,6 +6,7 @@ for headless rendering. """ +import math import os import tempfile @@ -13,19 +14,22 @@ import imageio.v2 as imageio -import math - import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt # pylint: disable=wrong-import-position from matplotlib.colors import LogNorm # pylint: disable=wrong-import-position +matplotlib.rcParams.update({ + 'mathtext.fontset': 'cm', + 'font.family': 'serif', +}) + 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))) ax.plot(x_cc, data, linewidth=1.5) - ax.set_xlabel('x') + ax.set_xlabel(r'$x$') ax.set_ylabel(varname) ax.set_title(f'{varname} (step {step})') ax.grid(True, alpha=0.3) @@ -75,7 +79,7 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t # X-label only on bottom row for col in range(ncols): bottom_row = min(nrows - 1, (n - 1) // ncols) if col < (n % ncols or ncols) else nrows - 2 - axes[bottom_row][col].set_xlabel('x', fontsize=9) + axes[bottom_row][col].set_xlabel(r'$x$', fontsize=9) fig.suptitle(f'step {step}', fontsize=11, y=0.99) fig.tight_layout(rect=[0, 0, 1, 0.97]) @@ -122,8 +126,8 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') fig.colorbar(pcm, ax=ax, label=varname) - ax.set_xlabel('x') - ax.set_ylabel('y') + ax.set_xlabel(r'$x$') + ax.set_ylabel(r'$y$') ax.set_title(f'{varname} (step {step})') ax.set_aspect('equal', adjustable='box') @@ -159,15 +163,15 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: if axis_idx == 0: sliced = data_3d[idx, :, :] x_plot, y_plot = assembled.y_cc, assembled.z_cc - xlabel, ylabel = 'y', 'z' + xlabel, ylabel = r'$y$', r'$z$' elif axis_idx == 1: sliced = data_3d[:, idx, :] x_plot, y_plot = assembled.x_cc, assembled.z_cc - xlabel, ylabel = 'x', 'z' + xlabel, ylabel = r'$x$', r'$z$' else: sliced = data_3d[:, :, idx] x_plot, y_plot = assembled.x_cc, assembled.y_cc - xlabel, ylabel = 'x', 'y' + xlabel, ylabel = r'$x$', r'$y$' default_size = _figsize_for_domain(x_plot, y_plot) fig, ax = plt.subplots(figsize=opts.get('figsize', default_size)) @@ -179,7 +183,8 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: norm = None if log_scale: - lo = vmin if vmin is not None else np.nanmin(sliced[sliced > 0]) if np.any(sliced > 0) else 1e-10 + pos = sliced[sliced > 0] + lo = vmin if vmin is not None else np.nanmin(pos) if pos.size > 0 else 1e-10 hi = vmax if vmax is not None else np.nanmax(sliced) if not np.isfinite(hi) or hi <= 0: hi = 1.0 @@ -204,7 +209,7 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: plt.close(fig) -def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements +def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches read_func=None, tiled=False, **opts): """ Generate an MP4 video by iterating over timesteps. From 3d10bb9fa61e678a5ae9a78429142cd31751e59d Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Wed, 25 Feb 2026 21:29:30 -0500 Subject: [PATCH 033/102] Validate timestep in --list-vars before assembly Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/viz.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 903dee7f2a..fcce066dbe 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -124,6 +124,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc except ValueError as exc: raise MFCException(f"Invalid --step value '{step_arg}'. " "Expected an integer or 'all'.") from exc + if step not in steps: + raise MFCException( + f"Timestep {step} not found. Available range: " + f"{steps[0]} to {steps[-1]} ({len(steps)} timesteps)") if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel From db19ff4392cd58ee2800cd58c14c78df761f4f68 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Wed, 25 Feb 2026 21:35:02 -0500 Subject: [PATCH 034/102] Add LaTeX label lookup for MFC variable names in plots Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/renderer.py | 73 +++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index f0d7c24eb8..4a5ad7a935 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -8,6 +8,7 @@ import math import os +import re import tempfile import numpy as np @@ -24,14 +25,70 @@ 'font.family': 'serif', }) +# LaTeX-style labels for known MFC variable names +_LABEL_MAP = { + 'pres': r'$p$', + 'rho': r'$\rho$', + 'E': r'$E$', + 'T': r'$T$', + 'D': r'$D$', + 'c': r'$c$', + 'gamma': r'$\gamma$', + 'pi_inf': r'$\pi_\infty$', + 'pres_inf': r'$p_\infty$', + 'heat_ratio': r'$\gamma$', + 'schlieren': r'$|\nabla \rho|$', + 'psi': r'$\psi$', + 'n': r'$n$', + 'qm': r'$q_m$', + 'Bx': r'$B_x$', 'By': r'$B_y$', 'Bz': r'$B_z$', + 'voidFraction': r'void fraction', + 'liutex_mag': r'$|\lambda|$', + 'damage_state': r'damage', +} + +_INDEXED_PATTERNS = [ + (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)}$'), + (r'^mom(\d+)$', lambda m: rf'$\rho {["u", "v", "w"][int(m.group(1)) - 1]}$' + if int(m.group(1)) <= 3 else rf'$m_{m.group(1)}$'), + (r'^alpha(\d+)$', lambda m: rf'$\alpha_{m.group(1)}$'), + (r'^alpha_rho(\d+)$', lambda m: rf'$\alpha_{m.group(1)}\rho_{m.group(1)}$'), + (r'^alpha_rho_e(\d+)$', lambda m: rf'$\alpha_{m.group(1)}\rho_{m.group(1)}e_{m.group(1)}$'), + (r'^omega(\d+)$', lambda m: rf'$\omega_{m.group(1)}$'), + (r'^tau(\d+)$', lambda m: rf'$\tau_{m.group(1)}$'), + (r'^xi(\d+)$', lambda m: rf'$\xi_{m.group(1)}$'), + (r'^flux(\d+)$', lambda m: rf'$F_{m.group(1)}$'), + (r'^liutex_axis(\d+)$', lambda m: rf'$\lambda_{m.group(1)}$'), + (r'^rho(\d+)$', lambda m: rf'$\rho_{m.group(1)}$'), + (r'^Y_(.+)$', lambda m: rf'$Y_{{\mathrm{{{m.group(1)}}}}}$'), + (r'^nR(\d+)$', lambda m: rf'$nR_{{{m.group(1)}}}$'), + (r'^nV(\d+)$', lambda m: rf'$nV_{{{m.group(1)}}}$'), + (r'^nP(\d+)$', lambda m: rf'$nP_{{{m.group(1)}}}$'), + (r'^nM(\d+)$', lambda m: rf'$nM_{{{m.group(1)}}}$'), + (r'^color_function(\d+)$', lambda m: rf'color $f_{m.group(1)}$'), +] + + +def pretty_label(varname): + """Map an MFC variable name to a LaTeX-style label for plots.""" + if varname in _LABEL_MAP: + return _LABEL_MAP[varname] + for pattern, formatter in _INDEXED_PATTERNS: + m = re.match(pattern, varname) + if m: + return formatter(m) + return varname + 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(varname) - ax.set_title(f'{varname} (step {step})') + 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) @@ -67,7 +124,7 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t row, col = divmod(idx, ncols) ax = axes[row][col] ax.plot(x_cc, variables[vn], linewidth=1.2) - ax.set_ylabel(vn, fontsize=9) + ax.set_ylabel(pretty_label(vn), fontsize=9) ax.tick_params(labelsize=8) ax.grid(True, alpha=0.3) @@ -125,10 +182,11 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab # data shape is (nx, ny), pcolormesh expects (ny, nx) when using x_cc, y_cc pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') - fig.colorbar(pcm, ax=ax, label=varname) + label = pretty_label(varname) + fig.colorbar(pcm, ax=ax, label=label) ax.set_xlabel(r'$x$') ax.set_ylabel(r'$y$') - ax.set_title(f'{varname} (step {step})') + ax.set_title(f'{label} (step {step})') ax.set_aspect('equal', adjustable='box') fig.tight_layout() @@ -197,11 +255,12 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: # sliced shape depends on axis: need to transpose appropriately pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') - fig.colorbar(pcm, ax=ax, label=varname) + label = pretty_label(varname) + fig.colorbar(pcm, ax=ax, label=label) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) slice_coord = coord_along[idx] - ax.set_title(f'{varname} (step {step}, {slice_axis}={slice_coord:.4g})') + ax.set_title(f'{label} (step {step}, {slice_axis}={slice_coord:.4g})') ax.set_aspect('equal', adjustable='box') fig.tight_layout() From 499274c77fff035331ddd661fc8a8695fc88bfe1 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Wed, 25 Feb 2026 22:35:50 -0500 Subject: [PATCH 035/102] Add viz unit tests with checked-in 1D/2D/3D fixture data 37 tests covering step parsing, label formatting, format/timestep discovery, binary and silo readers (1D/2D/3D), binary-silo consistency, and rendering (1D, 2D, 3D slice). Fixture data generated from minimal MFC runs (m=15, MUSCL) in both binary and silo formats (~1MB total). Also adds --step last support and updates visualization docs with tiled rendering, interactive mode, and LaTeX label sections. Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 44 +- .../viz/fixtures/1d_binary/binary/p0/0.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_binary/binary/p0/1.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_binary/binary/p0/2.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_binary/binary/root/0.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_binary/binary/root/1.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_binary/binary/root/2.dat | Bin 0 -> 912 bytes .../viz/fixtures/1d_silo/silo_hdf5/p0/0.silo | Bin 0 -> 9751 bytes .../viz/fixtures/1d_silo/silo_hdf5/p0/1.silo | Bin 0 -> 9751 bytes .../viz/fixtures/1d_silo/silo_hdf5/p0/2.silo | Bin 0 -> 9751 bytes .../1d_silo/silo_hdf5/root/collection_0.silo | Bin 0 -> 12675 bytes .../1d_silo/silo_hdf5/root/collection_1.silo | Bin 0 -> 12675 bytes .../1d_silo/silo_hdf5/root/collection_2.silo | Bin 0 -> 12675 bytes .../viz/fixtures/2d_binary/binary/p0/0.dat | Bin 0 -> 10834 bytes .../viz/fixtures/2d_binary/binary/p0/1.dat | Bin 0 -> 10834 bytes .../viz/fixtures/2d_silo/silo_hdf5/p0/0.silo | Bin 0 -> 21474 bytes .../viz/fixtures/2d_silo/silo_hdf5/p0/1.silo | Bin 0 -> 21474 bytes .../2d_silo/silo_hdf5/root/collection_0.silo | Bin 0 -> 13864 bytes .../2d_silo/silo_hdf5/root/collection_1.silo | Bin 0 -> 13864 bytes .../viz/fixtures/3d_binary/binary/p0/0.dat | Bin 0 -> 197396 bytes .../viz/fixtures/3d_binary/binary/p0/1.dat | Bin 0 -> 197396 bytes .../viz/fixtures/3d_silo/silo_hdf5/p0/0.silo | Bin 0 -> 210944 bytes .../viz/fixtures/3d_silo/silo_hdf5/p0/1.silo | Bin 0 -> 210944 bytes .../3d_silo/silo_hdf5/root/collection_0.silo | Bin 0 -> 15063 bytes .../3d_silo/silo_hdf5/root/collection_1.silo | Bin 0 -> 15063 bytes toolchain/mfc/viz/test_viz.py | 388 ++++++++++++++++++ toolchain/mfc/viz/viz.py | 14 +- 27 files changed, 440 insertions(+), 6 deletions(-) create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/p0/0.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/p0/1.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/p0/2.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/root/0.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/root/1.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary/binary/root/2.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/0.silo create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/1.silo create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/2.silo create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_0.silo create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_1.silo create mode 100644 toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_2.silo create mode 100644 toolchain/mfc/viz/fixtures/2d_binary/binary/p0/0.dat create mode 100644 toolchain/mfc/viz/fixtures/2d_binary/binary/p0/1.dat create mode 100644 toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/0.silo create mode 100644 toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/1.silo create mode 100644 toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_0.silo create mode 100644 toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_1.silo create mode 100644 toolchain/mfc/viz/fixtures/3d_binary/binary/p0/0.dat create mode 100644 toolchain/mfc/viz/fixtures/3d_binary/binary/p0/1.dat create mode 100644 toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/0.silo create mode 100644 toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/1.silo create mode 100644 toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_0.silo create mode 100644 toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_1.silo create mode 100644 toolchain/mfc/viz/test_viz.py diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index da8587fc03..413bbe91dc 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -14,8 +14,8 @@ MFC includes a built-in visualization command that renders images and videos dir ### Basic usage ```bash -# Plot pressure at timestep 1000 -./mfc.sh viz case_dir/ --var pres --step 1000 +# Plot pressure at the last available timestep +./mfc.sh viz case_dir/ --var pres --step last # Plot density at all available timesteps ./mfc.sh viz case_dir/ --var rho --step all @@ -44,6 +44,7 @@ The `--step` argument accepts several formats: |--------|---------|-------------| | Single | `--step 1000` | One timestep | | Range | `--step 0:10000:500` | Start:end:stride (inclusive) | +| Last | `--step last` | Most recent available timestep | | All | `--step all` | Every available timestep | ### Rendering options @@ -104,6 +105,45 @@ Generate MP4 videos from a range of timesteps: Videos are saved as `case_dir/viz/.mp4`. The color range is automatically computed from the first, middle, and last frames unless `--vmin`/`--vmax` are specified. +### Tiled 1D rendering + +For 1D cases, omitting `--var` (or passing `--var all`) renders all variables in a single tiled figure: + +```bash +# Tiled plot of all variables at the last timestep +./mfc.sh viz case_dir/ --step last + +# Equivalent explicit form +./mfc.sh viz case_dir/ --var all --step last +``` + +Each variable gets its own subplot with automatic LaTeX-style axis labels. +Tiled mode is only available for 1D data. + +### Interactive mode + +Launch a browser-based interactive viewer with `--interactive`: + +```bash +./mfc.sh viz case_dir/ --interactive + +# Custom port +./mfc.sh viz case_dir/ --interactive --port 9000 +``` + +The interactive viewer provides a Dash web UI with: +- Variable and timestep selection +- Live plot updates +- Pan, zoom, and hover inspection + +> [!NOTE] +> Interactive mode requires the `dash` Python package. + +### Plot styling + +Axis labels use LaTeX-style math notation — for example, `pres` is labeled as \f$p\f$, `vel1` as \f$u\f$, and `alpha1` as \f$\alpha_1\f$. +Plots use serif fonts and the Computer Modern math font for consistency with publication figures. + ### Format selection The output format is auto-detected from the case directory. diff --git a/toolchain/mfc/viz/fixtures/1d_binary/binary/p0/0.dat b/toolchain/mfc/viz/fixtures/1d_binary/binary/p0/0.dat new file mode 100644 index 0000000000000000000000000000000000000000..eb7725ff711b363980020ea3655e734e93a5f5cb GIT binary patch literal 912 zcmWe&U|`?}Vi;fnG6aCQ1112cH`qg%2cYx`D18A+UxCs$p!6Lm{Qycog3?c*^fM^^ z0!qJv(r=*jJ1G6Z9^{ryK%AIUkdYW)l#y?!KqP>;_ygP|^7t?xlTL$N4Z>xqIYhe| x;x&j%2abffyr3wxn8?6}DucM0{4k$269jhJlS{)~4o)p3g*v4!r&K@4;_ygP|T>LkugwOx~|K9!#+mVxp zfpkp0{G!Ll-`l6(>6$A0;DEiOomZa=;{kiYg}xsc7+@hlm+b zCT*fmea!w?V`sJ|C}R3~M*G?Kwk;5y)L=i8@%RDz>=pJ4{|QK%E1TIjHSEzmcWS*I zVG|)LU@k8xN-ZWbu%XHz`U!{mFO8<%pfI;GoO*OMkiMiaT~PP%d;3#1w$F8{ciSrz oFe#VM*=c`%^3%<;XYRD0H4_8~(=eBVQwvF;_ygRegg^g*;Jy8n{@-Al)$}Y- zviJ0)-UicP+cF^8~_UpGO39+*tu-|_8ehWLp0s9Kq zV_XbCI&K0V(24{0`*}b%!9oT>gIo>5WvMwtyBXp&ka@^JI|I+>2z zcCC#mtZ=ilQ{B+{R%hlKJC$3PwzTa~v~TYEdojCgg1wPax(avLcKeJ8*@3prVfJ@g zasoPb-?#e}$=9rNF3rwPp2N4|@;lpme%AN>e`eVt+XZ67TwYL=T1;eMLzO{HLWKF- zUyv|oI{6bE=4&^!0t5fO{e!@nkNyJb&3{kxZT$D%zRS!mth4RCy{Onrg9m|o?UPsq zYx$S%wBLT`OW~@SJMCkX_H3L9q}45V%>>iifE18F3q&E&FqeZ<3rV4l?s9tbK`sXX DDMRh- literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_binary/binary/root/0.dat b/toolchain/mfc/viz/fixtures/1d_binary/binary/root/0.dat new file mode 100644 index 0000000000000000000000000000000000000000..eb7725ff711b363980020ea3655e734e93a5f5cb GIT binary patch literal 912 zcmWe&U|`?}Vi;fnG6aCQ1112cH`qg%2cYx`D18A+UxCs$p!6Lm{Qycog3?c*^fM^^ z0!qJv(r=*jJ1G6Z9^{ryK%AIUkdYW)l#y?!KqP>;_ygP|^7t?xlTL$N4Z>xqIYhe| x;x&j%2abffyr3wxn8?6}DucM0{4k$269jhJlS{)~4o)p3g*v4!r&K@4;_ygP|T>LkugwOx~|K9!#+mVxp zfpkp0{G!Ll-`l6(>6$A0;DEiOomZa=;{kiYg}xsc7+@hlm+b zCT*fmea!w?V`sJ|C}R3~M*G?Kwk;5y)L=i8@%RDz>=pJ4{|QK%E1TIjHSEzmcWS*I zVG|)LU@k8xN-ZWbu%XHz`U!{mFO8<%pfI;GoO*OMkiMiaT~PP%d;3#1w$F8{ciSrz oFe#VM*=c`%^3%<;XYRD0H4_8~(=eBVQwvF;_ygRegg^g*;Jy8n{@-Al)$}Y- zviJ0)-UicP+cF^8~_UpGO39+*tu-|_8ehWLp0s9Kq zV_XbCI&K0V(24{0`*}b%!9oT>gIo>5WvMwtyBXp&ka@^JI|I+>2z zcCC#mtZ=ilQ{B+{R%hlKJC$3PwzTa~v~TYEdojCgg1wPax(avLcKeJ8*@3prVfJ@g zasoPb-?#e}$=9rNF3rwPp2N4|@;lpme%AN>e`eVt+XZ67TwYL=T1;eMLzO{HLWKF- zUyv|oI{6bE=4&^!0t5fO{e!@nkNyJb&3{kxZT$D%zRS!mth4RCy{Onrg9m|o?UPsq zYx$S%wBLT`OW~@SJMCkX_H3L9q}45V%>>iifE18F3q&E&FqeZ<3rV4l?s9tbK`sXX DDMRh- literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/0.silo b/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/0.silo new file mode 100644 index 0000000000000000000000000000000000000000..360cdeab89971b1a66863044a753abec1780e106 GIT binary patch literal 9751 zcmeHNeQZ-z6hE(9*(k+zFxD|L=oAuiY?) zY?c%-%WIsQ3?KpI8zB=6R8qr=@8fmX$CtJx7?Q;Tus4hK<>Xzm0a3}syx0=+WPl9F z0yB;kBA#%Q903bLf_F$ZHhz?-ZtVz72?7@|v3quP7li7xArzxzRZw!XbDE?rAC&OkW$41zuGB(Ax z>g!w$4fElN@eBc}NP~mQ+Ab3U|NV&Fh z9u((-a}+rHG9V^mPU7!iGp`HXLP#B+b>;m7ge75p)5^vzR}#AoqpZ}9qO;V2LL98R%bBu=^OKyP z=KNRA&vJf_^FKJh$oXZ?uX28k^Xr`d&G`+^|G7_GjC-g&4kQm%HFO-_+e37;e5jl~ z=Ft1UKlL9f*y+yF2&556Bk*`5kR)Y3&9?>pDf3qsw|6B?rc z1syd=m;ge&tgzM79Z~jQomEZikk{ zHi!(fS(DIarAL*S&8CDlluphprB^nS=cA}dMk0skx1sITM*I<{G~b6mni%VlZ|6E> z1Bhp|1N7z+I?6GzqZ}HUTYCk1glN>%u-wSLd;7w~Hc@Y1n$X4* z@HZ*oiz&~wO^Tj?TC>5r0a2oN7nZ#dKW^-0PuSfQMhs&@K$+&sm(7B6&xas6Mp7I0y)738yPTi{6)Ezol8?NzW3 z#c}Xwn`sRkMllC=G>zQ|5zXMhPiMBlTPRL|+f(N5hF?(3g*V@MX)jg*7!9kY{CE?7 zKrsi-Rqy%_UPI9e71#INfie{H;OL86rlF9g;X)ws;%4qiuS=AlvkdcLtV^>reaJ)91>=+NgTS5;+2^zS@0 zkNn>*J1&8tH2Pgo0;6{9L)j;h1S8#z{s0~rH3pZkycl(6H+@$O_*arT%(HY-)42>+ zALQvXw&?Bf2INfoHmtR18L-c!g)yu_eoKEMUB#p*Was3I~P8eYa}+)^w3fR57E78KMdV_V`Y zPPd6+y7)*oU7Qmi+jQzA(FG-QIx{o>P^02&#;BP1#|)93``vrXSD=(X#*n2aY0vr2 zJ@@td?m73|+pfvV$Q-1KQz^p2fKwZzm@!nNV+HOT2jX+GGV&GLnLls0%0HEg!Ng{6 zEVaDHxJm&6K(rEqK|xA#Xz{=Jz45E&bv}j^5dp9*l=fv{UD5%OlHrlwCF)TCK@bXR z94nNYUBzNKL?9&igrue8M~T$U4Uv?hljn(ama z?+cAXeZ4OmTmQA5_f?v9;wHfC%<0-C6H;_gVkw$9E;U7mq9H|(LKj%o8>zB^@iUBH zVEhlpFEf6H@#~D=Wc(K6cNo9N_@Fh zKkSJ5wAxrFG&UGZ_ZW9Rj~M#u$VtYU>f>X7YucaIp}L<(|2RBLsyRmHKUu%|m%Yd@ zYPV>{oqA+!n)2q|;={Bt@m|NZx_oga7t#Q_8Y z2m}xaAkcFJx=EQYuwy}c%6#bN`dVKJ#+u=@)AoeWe0Xi1*FZ?ssKMk&&2JbuSI<=7 z>01eV1T1u0Z4Q_e4)6)qE_66t7R*J4po}Ugv)WB!Ww~fCcSHDORFJC%0TV#zDl05A zS9+`y{aV{C)-pF3<*BaJeR-8rgz0j%-R(3Nitv_~&hD^?ZpfDFiqJ}`og>#4yBrlx zlidLYa;?;r$zA1!cjRiP&1^TjV1ZmGjnhQtqvo@h7ZZ%Ec8gdErM_(t8EUiCr_Ca> zTckE0`m`bGD7tk zDn~`Ha>&R`(kqZ7L`F@zd}3%F=kElq1!*h3Cir^=Z4?EnJ zd7VH~6~s9u+P}PYMK=-U2x}&u;4jhFE6-SbhXt>03}x}O&e%eAq;$^gcrNu3TN(r@ zwxqSk7W5h?wpAv&ORTus$neR+TVl3Vh&s^lSVQB1R=#lTXyb-aUMdkaNpekvTQs?y zVqsYkB+K=M7_~%~)m{ubxmxmOgHI1z=CET}nd-B=-P>pRw()rTJfAjZo3+>u7O!%R zc7*2!G$ssURv}8{-MPfVaw{I}MYGFP>_QB^LO`nI%EX1le=>}3EEdkaR-|g{UQzCxR>A76@gaumexz%O$eKE`#JT+<4 zT3mrKqqx0c%){FBIawK)V4xjo)snQ3U9t|8o{44b;%G6m0Ih(Jj}22=&ActbgCQ3x zL5mW#*g(<`q&Uhvd{weCJAKZqOi-@;OA`T^SR4c5^MGbuL`IK!~1z->?9(}q6PN5hMSF$%g zgZEI3f%N-z&mk4XNI3e&nj{ng)K>`j-rUp;fqjYO=QP9oFZQJglD`t4?Sp;kwK(gn z-q@FR{}1*hyX*S}0Y|BX7bSr+^rfS0zdEg|HykCA{YqcWw}O6{za_ct$A#Cn&%??!`MnP3%htoPBVwDl;o1-Sc-IvW~1j%|9uC zBE|E&pa4qg+J|JHgajC1Dze(}!l=kGm*&N&Q=7=|iZ<&aqIR(?ov6vZ3{xLq=`*%i zSXF2fgUN5hQi~Ct&Y5K6mWotno3q4ha+Nsr`RrCffZ!#fO+T9b`3N7KF427sPY1M0 Qh*U*?CxI#g-`yba57GwWuK)l5 literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/2.silo b/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/p0/2.silo new file mode 100644 index 0000000000000000000000000000000000000000..2f5a4e468ce8815994c7eabd04472d26fea3bd9c GIT binary patch literal 9751 zcmeHN3rtg282)def~5*5f{&=hfw&E^U^R1&UPY`JbUZXnqq7!zfl6shi&~wpi5fwp zm^czP7ndc@&CQ9A&Bt`IxlPUDqHbo!;QKK(D%0o^osa$Jo^#t=3YPoKu_jvR4Fv2qO&8aNJnRd5{FkyVf| zNDUk|Otgh56qKmAAXO+THHgEkm?T-)Mf2_P`S@Vst=4oC#wI3VOS39Ye=0?P48pU*I6*1uaR* zi`Z@iFoQeT!{7U|vh`o%d0!>^eHQ@68%Akn4NlTRvAJ-_z?38{lDed1B%0Ax9Z#9{ z3=c6p%J3J4CmA*{Jjd_?!%GaWFucm}I>Q?bZ!!G62~K}VVw6UH?dm^8R1P_UoJQWV zY0Uh`h*SHPO^ZO}iZkv`s&3@Fo`RQi_kF`}+0m~{h_asFdEo4l5UA(fqkq87yPls` z-5IA=&+k{08A|he8tLrGv)8oM-`*QAGWC!A8~rO4-=|h>o?7NM==Je+l{XV>KhwvZ zI=*Asu2{aJ>d~>1S=D?>zoNLXyq$b;bxBTYMIPU<#G1YQtF!tClRH-={#2;f_YEEI z{`pqwnb(J(&ALA$)!UPt^6KH{13G`j;rrtJH`gsiMKto~a%wL;Lfro7$Ie?HH}X|O z^?8-c8u@O~*Rt?b>ahwueJkN(4hvlts~v`h1FXi{1$MjBjIl^pq>%-s7MoFUmkYLX7lglx z406@LVE_otMTMm%w?{gmRcV{qQtAR;9_otSm(OtsFiOt0xg4ef0mk_7Y<9EYf=oHD z5T(T0S#oZX(>~K-wAo>ToGW%^bj@+W8*;Y8YOoQRdwDU@k;P^f z+)(0Q1~o%vX8V=NH@O5VQ{h*Jgp-4k$SWJf;!$L@l%yEKD?^S~i}6R0g!yLtk;O3& zaV6s+G)XL?ZNgV4kgFUOy~-gYGm5W3t|2mN(&Zi9!-1`xK%{OYe3lc)Ms@;OBn_@k zBH9mTe|RE7I)PL?bzA0h0!dL2;}p}Dw;s-G!K3VA&GaVv7t==z4w?PC1)pworM+pL z7Yo!;;yLr_bEzNSQqPILMep@3Xf+OOt5k3mTX40J;gf|o%VeD?XdzPaHPjzy6l^!40;#)Lx5Levs@cTS<8+=2&t!Q?a+IZ=l`E+9oTWn#|6 zX1e3S%+6>8F%eJRTzZPnj(zCnp3S81VV`T(9Q*Zom~;ZWe_bSvyB(VJ#Yr!nkxY79 zES*TRmkNVVL`&0jq z3P2|)i~sI696&N08ZxUNz?(?MK-%@%$B=?#6zm>eIRJ@3YAX%+-`v#ofqjX@=QP6n zH}<8$qP-HEZG(O3m7bPy&tqTO_&?Z}Y|byH1PsLu|CInv*OrE|{`AN>&%;m>*jM^R z(pJz81Gvxks+SW(p?|lK;wcGzj!Q#oG<2d5@4s`pd3|bdS@?`C7R)KI3c=*tu-IZmlXE87xWyus$?7OJ8J)%UEz(MdV h!I~V;{(OX4hf{DJ!P5b)(m||}d?l7L0?!sS@E1m!6xRR% literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_0.silo b/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_0.silo new file mode 100644 index 0000000000000000000000000000000000000000..af6de87b8308dda7a6655befdee2a82c047d275c GIT binary patch literal 12675 zcmeHN3rrMO6uq;s^0mMUqM&stqQv5|%hyk>6k(-7iy|>WYY=gPRRmlR!KBu|Q5&Jv z)cT8!*4Wlq(`swc(5h{0wW0~Bv9Z#|*i>ty#YT;3BKE%D?9Q^EqBQNozGUa!xpUup zGiUERbMJezAt%e&KQJm#R)Jh)uqh zSOd7p00Y(F3o4??W9l9}2E{IKa&k+dp#Z!5*jRJ@NI(!UxZ? z2bbCbV#$Ph>u|^%4u<2(9i(hlirI;ClFU{czObFG(Hv67B{-f3&(>o;Uq3?QB@Y>5rzpBZw@-4Ag)7T52@Z1JLT9pXut0Q0Ky@v-!N8EPNA| z96CMO5!Vt@pOU~vu&(!fAAkB1|JxijazfCz7(wssO3-!1mVb|)v2#0iM$J=`bE=Y! zQH2XGeOj%KVO&z^`OO5cBK_>fE51Pk!#(Zf`ZI~DMnB)of4~t zDm2l+(RXg7!dNPfxOgBN$n4a>?Y5uRKm-*B2aQ?}kEwWh)9h`ql8W0xj_d$3yEQPk zu=4aFp%?}$x4y88&WHZ6IPUAa@D*)# zHMi+6>h6({d24$oB%wAOn#XU5N5NbED+EY1(iZ~UG0KQGU3ogTI{|J+LuaAW0OAP( z+KbPo- zL+!ur8pD?kB?z>B#`SZgu=ge3YXy-69*YFN{|%Gd5v}(#D`)gxp7purU}m#Mx<*_K&dBBtML|)1-utveTsMpJ$q0ef%3> CwdouH literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_1.silo b/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_1.silo new file mode 100644 index 0000000000000000000000000000000000000000..4a9c85e88026170587c7217a7e3671e86b095bbb GIT binary patch literal 12675 zcmeHN3rrMO6uq;s^0mMUqM&stqQv5|%hyjW6k(-7iy|>WYY=gP6$D%m!KBu|Q5&Jv z)cT8!)@W<2X|=UzXw^1at!P4OY^<~~Hr3i_u~B21h`sMO>@52!O4BaxOLpF!JNLae zbN0S7_r5plvNMhS{UiKUzP=#JV_sGom`2YpgwJ37TyAz|o=W@bm%Gf?zj~F1*yL%M z<*b3M_E&)fFi;KNpdy-FrtZaKK=jfk2e%X)46wt8jWvf4$!;icIDi-UKp@^1)D~Gv zO|=k=kB?_qwCr9{B0Ws)N0lHjU=Nx-lxeT<8oI!PEfcEd(_4zNMu^BDNudBLNs|0n za}c#xAV@eEK?{p7s1Z0Xe_sUEOA>txTlj(+x#gkrOxzt;*aYw=6SXsJ{5bBy)}675 z`ZI5uar*Qu_(jVQkcLWdoru5x+PHzK@aPIY?(yi}tT=-&l*1=%-5Ea1&VmmqeDFMb zaH$<2noOv74h7BTVA!u*LCR*On4LH$$!xXa3+ve$$suK2g5$aIY(47rRYdN@>>PYv zwe|B7UQCH*Sf&baV0WA5D7q)wEBLjt!dzKi(~Vz)u=&pV#%5R8^FLGksSJB;YmX_1 zaqLAKcgEgZQxHk84AYR;l6WJF8eV4TKl$;2U+ZbodWvzPwk}bphq8Gk$)gi=dK8m% z1{7%DI$eCVKHi|KF;`TLvzUr&&G>hkiY$et7V|t<(QW*WG)0S|it4hWLQ7ed0q)C= zaw3g2v$3r+RajdAY?aQONGsp3?6KNns-b;t+e%8a{l4h9*1dJecHM5-qwpZ$LBNB6 z2LTTP&mID<;xF;m7PYh3cS0YEee2fWQ#r(DIA-xAjpyz{*EUeLJ@*O)DMXvb6ZmvU z3V@`?UZBYCU*VXM?4OqRdHp}Gs-uJMQEa-c0}Ujp!3T6L2p~t`0fu)A&OgQT*vmoe z$Hjqn$jXiyc9Ec&#H^5lx$OiTyK-(tRWarvibRC!sx0PGb0v6NXE>#nszufOg0J-j zQ+=(evbLtiyu<{7))y9-YRYOGsxdeX9)QD<^oFP^i|`1;go-x@olp?hAux#_Igh8& zc6_v+lmz9eL3x@8fg{Ni`?Iwy^2CMYE)-8m`t-$v-f;1lLzm_YQ-jc!6j3`{J($K_ zSJ=99y7hW|Si2LK*78+A9YY6?Su+epSh>PeOaW0lqfc`mqR+CC$rm#6vomonqUVw2 zxp}cEQ4GBRdQRjE9V;zNCyoyB>_y65;D(f2-*JffntjUMAHe|Z^3Tt8^@XT&;?r4t z-cAy}35yP%nq-e_38_zxVJtCk>@{*g(6<;tZ|_dfbw!qckDjq}8+Jy` zQ_u|k+a;|XXH;d zf_m}fXf;%!i3X0mcOx0bQgOt^eOW+erv`4f{j>_gs5m%a)LM8<#mgIJZGq)f+!Azn z8<5$pfjI@8`yd2|)_~8slgEK<=V&0>=SmBlrDDwXrf(sKiYHRXodev6K!c5+W_<@g zQn9_mrxi9)aoF)aZE%~46QizNL%~ye69MNl0rxa%5u2Q{AItvCuFE^RE^f`iOc*Vi zJy?^mnfbUFnco2SWMM~k|CK(z5i@mCY<>LAICT#5@uk0o1cMPx!ol>zl5%(f#b79J zS-lVwP(bItvB-Zh96>P%HkJ-p0kzcN;Mq$XU=@mya6cyfZD>O=1eR}pX$PGT{b6Cu zH+SJ{+U#mh(_hrx!y)6=)=o%3Z74L4Ul)slr~Fq4kZ9yt2yn+JBidBu>DZnGxakd@ z1r7sFnma|~>vpy88Htc$|${{wxdv77O*$o5FS({a}pAjlD zC|5oWdIc335_&>K+l>uT4qb{<5mvN_+F7oBmb;Fyb?02W>E^xDuCVC|6}9&vDr&xd zxWyqhy+}pZ+>nYsT6_N3vqDA6bv{F)l8TahLPditN$JMy%nbR{X+k>l_bxjxfl9}} z!6Hewn(hgaoPZPw{0d9V6()0KN!1KK8zl%VB;RmVnCB9;h38X5O}2%&`V*eiV2{NO z#T6!RwdgU}_Uo=OeCbdE|E|xtehwG*-sF3&0FuCCk-(dLm~}wD+K+vK^)QlM_Z*!d zK>vU7MLDt4%^aOCfe+gNG%VwLPqHRKtT4%%#33Q9e_50C2ial7B5RV<nCEnizX4Nv?9%`M literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_2.silo b/toolchain/mfc/viz/fixtures/1d_silo/silo_hdf5/root/collection_2.silo new file mode 100644 index 0000000000000000000000000000000000000000..8bf7d5eb88091292b2de4d7df8c17b5f82338295 GIT binary patch literal 12675 zcmeHN3rrMO6uq;s^0mMUqM&sVP-1b}Pq;V^gh-78^CDiP-yo!_KmwqBQNozGUa!xpUup zGiUERbMJezAt%c?&_BXog-rl|GEqCj#*gDJY~2}~ zs6X>(7-!DRhF`P{0cp4d*NOQ1uZ^3S3XiVf;~tOh&5ASlLOFcG)}7(A>@NI(!UxZ? z2bbCbqRE7M>u}Iq4u<{86{KudirI;ClFU{szObIHksMOSB{-fN&(>pJUq<9j&dJ5+ zRa-wV;l*SR(Bh}BVEy=xT3nexX4moWq|v# zqnu1<&1`JzOcmBv0Er{ZVghCzQ}$SGG1bt%c5El5*?wPiT#+>`{0S@F3tp zz=MDXf&PbptN2U2wMFeL_MOy+V&A%r_f!tCiNP$Mr19Kc=-LL#w&z}2F0cQ`RdsaGJ&H}Yb)dl{HF%G%1p(v=JizdF;rXX{9(y^6 z{kS;r4q4ez!yXbelb97!F}IzBV^_|vs4BrcM3IP4U6sXLX08Np>kOyNQnj?2U+}fQ zV5+Y*Ro2$jn3tO%(E7q+Q%!koLp27c!Gmx(lHL$iWicLMgi!J3pc4w>Is_*1Bj*V; z+D?eplai=BH7HM$AaFE!Vt=-lMV`2b+=b#PNuR!W&>Joucj(f5VQL84k|Jtns|Pc< z>k3sAK5hF?*Jw7%NwJiYXv!XY^_AL-bibI^{xUK~5IVMf5zf zJhvb&HHx7ZK+lPMp<|7O>BP|?p1nx9i`|fN>pKoJU$alS??*5IyZrMrU40SiocMG$ zpSM$lZ^F_;r>EHCT0-hmpkDcoxa5XHhYa65cDlZ&^x;mbX~FK-=k;j+=iV| z^VH;=qGaP_V;>^tm3v#RIw6K&P7GaS@@VLzjR>hjJbRIxx40oW zU(OrwvN1nrI^Arw!i+sI1^veAns4h1W}9rykR=jgBDNOl~%j~Quu2}u61_`3Nj0( z8bQ5mYP1@v&_n}A-?@Ixu!J)I4Ho;mHBjJ8*#+%TFVhF6-`ur|B9|ppb z*st%xSG3vH+@`;%yN5&Ot?iwVh}uwSp12_n1yA{}5FpV=e+Y2LDI?l6<>~mI1h^Ru zorMkqh$jee_w}IwcXm?iG*{Tz(w4Je_0v8StTyaDu8 zA8B!jO)pZ>H8-T957(bR-e0Ilxz1-uQc_V$PpD{!B{{>Gla(odI!#Dt;r^B9B~a=3 zH&`U;R?|Hpk`s_3fnQOnxx!?wEUlWwXQKpxMdTZ<3iEuTw(xw4sL8evSAWct8tk#8 zp`^m(trk57+kV|OjxQZb;NSHb*U#a?-kW@{6+jYrEE0H=53>%+R|l{!upUOT>z<<% z1nB=Sz9=Vly4hn3B=BJyfQFTP?@87qiWMeVlQb-Z^)G9Z{~$YzSY%B~y8K`9o>Lq7 zXZo^D+8aY{T8+EP(_h(UeEcw_jX}Q{xsMcKV=!!pwb3N*A7!ISdKhP;Ne&%jqe;;} J$25KV_%~<(>s$Z; literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/2d_binary/binary/p0/0.dat b/toolchain/mfc/viz/fixtures/2d_binary/binary/p0/0.dat new file mode 100644 index 0000000000000000000000000000000000000000..7bbb7fc340510e93e1028f5087852ded3da3ba5a GIT binary patch literal 10834 zcmeI2F-rqM6ol78xXx=V1#Rp-YdNs6@kdCCz!d>Q1b=~l!@@?;%2I69La-FH5VTMc z5fKptvD2*k1}yL91rBu%HXnh^n`N@uJoYXRAxzj9B8(459>-XG_w(8-)4L<%sqxHs zVZ1b68*hwv#(U#~@zMBXd^WxqUyYbcd-I5y6j=zJUcc(B464<+>6+K)_R`7W&T+{y zo7u=0v)hs->kA$03mxjPL>-o_FLbOgbg07;by%{#(6PSIp$<#bVafVJ$NG*+m-fC7 z+X&r?bYrXQfo0=O)k7Izm+UgV*D%X3mxyA zl&Hf}{=Nj*+@bTihk!aPkA$03mxjPL>-o_FLbOgbg07;by%{#(6PRw(xp9-#`FbNB)TLkZCU literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/2d_binary/binary/p0/1.dat b/toolchain/mfc/viz/fixtures/2d_binary/binary/p0/1.dat new file mode 100644 index 0000000000000000000000000000000000000000..9cf99145e1310ba1efa606493b7476d320adbd72 GIT binary patch literal 10834 zcmeI2dq`7J9LFbSnGak}8Je1kX=KHkkAm0^1V$2SB=v_zHqDTjI;^HdxG2jW_Vkfy zjr>PSC^8b3g^x-rLXjq@S)gGelA#P`1-97dEz|{Jqk`v+l#FIx#MM zr@++23t~+7uo5b=>cV)g&*Se5>krm3D>pQyX2m^kN8J;W)Cr$qR5=+N{Ycd`R+SZgu1~>m@nwM=50(83A(#a*G6J~ zi?5_2)GJm(oMFD8+i}@TXa{DLm9UbEv~|*c9GqW>Gt3usZ9U0MI@I53|Gl*PT;G;o z%RTC8$DU1h zy;Ojkazs{L7|->2{Qu|m=i8^0rVIJ^sky_$?_3lLa#5eM=yH{hV}m^nz4ZAPMe?7O zrlKPi89Pj9@bK%<@_rd=$*OxWn^&wnHq+@9{@%Fg`5!9@Y{2^duf#>N0U6q-?@V{q z6f2YDfl}2H=&!#TV$~mAYc9w7x({WUSbzLze%w;%2b9_L52pOG>K|ymVb#~(I>Ggq z;QTvF{O33aIpxg;G7LKLd2fDR6w6gnDI zHb(^K&q{DC;2fZXLnnof=7?~`!AfvS;8?&pKnI6T>WFZq!%A>y;FQ3zfOCKj?ud@A ztoY|V0*e^|a1NS+?Bi_C!4TXqSl4_Hu`*F;pJ05ekY6TUc zUa=D54D$tD|LbdnLT^S_-U_#XDM(uwvS@0esKl4>F~1*Z=?k literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/0.silo b/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/p0/0.silo new file mode 100644 index 0000000000000000000000000000000000000000..23901b7fca01d808da4b8f7cd441713cf4389da4 GIT binary patch literal 21474 zcmeHPYiv|S6h3!Lp{$E-g;GEsTWJLnt$l(Iq)^JH5s^xXK@4t}?rpoU-7UKX3O*r7 z@PQE{5e-NUMk8t>#zZ6$F^Zzb_)bjVhZ5x#Vg!RRzUn!5<}7!YZo%1Fx;m5W{pQX+ zXU^R3&Y9P}vk%tRc!oGfIUQM9z@bZq!}TJiL|*v%sr!%4t*x2oaL?bnJz)QJI4&cZ z){#Q8OQO>O2Eg!47yu5sGFM#g#pAAmQ=ca(Qce!Q)Ah~p`M9}6-WX#~+zgi7uxvVW z^VLID5l?;nEGQZ!7%(n1;50?Im;Zi3l7c=&4%kE0k~JQyvIsrVH%!vUvgu6UE!p?% z?7k%!PCh#)SI|)$Bl6?QJu}3~`6v}$$=-=RN#1H}8|>bCxujII8<>9@du!cif7Z^O zSvwo=H`9K<#OKP}ur$fahP|=kEaV_RJ`3yFQA_Bb1k@UgWUf#TxxFoKh&J*S4zKYU1$BDF? z;#U&Ck$6brw-OIa{9fV_iN_?Kka$w!j}m{9$VKTd<>2yY)C_0_ zGy|Fe%|Mzl@NbO2Uic4;zeX&mGkcg$Y>?y8{juTigu*k6k`0#OT2U*`4wh$54O%NE zvD(s|YTjkE{J^U*jII_3u2f{gZUZky1Hmv%#?jt0m{Vgo9P#70Z79aG`Meae`Mfm{ z@|qp(W~e<1W3NYn5%@E3@C@CJs9Svtx>qeK25NS)4rd;4YLC=l|S9k4pN3^F5R)+d!&?2DR0=JBL5RClf^rbl><7`K??#i*cO>xtM2W0U^d zkBm*)m=t1>aNUbdB*P1t+<{vbQZ_QsnONxlcmC0vcNg@~n0zYdcVmc4D;#=n&v9xI z#Ih=TK4aXhu5#U2q2nrlf21}2PMoeMX-4)LRx>i~Y%_um(S~`on$ea3)*Na!sIsi^ z1(%y8kju@8aCNu65`<7D3Wae=CZcE)#R)1xaOm!9a5e{BhTF-Q!{l!}(c|e;=|$&n{v%wg+&% z!}LYG%@O1<&b!Mk8EY{K4I$O{`2za`HQMw`3<>Ep8@6V|7wDd6(A>s5f*bq9 zJtxY-r;&SJx#ZvnUyB0794>yCcujmN6z*Vf=z0_A84 z-9K^VC~?(g^^O#8Xzg6+sYfZ`^2uV#mlZ;ujsu_$I>3!FA@L}UEHKC{V>oI5j-l1_ zZ}vduy~lHNz=I` zqkd`G=`Ej%W*sRlL~zU)hp(<1e#+u2MyD-|9zI zyR4^fS!i-Ts;KR!#(*mCte2W~0M17hwf)o>Q01NVQnRj~Kl7-Hn*Y=oQ01NVQnQ|V z{pWIMOl=&c`P2NRG7eX*KdH7KT3<7u8Ax9S^ozI-=r13LR9?&%aa-CCwDie~xarba z(&$Cp@Vhqc>z#q7|M#Eu|0%t` zWO?+!wppIqn(Ej;Gg5W-I=40$;3$s$#ghS<)-H6~en-K8!QQ4o5cjV)g>RF~ZU(r# z^cPXVz+y^{$eo3hO#8xR@-DgM4|8l>)fhAfWO9x5V){ZzjjkJwW2%-*31xGA&1{eiov;#6QDHYRm-@RwqU9;KU79xx9 z&g}c{z3;qt?)%<7=bn4td;4_J#KLG@vMwwt3Mh4obgqoo7{!i{wk*3)QZ#W|m|=R` zE{prGIjkR(sUOHCI|b^(KmzC=2@w#+_LT75w{g2S<;vfEG$}R~V0&+_t6Zq$4Ptwu zL=$tw9(^i6IP?ZRwiO!9jvA#AVv!OoVKVUcV~!nL+Dokj;stcP%(tJ7Ui+llq!f3C zSRN!P0U%70q(0&R!f-uEcw)vDrYML+;=*-NNNTu*MKzHuwDNK&Zd*)0+U<{O$d1CYvPqDZ%o&h=Ai-BGyDR%Ymjo$jL(BlX=~|-4V^`*>JNgm? zeIy&o^i7R0Hp#5j%{RDP>mWfX_ZxWr z5VqFRV>gRRCKOG<>y323UdHEw!JNBj)v>eu%oo#sjolOFJ$$LnYO&3Ae#@7#F4vm; z?k0cSi`xc-;Rztc8D!xU$3sqE$mt9R505!a+=JD;i2$!sCUo$%KWtjgxMW;!P2}yS zv5M8T4C8e_5tq~p#|jC0k1(>71t`IiM{Ra7jy{z3)Wu$8`QpL}hNkQcBh*$^=RS~? zVZ>}sMkZ!R(hXmgd11f6BLa^JJR$I;z|#WH2s|h7Q-S9NUKDsq;1>e_CGfI9YRcWx z4r-5v8Uq>w8Uq>w8UrE5z@3P{esd4RUjt^AD&0&cR*QJF-4*_(r(T=mD_Dkyc(1q> zEKe9~RaZ=0y``%|6Z^5{2U?9G)YpdLN<}2JO1RT$vDzUIqrIJYrb@frQH60^JmzDl zoCz^huD94sN^_%PYjncUAEUqk{7D!*Lst=Xy}8*Xo#$8DR%NMo!c_NZg4&$Mg$)W! zbFa2J8_bmo%=r)APGT!xe+vo20T~2;;gk` z&tYzZb(SWxb-rSRIC3N0)D7;Pz=kqTNcE^vux6Bd&3va~ayBTH_0@2{dwnH3B*kH| z)qv5xS~b)hpT@JuZbOeT-sk>XhWw~+8J8i?^eJPuT54=i<*`4`FwC_uoD>B~OOYjZ z?;J{HqXpMH6tlxr<3J8QtUFtkQ5TbNGvMpaPCFjMY=P{7Ic@gyPG0qFHmuHw12oTT zsBZ0S>)IYM&rzcBA!ME->rTDRtgUN9@vyd>FOvB6JyO0qw;Pi-h(`psmK*n;I`Bl{ zw4zdVVK&Tl%kgYv*0|iOCR+bhy9=vvxdpzTjaz{v3=dpK@>3gdiRj!<>Da-)|I_+{ z=|vMUmOweSgkCjjK{DT~S4T&dFRyQzRak~nK5mpdTA%{`IMOunV(^aACe~ z0lcmXtgDG%4vnh7EAM>tEUdunAh?z`egmAvY#cnfaqM|qF<}xv~c+}=avj7E?N9~wd<9O`|`{Q*y=e_NGxv>4M z-yM*H#p3*NI~VLdAlplm7A%iCD{r2hJG=m~Z1T!jhEXXi0>WVRlDwv0_lkg!>^E2>y;o+x{K7ZTAurwb z!2G3%m*ZQeGJNX&Ede3g*AMH5zjOSK+kZ}c@*bu?@o%xs#!rm$w4H}%hc_(AFV0F%GWUS~ zw6#N3{pSaDJkRtmeQ!+#(|@Vjp4$`p2d?4ze^GKn)j#8{^{W1+=NdKrJ#hcO@BGs^ zOn!sPJ&l_F|1EyIUH3`j|NrBk;@Du3#(x@zX*{HHiTsm4r1(8pq_~5|e;S8rJfv~S zAJV)bSfqG|;tm@BX&k2U&>zx#C0L|5i{c%MJ81l;ao8VrH#gGuX$;(H2DArp&2YE8 zAd>rCK8V}gc%-&R9>k3oT{?sw#PxrC!~U+hNet88wgND~-S@Ut-gjwCm%mQ8cimy| z8{pL2_%dWs=owSR!5!07Zoc&ud5O0zu@Bwwr6M(*Q(sWPw{`=2C6&GNR$gFe6Z~l4 zgMro|drrNyZ-*Q#4*lfgG5dP&l!rvW_|Mm}kIN?~4J*k;{BUbtHp49327Ty}u&@qeQq{wRlA=z)RNP1^& zY)#lEb;#C-{(R^tK4(t7x_=$RW%soPgyc7ZMY>+nTanSn%ARwtdMrAR;msE!0z&dv z!6NZY*Gqa6f5l#z?~tE;o5i>OF;GA0El2m4c&6(my?nInko}?ec>^tL`ni3DI{!Q@ zpf8Cu)c9ZwX#GIz2U_GDa_qrd@*8lIG_5U%qeaZ5|ksXr?izXJh{+W?=XOnJw zxddSuuD^JaAX5Dboqc}Ck_3HB)fOv$zg}&hA(q`F(3{v_L|H8rtlA;IEM(Q}yKqsx zSA6n^XRKUUX;mU3>5Q@7-^LCW-=RyOGg}*K%_c{!J<}q-gq9$pNwH>*6@Svmi&;nU ci$<2P*x*o{qaUVXFZB$WP5Hb++qyIGU&zuQvH$=8 literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_0.silo b/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_0.silo new file mode 100644 index 0000000000000000000000000000000000000000..16abbd65caeb1643250b2e326d9f296b29d37de4 GIT binary patch literal 13864 zcmeHO32;+Y6um!f=@uXjg-QVl25Ln~lkT9{TG|+=EEO0Plqt1@HbZD@S_@?c71vP& zaT^yvs(`pND2^Q&(4weCP^XF;xa&AB2naHaj@0{>`;#Ag=18o@7W!04iOwWabNvU7q;**BgJE;wqhLuuR{!#uNBiXfQLIsTN^* z^&laW44?oEii21%FwGR9K84@+T)cgIh)Xh=09M6|v1ZF5#RVBh0>nT(81cHGsmfR5 zZUPhDJ|1DQh-<|f>!G3#R{~)mZqzkQXg`+{{NTZY{mD!CC5;F~h}A(+QUMH#q9lsu zKr+S%3J%85!jb@S2wY530)iT&P~T(=ZQLj#zgjEDt__Dxvb-~rde|gN+{reQvH9q( zBA;{Gv@-bKA`npeDeyb1F6%h>hEU;mIQXQ;qkD^@2Vbg$k8C3uzRCR;Khr%E3}W-x z!wCw&rUJQqV(fl#5Dro?D`|G(oMf{#fG+}P>ktX4;1cXVf@f>R2YT210?m_dAP)$Y$F-_ zoY|MPF)Rm~*|J0{GD)~t!t$@h(Zt2_&*Ol#Zh_6jQ+B%^lV_@m{APq517ed$^?_}q=$s%_Xadmn#wb@wCks|y^{ ze;3qyR7MeqA`nF&ia->BUV%Wk_>00jl6tZ4_+AwIuCtCXg~Vng?&8@tUU~q<`oYIB zsi34-isS~q7;=*#w<`uTyZcKdW)||MR;rx7@`nlUy~(jTArHj2TI=LitDNwFYX;|i z8fqi)i3l{ozmc#!HIRpCypaj=F3%`9exeB9fP$DZo0_10J8K18z- z;ktUCr^ZtUv4K0B8ejeO4e~@n;0gD_CU;#^W20x08;pS`=DQnfn_3z$%rp(a;rio; zsp_im8-|lgHU}FiSXzw1L?7l#Sv=a#vf5e6)*d>vhdE$8m)&ua$YrrRu4GrCxl1-i z0)Fi}a`gR>E-e#NXQ3@CB=xpiRnm3IHj=knZ5@VjIxcOISwMgGVAJ0A#fVclJZTD$ z)T7UqUPPZB`*-_Frn^dUE~4kL^xT}x{1MEv0A1B6D$w_v#6nq>A|#$cl*@dY6~dK{ zPlj?AOPn!_AxbM%PG7mPGh>U6e?FBiF5T~+8;=xsqE4qxnVh$m5{9s=yT+8x3 z6E6@W_-Xp1%4Z)`zonh`}U@!A~Zx#siZO(vWzbLULXkf#e)BZ+5A3s;h!Ob1{Ir zuK4q|8-zoGn#QK}iljWR&Q`JyAu&u;TMF;B;8K{GOXiCvkKYr#{7#i=akI)F)vF$y z@0^BwEX^9qE+$%ffPnr?z-OAg&_hnuk5zvb z=P5)CH*ohep1)f8Crz(uS0eCP|;je7GCbn<5V%2)i!-8~ITetzUQuv<)pf@$0S#7eaM zuL!VcW~4R|OKS*WMaL(T73he-!1J;>hxVKyROHZRJ{@)q6*;ndLPaZ` zEh9p@)W4vj&w3FRy{rcp<456?IA1A=^k!`xR9-ZwiMEQ4mQzDjFc)pvEnu zC~Z`UJlW^5$)T69Q%FT=5;HZY)q474b@z%5yUr9U((cF{`C2L}>bC(lcMaZ=3=Kn|vLV<^SUN<7t-bU`2t%%DEn|+fz5U{t7t@6);w^*GaveYnj?7 z!*Hf%PpGAOlgzJRkJT;JUUzI9^=Jxy(X>!*2o*^3zd`y{8rjFPS4ERq0uhS>>@fRT z|5^6(=dmGlwvTwrbGOO@;zabDA;ABCKuKx^v%)jjPr9)w-OD|L{`SI5hZW6SuRy$b zOTlrId_b;hvMCL&YI26Auy*VhlC5$Nu-700HF-tquPANu?~(r{Qd<-Q%47B^6cjaY4UFw6Qn6vBOj)y NqZe+;5;hU?{SSj>kz@b> literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_1.silo b/toolchain/mfc/viz/fixtures/2d_silo/silo_hdf5/root/collection_1.silo new file mode 100644 index 0000000000000000000000000000000000000000..61bbdf1396db60efa1a154d02a458e34a91e20ee GIT binary patch literal 13864 zcmeHO3vg3a8vbwE(pP{q6e%bp z{m(h~e)-RL{`23Pd)=k3zA2e0hNL8*(j`k)fza6HiBG<}_4}Fb(pd(}oIP8-!Jp*@ zGqagx5tj1@37KL51z=DD#DjrpW(xHM{J!tny?eu4lF0g{u)mcnDF-T2#ZBrE7n*K6@9o82m^7W?qNdvfs_yc50)KGUBxeHL@+|E4vLZv zU{Dk#Su_WdaYj&ZFoqVEBuGHu;!=_j)HsFuCR=FZMp60IS~+2BBy3XUosrbTCRyT6 zw$Y5u+pC>^*X-G4@P$Pnp!8GV2UcCzas1yxh2N3jlOB)mEs7p|=@LG&jb`{}^k4a0 z_e?N|&0{YoC;*!ZUBYtrc(n;+{F# zU5@vg5PZK3&jPE!u8_ciCp*pmLia>_4Zqg;ymd<&&*4}0b6A@*$40{baxn~$)N|+I z5___ZX6$npPHkgY4m7i6iB@EiaIJ*pH;dEB2miwwPj^kWH0RpvP`hYx{`ef59jkdZ z2Ud93Hd}UsJ=*dRsPBvzjqPb9(MTSPI2nEQ`1A0uc5Z8(qCKe zfFIFux}J9Jd->UR=ZX1GKXMk%Xl2&$YbF6ZBU%FNeeorH}Y8&?4 z;U`|&?40*dTlz*NWJG>-!D9yQf_jh17y>Z_VhF?#h#}A`5Qr3iQFupEFZP|(i(=oW ztRqZeu^ENCc(#p~9zd~v=y6;+C}|d_+`tz@UMl2u#ersb{|AYgg}kYiDi^Q(a`Nl1 za%|4Y1M#iaQ*x_SPI$l#gY(}Dw~=^P1ey@oNLZd3$U{>VfbN=h9~T?HXPKP@*usKl z7PCSD?rkSwPjxr?>Z@@dqS=UWUA^C1?27(YwL} z#^4i6J&mvrET6pUD)L9`Mf{r;0mK zr_-iP&f6)3AslFLIMN;0vV709tHcNantnTL?;#0Tca6dj^f!W_H=aw-%~k&2kDjsf z5OzjQPsy1%nTDM6wTF4y!-DfA=Va{o#NQWZhIM{P&T?G^l6vI4PC7Z+Ml(6@DgC^r zI~QYq*^cWUD*?Y8_k8>|p^JnkIX+1}a^BL5$l18ytpz&7V3NcTAQN}vvFQkDSUfKv zId6?Za*kWFu+%lnUBREZ7(iWD0(sl*!XZITW7B#?Ql3|5E7^yU7$&MMh4)%;Da^_v z^A$5D9SU83XUVj=!FO%E@1&BjF<==x|Tkfi?Np#qkJ1rNoUesUza>;TD%pEF8I z=1g-z!m?@B1gOUbINX`#{lJP1&G6!5U**FDt{wBn_A+1-)eJ{FKe`hJ^6KE!arZzM zufDrx{-dypS0C;F!k>XnbTcfRdv-gd@y-(Wx9@@lTx(7IU^jH|>gbId{s-l}`by!% z{eaITu-W>5l)VOrdG*8Ni3j09ULC$`YbUUWKxVl5y3Y*pVSoLRdo^{@{6U#S$2aRTC`Peb-I67X@_guWn9R z1}}1h^)&--hbC_Dw~j+=;7+U#fgeW~-w&T+H4Rohbj>C{ANs;gqhCD%oxItR@(n+6 zch7*5ZyrAj>=x6ZaQ2>`uo5f(R|Hrzaw!D3McRnAPK)==glH0v%Bpcurk( z;?N~RMGkG|(_z<8kt3%kRJ766G9s)?{RS#}zZX%_f6G?vj)VULDUP$UiMP1T$$TphO ze$L9~osqC13L>dTMFZp;)VO67rHv}lC;R+mM))P{0#Z?i#7xa;wO;&Kt=+Kwz@HT!6B`Rj>z1cYBS-gp;{YMCsQEG;c8EG$GsL_}r@PR#v4 zJM(Kj7jC|!IOltuB>xx3aeVZT_rv$!zj*(C{=xe%|9ZZnN$ z$)8UCeDar*zn=W<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j z)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_Htg zS&ve^<@>QUCC zRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iu zk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6| zWj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!J zdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z z^_HtgS&ve^<+$#d_Wt2=^Q>p%ZGkPY1-8Hz*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-p zfi18Fw!jwH0$X4UY=JGX1-8Hz*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-p zfi18Fw!jwH0$X4UY=JGX1-8Hz*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-p zfi18Fw!jwH0$X4UY=JGX1-8Hz*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-p zfi18Fw!jwH0>^Q=KD}@6A3nZ$*0c4tz!um7TVM-pfi18Fw!jwH0$X4UY=JGX1-8Hz z*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-pfi18Fw!jwH0$X4UY=JGX1-8Hz z*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-pfi18Fw!jwH0$X4UY=JGX1-8Hz z*aBN%3v7Wcum!fj7T5w?U<+)4EwBZ)z!um7TVM-pfi18Fw!jwH0$X4UY=JGX1-8Hz z*aBN%3v7Wcum!fj7T5w?U<+)4^R(W(TjiHuJU(CFD%D#~z2)jr z)}vH!IrWySM_G?jz2($ft{!DQO7)ggZ@GGu^(fU_PQB&oQP!hWZ#ngrt4CRnQoZHW zTdp2uJxcYKQ*XI?l=UdpTTZ>@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j z)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_Htg zS&ve^<@>QUCC zRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iu zk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6| zWj#vumQ!!JdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!J zdX)7j)mu)z^_HtgS&ve^<@>QUCCRBt);ma9iuk5awm)LX6|Wj#vumQ!!JdX)7j)mu)z z^_HtgS&ve^<!>%{Xu`w zAM^+PL4VL6^auSxf6yNW?GEuc{ULUSep7$YAM^+PL4VL6^auSxf6yQF2mL{R&>!>% z{Xu`wAM^+PL4VL6^oQ6L2A%ptzwQt}uRrJy`h)(UKj;togZ`jD=nwjX{-8hT5Bh`t zpg-sj`h)(UKj;to!=T+E9;ZLV&d_h_5Bh`tpg#;-f5=)P6!+;ybF5bB)-z@Lr0f7?IUzt}(7zu7<9zuG_BzgMdZ z>7(@r{Xu`wAM^+PL4VL6^auSxf6yQF2mL{R&>!>%{Xu`wAM^+PL4VL6(nX=#5&DDv zpg-sj`h)(UKj;togZ`jD=nwjX{-8hT5Bh`tpg-sj`h)(UKj;tD>O%Tx{Xu`wAM^+P zL4VL6^auSxf6yQF2mL{R&>!>%{Xu`wAM^+PL4VL6^oMj&sCI<@pg-sj`h)(UKj;to zgZ`jD=nwjX{-8hT5Bh`tpg-sj`h)(UKj;toL$$h)K3ad!AM^+PVc7aZ)(WAxPdA!b ztYcJ%f`Q!V`PN&n( zFD;wcRLrwiCjNZ*)z0s)ygRk+(QH2de0JgL%xu2?c;n(scXmH?Z|RwO-uSrZ$KUJE z&maHrulFDCzs^6Le>wkj{_Xn1^_S~U*WdOJ_AmBN_HXu&_OFBNo#TEw?w3>3{@MQ7 z{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{@MQ7{yF`7-}djl-;Ymq zhxoYupg-sj`h)(UKj;togZ`jD=nwjX{-8hT5Bh`tpg-sj`h)(UKj;toL+mT58T&$N z>JR#Z{-8hT5Bh`tpg-sj`h)(UKj;togZ`jD=nwjX{-8hT5Bh`tpg*L#Lwr2d9pdBq zgZ`jD=nwjX{-8hT5Bh`tpg-sj`h)(UKj;togZ`jD=nwjX{-8hT53w(#X6y^8sXyos z`h)(UKj;s`)E}}|2*thLoSLkU)hgUl@0cDh2rT3{;NW%O=YR)zfCqSh2Y7%7cz_3Z zfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh z2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7 zcz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3ZfCqSh2Y7%7cz_3Z VfCqSh2Y7%7cz_3n+XGoEgugG{m}>w4 literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/0.silo b/toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/p0/0.silo new file mode 100644 index 0000000000000000000000000000000000000000..d90063d6f22af2c8348236f7998e26ccad7fd98b GIT binary patch literal 210944 zcmeI13yfuTeaHWIW=D3HCG0MbrKn{PRA^uyu#5O&H#|0LfGo1K+88q(cJ3}W&fHmM zc5#=qf{zxBjiIRqwWesI#VS^`CTeU6KGIktX-hGv#oAhn)>uQ+V2q{R{?EPlGkX_z zczjv}^m~#!pL5SW-|zR_?>*;tAM>_t7jHdg`N_*imM$$U2M0?zf3&x0{osM`jNbN+ z?b|NCa%6ns&RaXfxBVlln|ofAjz2-+R}edg>yF zv~p!B?>nlW^;?6;{sFBxy_C^%R9R7vtL&Ye+uhz&t5BmX?_KR^ER$Li+j!83!8*3Gg(@9g_b?qtH=0nls|lfBe$fg_rxW>KmN#_4Mv9k5=FDgZkL_nOtA{_Dx?n@+3I1xsE%% z0aeP{g(83L@4ou({+-9Fw0~7{b=GULxmt(j#qibo>_MgdSyKIWHuVA2wgR)1B_?=6`0sHq&g`1K&FPvAVC8 zy#wqIcwL~)s1U6)RiM7+_Aa8@vZ^CJmB9B zc<+E;AMpMG|7pPQ4EWsv9~kia1O8yZ9}f7V0UsQYL&|Z`M3@HBKpIE`X+SmbWPJX* z?HAzl*FE#!KIh5kX5HXx{4@Ep>F2KBabK^SpZOiA_f_kIgRffOUHEFSdG|kjX|XR7 z&pEaK_3F?UiQE2V+4N!O@{y!R{#?A`rjh!~&RF?!tA1#n?@Z5@^XeCek5#vwv$Jzk z^)u~pb^TcGZz#tecK@96tK6UIbSK*T_qMxx=gZkIse+U0t=0SK*_c|I$6_QMZ19d)`>?fvC#i|F@KdMfbUSa=zW)=L3uG)0|7|);2Hcy3^J-%Z9`6o^$x!bL;MpwmkA1Me~DTDQ~K` zh8yZxFjd)n-TLjy->Ob zhkn+%^ow7(wr+bA%>`@vn&&qC_m=nX`^nj?KOsJW$#hq%H<>OtG@0r(wWp@dwCAts z)U&ObJk1Gq-Q@JX_WH7lNz|VfU*;`=Pjf>$rP&*XGxcehL(_V}vt zQx<-kU$eBVxuLeyymoW#oqId==cV@K+{Es=+TmdXVq@=8?!CFxi{+$;4<2lOL~X7c zZq54ux4OEpu&rTTnjar}x5s%&KU6c{+}-!pr{t1;e{;ljxv}@Rl$!^98e#e8U;3Lz zx^SAG6<_sd{fqL@r-3*8#g(`B5B^au>G8pTX%3BnmuIvn0 z`ZMLs=X34zzw{sFpL<*Hzx3vxmeyU1IBdy31T1YXI-83QA4_~JZ7w>Siw+-4 zd@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~J zZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA) zI-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>S ziw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83Q zA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4 zd@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~J zZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA) zI-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>S ziw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83Q zA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4 zd@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~J zZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA) zI-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>S ziw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83Q zA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4 zd@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~J zZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA) zI-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>S ziw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83Q zA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4 zd@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~J zZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA) zI-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>S ziw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83QA4_~JZ7w>Siw+-4d@OA)I-83Q zA4_~JZ7w>Si>_oz18E=)q=7V$2GT$pNCRmg4WxlIkOtB~8b||aAPuB}G>`_;KpIE` zX&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~8b||aAPuB}G>`_;KpIE` zX&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~8b||aAPuB}G>`_;KpIE` zX&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~8b||aAPuB}G>`_;KpIE` zX&?=xfi&<--N5#37hgGY>cpM5c7|`I)!JCf$Y`sza?sA><>>LHwQ78GS=!TWDI1S2 z8xM|_GB#3Lqsy1BE@iY;&S>>rZycE4JmAv^|N6?$zH9qM+b%s}by-r%!AC4B?rN3M zGFC2V)d%N0)3fEg6{YN{$3N4azos))%5imlRlQv|IlZsFzN~8QA6swTsm{!N8DClZ zsnWWPmu}r>zMOv8rgq(O#_*PX^X-ZGJ?)({yUJO^+jq9Rd)srJ?(VXFcyljh<4dZ4 zJ+9h({WJXV^|RgfeA%+-<1?M^M5jB|-d`?Te4okvgMD7T=suIvo!#9sb=c#p#!p#z z>T8ykH8<2_8>zRs_RhVX=}xyjIXAI;u6DR?ZPV3^YoW%Vh$w(i(*Ng2DnsjI$5uV|G|H!E*=ct=zABFmBcJYi&XMHw4DRdWH>0*`nR z&-`33z!yE07vP(gjvaI46XwZ&r|O^6<;KBT{4{*0TKS3(pS8#}md$A(4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2GT$pNCRmg4WxlIkOtB~ z8b||aAPuB}G>`_;KpIE`X&?=xfi#c?(m)zW18E=)q=7V$2A)s@4{q-7{>1s-_cY6A zH(T0VbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c z+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLp zbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t z7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_q zK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9 z_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c z+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLp zbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t z7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_q zK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9 z_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c z+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLp zbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t z7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_q zK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9 z_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c z+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLp zbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t z7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_q zK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9 z_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c z+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLp zbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t z7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7acy9_*mLpbT$_q zK9=}c+FW!t7acy9_*mLpbT$_qK9=}c+FW!t7v1)47hgGY>cpM5c7|^!jEt@*V=I@I zvZNO@UQSrutR8&Cvf{2*87*Vwf>wQSzB4^r&RbE+o~oH?&tKD-D&@GkzN+4?o1EU) zUSC!<_m8c&?o?-HzKjn?bn8Cz<@Ccgwdah29+{WJXV^|RgfeA%+-<1?M^M5jB|-d`?Te4okvgMD7T=suIv zo!#9sb=c#p#!p#jcg@nW=7xG~BlR}d-nq9k-RZU`=O%W~)ehIKZJJ$o+WKZWzxG|9 zNWGPnrJVHe!Gp8?)#h&R*0js5t}ZNWYgm`f+*Z7>RL|wLpuuZ6vG>59_F*+OhxJg+ z{O0PL?mhAeQ(CQ!_3%eqt>!SA=D_pN5iBWJl>IehaQc-?PVet|@?aNu^at+!)P+}FwzZ7C{<71?%4~gj zb@|l0zqhHpxVQR(zq##_@}b`9zu)tZZz(7AR_}ZJYi=oT?5*B%?4Q54)U&e6j{3^y zKUZGcTYdf?-TdWpX>avg4<2=Q`NQ7ojh{T1XHxHHd73n^{i1D`<}IVX7)N~DnfbZC zWxV*Qe9QRhr4Qe;b=$=kmirkGwUj$%zjREijI3R|esH?B%2@AB?u!femrd;IOt(AT zU9-P2m`AO$V&a;qU7MymS2vsI24k+-+`K;yHh-ddd#p=!+<9Q-0p98-$!?Kt?isyH@_}cQzLd5jRv_S9#pkP3to*s za?ggYwVPte^xg-(@^LuUWZE2@AGY+*3Cp^eI3jZsGEHKpWSn>%xT<4ymC* z)%kY5e7mLJYy434&$zZ0?=MGAPp1#iMkPMGir_2^Mo)uJzWe}d|RgVSd zBR`Bv8G|PsZ|!(9IHZN;gv_)!&TNsG<2be9M#Yx#dU14ThL#RBl2CB*x*CNVckH%p zTlq%N=(PNE{x6@nyc(Ms(V5wDGB&jlciZ-KX47%*U;CY#H?Na>qr1l$)eFL05l?;b zefiSQT@7D!#p~rA4wK=V9pSTWPiK7Y?v>x3I0@#O``8NtN~EO(@=yNdyPpYXsv!;U zN_%(eJ!$UNvb-q2ThED*hFPN9KaKC!*Ur21dmFCk>elm3l%KERdFZ^5-OMlub>^xk z<~C}?Yx{Be>gPQF#^Uegt4XukLqE8<8hhPjFxWCaTb#yZDhkC8X`dnOqxzUC{|y#I zzrmf+y0XVkoId5={oh+`Sk7rOk0tv=wxz88zn;mjYxjT0Y~!lVE0Wt*w6w~WZ12j8 zm$$U)WP3}SPE4?eJ*lvAUsOIHmH!--2cq(os5}^z{}Gk1M&)Z!c_=F1h{~TtlVc;uj|!T z!P@=hwS9&B&_LSH%hvMRU{~qlNKuB$s|Ru!KU0vA^19$S|ID0Ey*>N##fFjRXWe1B zt8yD96SldpVw)Q>MK^5oP{lUpT3Xg(e$r)&TFuZles=lz*%jJ+vf=nI3ezDda;uJ} zY}2bCtEKte`nZ;#)X{u^+EFJ#-Zn`+&_VoA4((?Kq%`~^*`F+w_ULD-) z?G^98?Rj5Ar=#NXu`F0$xlJhxuC3T6lk@s~$&Opzm|Rfmn{Td>=3A7K`RxjB&!DH> zOKzr+?kgz6aRJdD+{(ejMQ@gQ$3{m@C#tz`>}Z|?cGStzVCzh&gXw$-#wp&?M|GdK z`9t45DYx`4bHqCA2>y!fjLK7}sQcFYJ~q*fW4aYT`ql8J9D5tM`ENJv4iA2kTRIt8 znz0yo|H@l#@7&b2G3W=6mEuyjWc$!YBP6!Fy`+6gu@E+x8MUicObai{pjr4zlT@FR z-D5X*$ID&bnrph&>g$PC8Do)MawzKRpDA`PU-K_&>D;WX#2#O`t+C!`dmPhbqYO(@ zXTuivm?xJG^TVQ4-tf$Z&NbJp@07&NKW=Q0PF>tAf3axSMe?jp&Jy>P?KjH#I@usM ze)G?_%7Z#NM_wPQyGfo58r;)&_N_7)GqR32I>GSH6EL@1 zg*RrNOJd&M!}rg3^OyGz z-oJSNse|Y}#{=xef@1MMX^Zmj1 z7vGuQ=r;*KnF!maLzHoh||Pod&RS3iB< zhAX3h%ncR@uDvf4- zmX3bb>n}>OLHX#aIwZx{Hbq%HuFBP#CFM0kMK@g>aC`cDWodbRkLwS*1<&u3*7E9L zFYTA<_Vu`wy#6U)ygl!`Md_%xyx;TFo}YDxWqsu~nc=9-wH4cBa$cV=*>TGolM70x z-drQix9G9ObS$_%gI><_-Ap0fS5St_S`67`i(1XFN_p#v=qP$A&O0_bnhz(^8XcH@ec1r_997$2J?-6^4m<&WXkAB~gB>qp zocMxyr=L{eH7z@$tN0XrQZ-!p=+X-J7}XGf00bZa0SG_<0uX=z1Rwwb2tWV=5P$## zAOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;| zfB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U< z00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa z0SG_<0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV= z5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf zKmY;|fB*y_0D-qf;7BTLUb#H_tAnGXqlZ(Uo6&utDL?ha?%%!fo5QKUYkX>8?F&z* zo`31-BfB1WI<;WL{pK`4dm(})bm$QHPbNzAukw4@w&mW$@ynpci#rr4k-+X`Y z{l)hu-{15<(EmdJ6a8=WKhpm?m40X2KW+P$tCjRW)BjBWGyTu>KhytA|1KhytA|8w>K`=%9XYwf1k!t~w;z4CEREM6~(h8mG+!Gkd=bDGTL=y5~+K1bq`kkyVZ zEPA=Ttg06o&~^IV;ubF}a<)!4>bNbF8**Ev(ag`%(a(DQMM;+L!q+xMSv;=F)tV*c zHA6)=T^w+G`g>(*d3}%T54r`R>PJm+21mxWtL;pYp}q^S)b@j*83sJwNUF zS$A00S8kITj@n#Xu}voD_4$$=x4bdAphUO1Mw)NYV~gonaC-*5oaeilLb|V@441VS zvdb2=n&A@VttXFVQHaeOQC(Udy8oGAW$ChqVGo*b~&oL3xpR2B> zNe|AEZKLdDB>-SGX+OJ=vuGTy_iL1tCc-vtPHBy>qudwdR_xwR(SQ)ePNKcF9MhebHFnV!Q_swudrvL^9-j5felx5b@Ry3A7jGDPXD|5$hh=mJ~H0wKK5c~ z*V;95(8hy8*^_^6rX#VIme%NUbtDly z7owOm)#mvaRe#Mq9(B+y_Kfu8+-V6r-*Ehc^%8xyn8A^{Om1LHCSBN)Z+m5~t(>;a z&9yxi9knB7cKLqITr-5TD@LLO)Y*Z8zDPV89+M+y1siPpQnayfr9are!S8?7WA?eE zkMEx+e(T8e@QtQz$5|nnQq6Mv^eok^IM=*G>QJg_ztcQ6%4=3$_eaOotdeAS?UdGf z??sD_tGVPW?>VmK(zdUR9cj2l{z3o(5IAEA95G*mI#9xa>wx=!93ThC0djyGAP2|+ za)2Bl2gm_(fE*wP$N_SI93ThC0djyGAP2|+a)2Bl2gm_(fE*wP$N_SI93ThC0djyG zAP2|+a)2Bl2gm_(fE*wP$N_SI93ThC0djyGAP2|+a)2Bl2gm_(fE*wP$N_SI93ThC z0djyGAP2|+a)2Bl2gm_(fE*wP$N_SI93ThC0djyGAP2|+a)2Bl2gm_(fE*wP$N_SI z93ThC0djyGAP2|+a)2Bl2gm_(fE*wP$N_SI93ThC0djyGAP2|+a)2Bl2mUZ~;C}&^ CK=}v& literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_0.silo b/toolchain/mfc/viz/fixtures/3d_silo/silo_hdf5/root/collection_0.silo new file mode 100644 index 0000000000000000000000000000000000000000..65dd70f157194a8e86ec97d0c63a2be48b545fb8 GIT binary patch literal 15063 zcmeHO3vg7`8UAmQB~MJUhBwM4A&Nma`^e^j(!`K#!vu#2)ES0xLIRtxC1%4WJVJ{V zN)=E66$d3UWU!1nP?;8OahM3G;}jv&Hf@SLN=LLsY1KH49mdXt{^#-E-Mj3)OPJVU zH#wQy|D6B+=W)OLpYJ^G+0E6J?$Id|Q{s}6fWkEyPR|e;o4nyX_2AXH)s^$&EOqbh z@dp2|i8C{sc@|;0XsnPaai9QVMPyf&LolQJ>@UP%;nEtXY^2Bl2} zh=)W-!*)S?y}!xR4jK6Pn8IKYZN(TXp>`Bk0%0H?wE7OAJu4*yz=PV}&&|9^*}(|0 zI4DXMK%Al|$s#+Dj86jvD`RM3NCFcA7oU=ZpvEgyHrc{qZWNW5oGS-sM#3glJ{d_P zY?3AJWE;)cluv%6#XWz16?|q92q@RkA(%((f!&)VDAG9~^A{fD!CE+96 zXok;o!1Z2uCK${5v6mARfK>%D|4*l05{XEVie5>*6Z<6Vtq2|QMR0GOBq0^|u>2_Q zt)Jbo>CD_&)iwCMrr_sgcoyXf?1~8-*xPIV9l9slYxuRL+1s+b?K*xPgW3OS%Uc%- z`#VK7K+?#ar%3F{Hkz?NdTI5K8J1(r%*|*-CJ8@~u-vrxPjb(PEb~L|S(a5!s|}Vd zZFJq|u-Y(MXtiU67j3l`wAu=6);4dmZ-(Eqq}_|J(^K!SYw~-S!iI>|_m>HifYg*& z-`u*azRthQXNPZ9M>?kF(b%r?GzVJ+st&bs1eK+4<&13A>i4v9t6eX%@J{%4@n;Bb zS>Ra%wU5L`5EwyV1c4C*Mi98A5QsGYqEVTo(cE`t49$I?Sbub-E;dtf70;IO@=eMu z0=aqzpmcl|D4CWrxqvT+!c-`{77v=${eu!SG2OxfyKX7>2-}$2;?6A`o9p(0_)_ba za;a5A_`rpM<2`x{iPQ4Q0}Bbu>tp!TRINK)*6{Pok1lJ*&g$URA{N5&S3nT=uaGLzSm96qdQt%DWns!f1ds~}#tq0PAZ#?E{Th`vu ziWA0++p)TI{Aa3`dc49EQpxOKB?a3SV=&P_bDaV{+b+npvEa}q?b@Uh((YzYoFewJ z*b~>WR%o7*)scjk#aqrls_W8nV`@CwvOv;kxwS&NF4;!&ax1BK)Z+#&ZIN9%uzT=; z>t`Dgr$~5G7a(awpPOTdK3~i~+*Prlx)S>$dLG-JTRN>Mn|T(%X#5jl(bsRElu;>k z@eH9{=F=;TDicfPr}V!<3Ym{!lp zHGvN^n8AE?&25m28O`wV^Ro(I9goiJKWK-AJo?s4mt0WFZSVTSYgNFyuNl7Vy|9rt z)eI9-^Pd9z8iv}`zHwV%InQ?b=|wwXJ=g9`KeP)N6PRJ~!hzQylN%&{bo>ZB%C)(P zXS?A9kLGo5|2@?3=&|A%9|3-}z-(K8Q*{!~@#x?B6MNtV9-VUJ)n53LNAG|5;yG~f zaXK|mVh$5wb4c`;Gizr8D|4(KM zAE%n}z41VjEJJ;KE3VX8Am1k0aAdAq`1s>rWM+UHO~%2Kxn1|b`xs3J&&O+*Lk>nW zV0rg1R^VC`(2XyxOj!+Yaf7W*w{L)UZt$xUeNV$ij81}Y^U9xtevD>9-_C*^(93Pt z?|9%9-U&v-io6fLfls*he9iXnxMPolipwt#fCIHz@aKCvS}`&bzJCPRY-Bi0a7(l^ z+G1_8bTAX#@{WOpx)X?-nBcx2Lla!zj)C=&upzo4X*5~wi=oNtZztsZR2Q46+KpHm zLkJ^E-^i}OnJAoiUi>uY$}piKyLRW(Zqrba-7y#{>U4Kx>$=oUP|+7LL`CO(-LFQ% zhPoF?BP#lD3{lZn-=)vi#byYp=vov~(ex?lj|>+o((d!womwh#4Tg%w`wPo;UFs&N zC|#Zsj)iT%T}LZcMZ$(Ch@=q}WlL;u$tc2MibS6*+xKseE;d6*MR^i4bx*77*2k)A z?FWm73l(WsWcDI06_pHzib`^>^ys>jq#_hNkmTJBHKO}zf4AV0u0yub`~{+5i+5Bc zY>0wL8d1^g7^0$@bDQdPu^B=tdMFC1$i35J3Xe^hg8rd7RX+^&lN=$dwo(mUIqZX> ztG{}>C%daFE7TvIF=Dz_1&b|~#2^?_$`#7>sU?Y4Zk`xIfIV{O3~iO-;d%Slieu{bTmYL)Uc_A9dzPmy+&a2ZxG=*9DAsUx$dWfdz p=l6wZiucHGziRCzM+!o0N^fId9WaoM!cl7GY(OcOeRK|wzSmvsGZuhCbo%WoOEnDgZ5(f&vSQ8{b9MjAd>LI*t&ug>kG$|tk;H5-S*J4?vXi(Zz zfOtrRG;9~N*ZZ41?T~?wk0}fm(N>JH5^6_rB@hPUL96c(+H+Du06eJe{nX5xlpTx^ zi-V$M0mLbak}R?V$@nx-urh`gh9oc{aPcWg2x`1SWs@x&;YLw;$+>oDW+ZG<<&%*# z!X{bbPPWmEP5IwpHXB|RS9TLdHcvLt+D z8_n=}4!YhA&je#xKlXBh0~n!PQ{+iv35F_`@yw!C$b zu)kMS10;>yd5XlIY@-?bW0zNdhhaI!%-oDdWRmbL3CnGZe<$~Rz%oDXo@H6(wAx_V z(ni-q4yz5Lg;qO8c+pmCL94C6Zf)~6`)2q(OWM8oIz9FNx+cGODQt*XeSeuS2}n(e z_06rz>g)W=e0KOsb)@5J9*ylPPjj#pK+S)4bzNQBUKVQQ2r4Vkz)&k^WUE%cr;S_f z-o?V(;oHTZA-H9MXARUo5*tBa1c4C*Mi3Z5;EqBd()^1?Ws*j7-K%a6@mZi`TFT@Cz8nfuq3}jLXjb=+O3cJ`3k&SJque8GV`_^# zw{UE3+6UrGtzXEcRt@0;7Y9!C=q)79$R`ghBrLCv;Zsw!?r>Sd&x_N(U^_b|TruOM znax?D2-mhVv80xUX5SKAhiDceT<7z9o4hTM5WK=^^7~e{${R_+H#}?FJuU5RZQivW zNDIF4w5M%Zdq*oy7&Gq1>eBI_saops3R6fWvxAisY+H=MME}fn3ixchAlJr%Lz}c~ zlTJvxpFMGk*vn#1T*q3Wc}iAC5?&T>x$u;(OUsR^@o38eNu%Y~3hBCJ8_mnDq~1}_ z7`U`WcIm+G!6UBkZ$z9T;YnS9q!E2?jv@MdA^%8M#e(We?2G7mYR>?UT3;y!8#)-PlFUeVst3c9-ocBm4C);Qy=SlZx zz7fvFm|vd2@sEXoU*;c3*e`UE@Fd44X++M4Vu+mQoHeBy5JQF}h5(r?*s!${A=Slm z2+8?y6q56&Pju(F=T+D8Z!U45jw^wE+ZDngLEXlt@rtCp&vwOcB_!YigzSX(T5u>l zQ$o6`ef15Y!|yz~Ej|XQmhsQO{egQv4$?sFqn<#L%1eM+SOXSJiaXu1COPZ?$#wU> zwzy)!95Op-e0|Mbkc%13@bS-P6~Hf>8+rOpERHpzygbKSznpZP2^1Kemb4t|u|^#Htw z(RA>9ymmR{U^D}kcmHq&u0;Xe`0~n>)$lqu*xGdW259F7zdG6X9BjnsB={lEF|>NzSz%U6+zngn|c>yt|=BbU*Fy7F^PG z$Tph4Koo58j*5g0Q4mQZDw-WbR8(_*Q=Kk0Lr6uBM2#h1w0mjWq) z1MYtrNA?NqBzvm-2%`W?Iu2;=I(pRpTmC z-=ZwJXq)`$qiR$9{DhEPB^TtU1l6YW!O2=1yX|RYP8P_7~@ul)9_)?RY7AjGEhF80*{1KBui`OXlH-CZa&|M% maps to LaTeX subscript.""" + self.assertIn('2', self._label('alpha2')) + + def test_unknown_passthrough(self): + """Unknown variable names pass through unchanged.""" + self.assertEqual(self._label('my_custom_var'), 'my_custom_var') + + +# --------------------------------------------------------------------------- +# Tests: discover_format +# --------------------------------------------------------------------------- + +class TestDiscoverFormat(unittest.TestCase): + """Test discover_format() binary/silo detection.""" + + def test_binary_detection(self): + """Detects binary format from binary/ directory.""" + from .reader import discover_format + self.assertEqual(discover_format(FIX_1D_BIN), 'binary') + + def test_silo_detection(self): + """Detects silo format from silo_hdf5/ directory.""" + from .reader import discover_format + self.assertEqual(discover_format(FIX_1D_SILO), 'silo') + + def test_missing_dir_raises(self): + """Missing directories raise FileNotFoundError.""" + from .reader import discover_format + d = tempfile.mkdtemp() + try: + with self.assertRaises(FileNotFoundError): + discover_format(d) + finally: + os.rmdir(d) + + +# --------------------------------------------------------------------------- +# Tests: discover_timesteps +# --------------------------------------------------------------------------- + +class TestDiscoverTimesteps(unittest.TestCase): + """Test discover_timesteps() against fixture data.""" + + def test_binary_1d(self): + """Finds sorted timesteps from 1D binary fixture.""" + from .reader import discover_timesteps + steps = discover_timesteps(FIX_1D_BIN, 'binary') + self.assertEqual(steps, sorted(steps)) + self.assertIn(0, steps) + self.assertGreater(len(steps), 1) + + def test_silo_1d(self): + """Finds sorted timesteps from 1D silo fixture.""" + from .reader import discover_timesteps + steps = discover_timesteps(FIX_1D_SILO, 'silo') + self.assertEqual(steps, sorted(steps)) + self.assertIn(0, steps) + self.assertGreater(len(steps), 1) + + +# --------------------------------------------------------------------------- +# Tests: binary read + assemble (1D, 2D, 3D) +# --------------------------------------------------------------------------- + +class TestAssembleBinary1D(unittest.TestCase): + """Test binary reader with 1D fixture data.""" + + def test_ndim(self): + """1D fixture assembles with ndim=1.""" + from .reader import assemble + data = assemble(FIX_1D_BIN, 0, 'binary') + self.assertEqual(data.ndim, 1) + + def test_grid_and_vars(self): + """1D fixture has non-empty grid and expected variables.""" + from .reader import assemble + data = assemble(FIX_1D_BIN, 0, 'binary') + self.assertGreater(len(data.x_cc), 0) + self.assertIn('pres', data.variables) + self.assertIn('vel1', data.variables) + self.assertEqual(data.variables['pres'].shape, data.x_cc.shape) + + def test_var_filter(self): + """Passing var= loads only that variable.""" + from .reader import assemble + data = assemble(FIX_1D_BIN, 0, 'binary', var='pres') + self.assertIn('pres', data.variables) + self.assertNotIn('vel1', data.variables) + + +class TestAssembleBinary2D(unittest.TestCase): + """Test binary reader with 2D fixture data.""" + + def test_ndim(self): + """2D fixture assembles with ndim=2.""" + from .reader import assemble + data = assemble(FIX_2D_BIN, 0, 'binary') + self.assertEqual(data.ndim, 2) + + def test_grid_shape(self): + """2D fixture has 2D variable arrays matching grid.""" + from .reader import assemble + data = assemble(FIX_2D_BIN, 0, 'binary') + self.assertGreater(len(data.x_cc), 0) + self.assertGreater(len(data.y_cc), 0) + pres = data.variables['pres'] + self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc))) + + +class TestAssembleBinary3D(unittest.TestCase): + """Test binary reader with 3D fixture data.""" + + def test_ndim(self): + """3D fixture assembles with ndim=3.""" + from .reader import assemble + data = assemble(FIX_3D_BIN, 0, 'binary') + self.assertEqual(data.ndim, 3) + + def test_grid_shape(self): + """3D fixture has 3D variable arrays matching grid.""" + from .reader import assemble + data = assemble(FIX_3D_BIN, 0, 'binary') + self.assertGreater(len(data.x_cc), 0) + self.assertGreater(len(data.y_cc), 0) + self.assertGreater(len(data.z_cc), 0) + pres = data.variables['pres'] + self.assertEqual(pres.shape, + (len(data.x_cc), len(data.y_cc), len(data.z_cc))) + + +# --------------------------------------------------------------------------- +# Tests: silo read + assemble (1D, 2D, 3D) +# --------------------------------------------------------------------------- + +class TestAssembleSilo1D(unittest.TestCase): + """Test silo reader with 1D fixture data.""" + + def test_ndim(self): + """1D silo fixture assembles with ndim=1.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_1D_SILO, 0) + self.assertEqual(data.ndim, 1) + + def test_grid_and_vars(self): + """1D silo fixture has non-empty grid and expected variables.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_1D_SILO, 0) + self.assertGreater(len(data.x_cc), 0) + self.assertIn('pres', data.variables) + self.assertEqual(data.variables['pres'].shape, data.x_cc.shape) + + +class TestAssembleSilo2D(unittest.TestCase): + """Test silo reader with 2D fixture data.""" + + def test_ndim(self): + """2D silo fixture assembles with ndim=2.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_2D_SILO, 0) + self.assertEqual(data.ndim, 2) + + def test_grid_shape(self): + """2D silo fixture has 2D variable arrays matching grid.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_2D_SILO, 0) + pres = data.variables['pres'] + self.assertEqual(pres.shape, (len(data.x_cc), len(data.y_cc))) + + +class TestAssembleSilo3D(unittest.TestCase): + """Test silo reader with 3D fixture data.""" + + def test_ndim(self): + """3D silo fixture assembles with ndim=3.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_3D_SILO, 0) + self.assertEqual(data.ndim, 3) + + def test_grid_shape(self): + """3D silo fixture has 3D variable arrays matching grid.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_3D_SILO, 0) + pres = data.variables['pres'] + self.assertEqual(pres.shape, + (len(data.x_cc), len(data.y_cc), len(data.z_cc))) + + +# --------------------------------------------------------------------------- +# Tests: binary vs silo consistency +# --------------------------------------------------------------------------- + +class TestBinarySiloConsistency(unittest.TestCase): + """Verify binary and silo readers produce consistent results.""" + + def test_1d_same_grid(self): + """Binary and silo 1D fixtures have the same grid.""" + from .reader import assemble + from .silo_reader import assemble_silo + import numpy as np + bin_data = assemble(FIX_1D_BIN, 0, 'binary') + silo_data = assemble_silo(FIX_1D_SILO, 0) + np.testing.assert_allclose(bin_data.x_cc, silo_data.x_cc, atol=1e-10) + + def test_1d_same_vars(self): + """Binary and silo 1D fixtures have the same variable names.""" + from .reader import assemble + from .silo_reader import assemble_silo + bin_data = assemble(FIX_1D_BIN, 0, 'binary') + silo_data = assemble_silo(FIX_1D_SILO, 0) + self.assertEqual(sorted(bin_data.variables.keys()), + sorted(silo_data.variables.keys())) + + +# --------------------------------------------------------------------------- +# Tests: 1D rendering (requires matplotlib/imageio) +# --------------------------------------------------------------------------- + +class TestRender1D(unittest.TestCase): + """Smoke test: render 1D plots from fixture data.""" + + def test_render_png(self): + """Renders a single-variable PNG that is non-empty.""" + from .reader import assemble + from .renderer import render_1d + data = assemble(FIX_1D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_1d(data.x_cc, data.variables['pres'], 'pres', 0, out) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + def test_render_tiled_png(self): + """Tiled render of all variables produces a non-empty PNG.""" + from .reader import assemble + from .renderer import render_1d_tiled + data = assemble(FIX_1D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_1d_tiled(data.x_cc, data.variables, 0, out) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + +class TestRender2D(unittest.TestCase): + """Smoke test: render a 2D PNG from fixture data.""" + + def test_render_2d_png(self): + """Renders a 2D colormap PNG that is non-empty.""" + from .reader import assemble + from .renderer import render_2d + data = assemble(FIX_2D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_2d(data.x_cc, data.y_cc, data.variables['pres'], + 'pres', 0, out) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + +class TestRender3DSlice(unittest.TestCase): + """Smoke test: render a 3D slice PNG from fixture data.""" + + def test_render_3d_slice_png(self): + """Renders a 3D midplane-slice PNG that is non-empty.""" + from .reader import assemble + from .renderer import render_3d_slice + data = assemble(FIX_3D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_3d_slice(data, 'pres', 0, out) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + +if __name__ == "__main__": + unittest.main() diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index fcce066dbe..2c33d0e627 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -23,6 +23,9 @@ def _parse_steps(step_arg, available_steps): if step_arg is None or step_arg == 'all': return available_steps + if step_arg == 'last': + return [available_steps[-1]] if available_steps else [] + try: if ':' in str(step_arg): parts = str(step_arg).split(':') @@ -35,7 +38,7 @@ def _parse_steps(step_arg, available_steps): single = int(step_arg) except ValueError as exc: raise MFCException(f"Invalid --step value '{step_arg}'. " - "Expected an integer, a range (start:end:stride), or 'all'.") from exc + "Expected an integer, a range (start:end:stride), 'last', or 'all'.") from exc if available_steps and single not in set(available_steps): return [] @@ -80,8 +83,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc " [dim]see available timesteps[/dim]") cons.print(f" [green]./mfc.sh viz {d} --list-vars --step 0[/green]" " [dim]see available variables[/dim]") - cons.print(f" [green]./mfc.sh viz {d} --var pres --step 0[/green]" - " [dim]render a PNG[/dim]") + cons.print(f" [green]./mfc.sh viz {d} --var pres --step last[/green]" + " [dim]render a PNG[/dim]") cons.print(f" [green]./mfc.sh viz {d} --var pres --step all --mp4[/green]" " [dim]render an MP4[/dim]") cons.print(f" [green]./mfc.sh viz {d} --var pres --step 0 --slice-axis z[/green]" @@ -118,12 +121,15 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc if step_arg is None or step_arg == 'all': step = steps[0] cons.print(f"[dim]Using first available timestep: {step}[/dim]") + elif step_arg == 'last': + step = steps[-1] + cons.print(f"[dim]Using last available timestep: {step}[/dim]") else: try: step = int(step_arg) except ValueError as exc: raise MFCException(f"Invalid --step value '{step_arg}'. " - "Expected an integer or 'all'.") from exc + "Expected an integer, 'last', or 'all'.") from exc if step not in steps: raise MFCException( f"Timestep {step} not found. Available range: " From d94f4374d9eddc7a0f68f685a27029f07d052e18 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 00:17:23 -0500 Subject: [PATCH 036/102] Fix ghost-cell dedup, 3D memory guard, and interactive host binding - reader.py: use scale-aware rounding in assemble_from_proc_data to correctly deduplicate ghost-cell overlaps across all domain scales - viz.py: refuse to load >500 timesteps for 3D data to prevent OOM - interactive.py: bind Dash server to 127.0.0.1 instead of 0.0.0.0 Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 2 +- toolchain/mfc/viz/reader.py | 30 ++++++++++++++++++++---------- toolchain/mfc/viz/viz.py | 6 ++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index c77015eb73..9d19f2a336 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -611,4 +611,4 @@ def _tf(arr): return arr f'[bold]http://localhost:{port}[/bold]') cons.print(f'[dim]SSH tunnel: ssh -L {port}:localhost:{port} [/dim]') cons.print('[dim]Ctrl+C to stop.[/dim]\n') - app.run(debug=False, port=port, host='0.0.0.0') + app.run(debug=False, port=port, host='127.0.0.1') diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index aac642a0ad..6e13f2f0fd 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -278,7 +278,7 @@ def _is_1d(case_dir: str) -> bool: return os.path.isdir(os.path.join(case_dir, 'binary', 'root')) -def assemble_from_proc_data( # pylint: disable=too-many-locals +def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-statements proc_data: List[Tuple[int, ProcessorData]], ) -> AssembledData: """ @@ -315,19 +315,29 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals z_cc = (pd.z_cb[:-1] + pd.z_cb[1:]) / 2.0 if pd.p > 0 else np.array([0.0]) proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) - # Build unique sorted global coordinate arrays (handles ghost overlap) + # Build unique sorted global coordinate arrays (handles ghost overlap). + # Use scale-aware rounding: 12 significant digits relative to the domain + # extent, so precision is preserved for both micro-scale and large domains. + def _dedup(arr): + extent = arr.max() - arr.min() + if extent > 0: + decimals = max(0, int(np.ceil(-np.log10(extent))) + 12) + else: + decimals = 12 + return np.unique(np.round(arr, decimals)), decimals + all_x = np.concatenate([xc for _, _, xc, _, _ in proc_centers]) - global_x = np.unique(np.round(all_x, 12)) + global_x, xdec = _dedup(all_x) if ndim >= 2: all_y = np.concatenate([yc for _, _, _, yc, _ in proc_centers]) - global_y = np.unique(np.round(all_y, 12)) + global_y, ydec = _dedup(all_y) else: - global_y = np.array([0.0]) + global_y, ydec = np.array([0.0]), 12 if ndim >= 3: all_z = np.concatenate([zc for _, _, _, _, zc in proc_centers]) - global_z = np.unique(np.round(all_z, 12)) + global_z, zdec = _dedup(all_z) else: - global_z = np.array([0.0]) + global_z, zdec = np.array([0.0]), 12 varnames = sorted({vn for _, pd in proc_data for vn in pd.variables}) nx, ny, nz = len(global_x), len(global_y), len(global_z) @@ -343,9 +353,9 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals # Place each processor's data using per-cell coordinate lookup for _rank, pd, x_cc, y_cc, z_cc in proc_centers: - xi = np.clip(np.searchsorted(global_x, np.round(x_cc, 12)), 0, nx - 1) - yi = np.clip(np.searchsorted(global_y, np.round(y_cc, 12)), 0, ny - 1) if ndim >= 2 else np.array([0]) - zi = np.clip(np.searchsorted(global_z, np.round(z_cc, 12)), 0, nz - 1) if ndim >= 3 else np.array([0]) + xi = np.clip(np.searchsorted(global_x, np.round(x_cc, xdec)), 0, nx - 1) + yi = np.clip(np.searchsorted(global_y, np.round(y_cc, ydec)), 0, ny - 1) if ndim >= 2 else np.array([0]) + zi = np.clip(np.searchsorted(global_z, np.round(z_cc, zdec)), 0, nz - 1) if ndim >= 3 else np.array([0]) for vn, data in pd.variables.items(): if vn not in global_vars: diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 2c33d0e627..a29e4d321c 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -213,6 +213,12 @@ def read_step(step): test_assembled = read_step(requested_steps[0]) avail = sorted(test_assembled.variables.keys()) + # Guard against loading too many 3D timesteps (memory) + if test_assembled.ndim == 3 and len(requested_steps) > 500: + raise MFCException( + f"Refusing to load {len(requested_steps)} timesteps for 3D data " + "(limit is 500). Use --step with a range or stride to reduce.") + # Tiled mode only works for 1D if tiled and not interactive: if test_assembled.ndim != 1: From 32cbd52827454b0de2e56bcad625e8e6b3101db9 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 01:24:55 -0500 Subject: [PATCH 037/102] Add terminal TUI (--tui) for 1D/2D interactive visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Textual-based terminal UI that works over SSH with no browser or port-forwarding. Features: variable sidebar, step navigation (,/./←/→), viridis heatmap with colorbar for 2D, colormap cycling with [c] (viridis/plasma/inferno/magma/cividis/coolwarm/RdBu_r/seismic/gray). Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 8 + toolchain/mfc/viz/tui.py | 373 ++++++++++++++++++++++++++++++++++ toolchain/mfc/viz/viz.py | 33 ++- toolchain/pyproject.toml | 3 + 4 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 toolchain/mfc/viz/tui.py diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 1819d60af0..9ac6673086 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -1031,6 +1031,12 @@ default=8050, metavar="PORT", ), + Argument( + name="tui", + help="Launch an interactive terminal UI (1D/2D only). Works over SSH with no browser.", + action=ArgAction.STORE_TRUE, + default=False, + ), ], examples=[ Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Plot pressure at step 1000"), @@ -1038,6 +1044,7 @@ Example("./mfc.sh viz case_dir/ --list-steps", "List available timesteps"), Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video"), Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z", "3D slice at z midplane"), + Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D)"), ], key_options=[ ("--var NAME", "Variable to visualize"), @@ -1046,6 +1053,7 @@ ("--list-steps", "List available timesteps"), ("--mp4", "Generate MP4 video"), ("--interactive / -i", "Launch interactive Dash web UI"), + ("--tui", "Launch terminal UI (1D/2D, works over SSH)"), ("--cmap NAME", "Matplotlib colormap"), ("--slice-axis x|y|z", "Axis for 3D slice"), ], diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py new file mode 100644 index 0000000000..c1cc1ad295 --- /dev/null +++ b/toolchain/mfc/viz/tui.py @@ -0,0 +1,373 @@ +""" +Terminal UI (TUI) for MFC visualization using Textual + plotext. + +Launched via ``./mfc.sh viz --tui [--var VAR] [--step STEP]``. +Opens a full-terminal interactive viewer that works over SSH with no +browser or port-forwarding required. + +Supports 1D line plots and 2D heatmaps only. + +Requires: textual, textual-plotext, plotext +""" +from __future__ import annotations + +from typing import Callable, Dict, List, Optional + +import numpy as np + +from rich.color import Color as RichColor +from rich.console import Group as RichGroup +from rich.style import Style +from rich.text import Text as RichText + +from textual import on +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.widgets import Footer, Header, Label, ListItem, ListView, Static + +from textual_plotext import PlotextPlot + +from mfc.printer import cons + +# Colormaps available via [c] cycling +_CMAPS: List[str] = [ + 'viridis', 'plasma', 'inferno', 'magma', 'cividis', + 'coolwarm', 'RdBu_r', 'seismic', 'gray', +] + +# --------------------------------------------------------------------------- +# Step cache {step -> AssembledData} +# --------------------------------------------------------------------------- +_cache: Dict[int, object] = {} + + +def _load(step: int, read_func: Callable) -> object: + if step not in _cache: + _cache[step] = read_func(step) + return _cache[step] + + +# --------------------------------------------------------------------------- +# Plot widget +# --------------------------------------------------------------------------- + +class MFCPlot(PlotextPlot): + """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim / + ._varname / ._step before calling .refresh().""" + + DEFAULT_CSS = """ + MFCPlot { + border: solid $accent; + width: 1fr; + height: 1fr; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._x_cc: Optional[np.ndarray] = None + self._y_cc: Optional[np.ndarray] = None + self._data: Optional[np.ndarray] = None + self._ndim: int = 1 + self._varname: str = "" + self._step: int = 0 + self._cmap_name: str = _CMAPS[0] + + def render(self): # pylint: disable=too-many-branches,too-many-locals + data = self._data + x_cc = self._x_cc + self.plt.clear_figure() + + # 1D: use normal plotext path — gives proper axes and title for free. + if data is None or x_cc is None or self._ndim == 1: + if data is not None and x_cc is not None: + self.plt.plot(x_cc.tolist(), data.tolist()) + self.plt.xlabel("x") + self.plt.ylabel(self._varname) + self.plt.title(f"{self._varname} (step {self._step})") + else: + self.plt.title("No data loaded") + return super().render() + + # 2D: pure-Rich heatmap with vertical colorbar. + import matplotlib.cm as mcm # pylint: disable=import-outside-toplevel + import matplotlib.colors as mcolors # pylint: disable=import-outside-toplevel + + # Content area = widget size minus 1-char border on each side. + # Reserve 1 row each for header and footer → h_plot rows for the image. + w_plot = max(self.size.width - 2, 4) + h_plot = max(self.size.height - 4, 4) # -2 border, -2 header+footer + + # Right side: gap + gradient strip + value labels + _CB_GAP, _CB_W, _CB_LBL = 1, 2, 9 + w_map = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) + + ix = np.linspace(0, data.shape[0] - 1, w_map, dtype=int) + iy = np.linspace(0, data.shape[1] - 1, h_plot, dtype=int) + ds = data[np.ix_(ix, iy)] # pylint: disable=unsubscriptable-object + + vmin, vmax = float(ds.min()), float(ds.max()) + cmap = mcm.get_cmap(self._cmap_name) + norm = mcolors.Normalize(vmin=vmin, vmax=vmax) + # Transpose + flip so y=0 appears at the bottom of the display. + rgba = cmap(norm(ds.T[::-1])) # (h_plot, w_map, 4) + + lines = [] + for row in range(h_plot): + line = RichText() + # Heatmap cells — one terminal character per data point. + for col in range(w_map): + r = int(rgba[row, col, 0] * 255) + g = int(rgba[row, col, 1] * 255) + b = int(rgba[row, col, 2] * 255) + line.append(" ", style=Style(bgcolor=RichColor.from_rgb(r, g, b))) + # Gap + line.append(" " * _CB_GAP) + # Colorbar gradient strip (t=1 at top = vmax, t=0 at bottom = vmin) + t = 1.0 - row / max(h_plot - 1, 1) + cb = cmap(t) + cr, cg, cbb = int(cb[0] * 255), int(cb[1] * 255), int(cb[2] * 255) + for _ in range(_CB_W): + line.append(" ", style=Style(bgcolor=RichColor.from_rgb(cr, cg, cbb))) + # Value labels at top, middle, bottom + if row == 0: + lbl = f" {vmax:.3g}" + elif row == h_plot - 1: + lbl = f" {vmin:.3g}" + elif row == h_plot // 2: + lbl = f" {(vmin + vmax) / 2:.3g}" + else: + lbl = "" + line.append(lbl.ljust(_CB_LBL)[:_CB_LBL]) + lines.append(line) + + y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) + header = RichText( + f" {self._varname} (step {self._step})" + f" [{vmin:.3g}, {vmax:.3g}]", + style="bold" + ) + footer = RichText( + f" x: [{x_cc[0]:.3f} \u2026 {x_cc[-1]:.3f}]" # pylint: disable=unsubscriptable-object + f" y: [{y_cc[0]:.3f} \u2026 {y_cc[-1]:.3f}]", + style="dim" + ) + return RichGroup(header, *lines, footer) + + +# --------------------------------------------------------------------------- +# Main TUI app +# --------------------------------------------------------------------------- + +class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes + """Textual TUI for MFC post-processed data.""" + + CSS = """ + Screen { + layers: base; + } + + #content { + height: 1fr; + layout: horizontal; + } + + #sidebar { + width: 22; + border-right: solid $accent; + padding: 0 1; + } + + #var-title { + text-style: bold; + color: $accent; + padding: 0 0 1 0; + } + + #var-list { + height: 1fr; + } + + #status { + dock: bottom; + height: 1; + background: $panel; + color: $text-muted; + padding: 0 1; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("comma", "prev_step", "◀ step"), + Binding("period", "next_step", "step ▶"), + Binding("left", "prev_step", "◀ step", show=False), + Binding("right", "next_step", "step ▶", show=False), + Binding("c", "cycle_cmap", "cmap"), + ] + + step_idx: reactive[int] = reactive(0, always_update=True) + var_name: reactive[str] = reactive("", always_update=True) + cmap_name: reactive[str] = reactive(_CMAPS[0], always_update=True) + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + steps: List[int], + varnames: List[str], + read_func: Callable, + ndim: int, + init_var: Optional[str] = None, + **kwargs, + ): + super().__init__(**kwargs) + self._steps = steps + self._varnames = varnames + self._read_func = read_func + self._ndim = ndim + # Store init_var but don't set the reactive yet — the DOM doesn't exist + # until on_mount, and the watcher calls query_one which needs the DOM. + self._init_var = init_var or (varnames[0] if varnames else "") + + def compose(self) -> ComposeResult: + yield Header(show_clock=False) + with Horizontal(id="content"): + with Vertical(id="sidebar"): + yield Label("Variables", id="var-title") + yield ListView( + *[ListItem(Label(v), id=f"var-{v}") for v in self._varnames], + id="var-list", + ) + yield MFCPlot(id="plot") + yield Static(self._status_text(), id="status") + yield Footer() + + def on_mount(self) -> None: + # DOM is ready — now safe to set the reactive (fires watcher → _push_data) + self.var_name = self._init_var + # Highlight the initial variable in the sidebar list + lv = self.query_one("#var-list", ListView) + for i, v in enumerate(self._varnames): + if v == self.var_name: + lv.index = i + break + + # ------------------------------------------------------------------ + # Reactive watchers + # ------------------------------------------------------------------ + + def watch_step_idx(self, _old: int, _new: int) -> None: + self._push_data() + + def watch_var_name(self, _old: str, _new: str) -> None: + self._push_data() + + def watch_cmap_name(self, _old: str, _new: str) -> None: + self._push_data() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _status_text(self) -> str: + step = self._steps[self.step_idx] if self._steps else 0 + total = len(self._steps) + return ( + f" step {step} [{self.step_idx + 1}/{total}]" + f" var: {self.var_name}" + f" cmap: {self.cmap_name}" + f" [,] prev [.] next [c] cmap" + ) + + def _push_data(self) -> None: + """Load the current step/var and push data into the plot widget.""" + if not self._steps or not self.var_name: + return + step = self._steps[self.step_idx] + try: + assembled = _load(step, self._read_func) + except Exception as exc: # pylint: disable=broad-except + self.query_one("#status", Static).update( + f" [red]Error loading step {step}: {exc}[/red]" + ) + return + + data = assembled.variables.get(self.var_name) + plot = self.query_one("#plot", MFCPlot) + plot._x_cc = assembled.x_cc # pylint: disable=protected-access + plot._y_cc = assembled.y_cc # pylint: disable=protected-access + plot._data = data # pylint: disable=protected-access + plot._ndim = self._ndim # pylint: disable=protected-access + plot._varname = self.var_name # pylint: disable=protected-access + plot._step = step # pylint: disable=protected-access + plot._cmap_name = self.cmap_name # pylint: disable=protected-access + plot.refresh() + + self.query_one("#status", Static).update(self._status_text()) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + @on(ListView.Selected, "#var-list") + def on_var_selected(self, event: ListView.Selected) -> None: + item_id = event.item.id or "" + if item_id.startswith("var-"): + self.var_name = item_id[4:] + + def action_prev_step(self) -> None: + if self.step_idx > 0: + self.step_idx -= 1 + + def action_next_step(self) -> None: + if self.step_idx < len(self._steps) - 1: + self.step_idx += 1 + + def action_cycle_cmap(self) -> None: + idx = (_CMAPS.index(self.cmap_name) + 1) % len(_CMAPS) + self.cmap_name = _CMAPS[idx] + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def run_tui( + init_var: Optional[str], + steps: List[int], + read_func: Callable, + ndim: int, +) -> None: + """Launch the Textual TUI for MFC visualization (1D/2D only).""" + if ndim not in (1, 2): + raise ValueError( + f"--tui only supports 1D and 2D data (got ndim={ndim}). " + "Use --interactive for 3D data." + ) + + # Preload first step to discover variables + first = _load(steps[0], read_func) + varnames = sorted(first.variables.keys()) + if not varnames: + raise ValueError("No variables found in data") + if init_var not in varnames: + init_var = varnames[0] + + cons.print( + f"[bold]Launching TUI[/bold] — {len(steps)} step(s), " + f"{len(varnames)} variable(s)" + ) + cons.print("[dim] ,/. or ←/→ prev/next step • ↑↓ select variable • q quit[/dim]") + + _cache.clear() + _cache[steps[0]] = first + + app = MFCTuiApp( + steps=steps, + varnames=varnames, + read_func=read_func, + ndim=ndim, + init_var=init_var, + ) + app.run() diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index a29e4d321c..3ca97dd26f 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -74,8 +74,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f"[bold]Format:[/bold] {fmt}") # Quick guide when no action is specified - if not ARG('list_steps') and not ARG('list_vars') and ARG('var') is None \ - and not ARG('interactive') and ARG('step') is None: + no_action = (not ARG('list_steps') and not ARG('list_vars') + and ARG('var') is None and ARG('step') is None + and not ARG('interactive') and not ARG('tui')) + if no_action: cons.print() d = case_dir cons.print("[bold]Quick start:[/bold]") @@ -154,8 +156,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc tiled = varname is None or varname == 'all' if step_arg is None: - if ARG('interactive'): - step_arg = 'all' # default to all steps in interactive mode + if ARG('interactive') or ARG('tui'): + step_arg = 'all' # default to all steps in interactive/TUI mode else: raise MFCException("--step is required for rendering. " "Use --list-steps to see available timesteps, or pass --step all.") @@ -200,8 +202,9 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc interactive = ARG('interactive') - # Load all variables when tiled or interactive; filter otherwise. - load_all = tiled or interactive + # Load all variables when tiled, interactive, or TUI; filter otherwise. + # TUI needs all vars loaded so the sidebar can switch between them. + load_all = tiled or interactive or ARG('tui') def read_step(step): if fmt == 'silo': @@ -219,16 +222,28 @@ def read_step(step): f"Refusing to load {len(requested_steps)} timesteps for 3D data " "(limit is 500). Use --step with a range or stride to reduce.") - # Tiled mode only works for 1D - if tiled and not interactive: + # Tiled mode for non-TUI, non-interactive rendering only works for 1D + if tiled and not interactive and not ARG('tui'): if test_assembled.ndim != 1: raise MFCException("--var is required for 2D/3D rendering. " "Use --list-vars to see available variables.") - if not tiled and not interactive and varname not in test_assembled.variables: + if not tiled and not interactive and not ARG('tui') and varname not in test_assembled.variables: raise MFCException(f"Variable '{varname}' not found. " f"Available variables: {', '.join(avail)}") + # TUI mode — launch Textual terminal UI (1D/2D only) + if ARG('tui'): + if test_assembled.ndim == 3: + raise MFCException( + "--tui only supports 1D and 2D data. " + "Use --interactive for 3D data." + ) + from .tui import run_tui # pylint: disable=import-outside-toplevel + init_var = varname if varname in avail else (avail[0] if avail else None) + run_tui(init_var, requested_steps, read_step, ndim=test_assembled.ndim) + return + # Interactive mode — launch Dash web server if interactive: from .interactive import run_interactive # pylint: disable=import-outside-toplevel diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 86e435a36b..23ef466713 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -41,6 +41,9 @@ dependencies = [ "h5py", "imageio>=2.33", "imageio-ffmpeg>=0.5.0", + "plotext>=5.2.0", + "textual>=0.47.0", + "textual-plotext>=0.2.0", # Chemistry "cantera>=3.1.0", From 4b78bf1f0b335d0ec972faa776fbf05c4b93ebee Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 01:39:15 -0500 Subject: [PATCH 038/102] Fix MP4 memory usage and remove cmcrameri-dependent colormaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stream MP4 frames incrementally via imageio.get_writer instead of loading all frames into memory at once with mimwrite (OOM risk for long videos). Remove berlin/managua/vanimo from the interactive colormap list — these require the optional cmcrameri package and silently fail without it. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 3 +-- toolchain/mfc/viz/renderer.py | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 9d19f2a336..21e6dc07e6 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -28,8 +28,7 @@ "Blues", "Greens", "Oranges", "Reds", "Purples", "Greys", "twilight", "twilight_shifted", "hsv", "tab10", "tab20", "terrain", "ocean", "gist_earth", - "gnuplot", "gnuplot2", "CMRmap", "cubehelix", - "berlin", "managua", "vanimo", "Wistia", + "gnuplot", "gnuplot2", "CMRmap", "cubehelix", "Wistia", ] # --------------------------------------------------------------------------- diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 4a5ad7a935..b7b3541daf 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -374,12 +374,12 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum success = False try: - imageio.mimwrite( - output, - [imageio.imread(os.path.join(viz_dir, fname)) for fname in frame_files], - fps=fps, codec='libx264', pixelformat='yuv420p', macro_block_size=2, - ffmpeg_log_level='error', - ) + with imageio.get_writer( + output, fps=fps, codec='libx264', pixelformat='yuv420p', + macro_block_size=2, ffmpeg_log_level='error', + ) as writer: + for fname in frame_files: + writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) success = True except (OSError, ValueError, RuntimeError) as exc: print(f"imageio MP4 write failed: {exc}") From 667ca10b6330fea3554a49f9f79fac072ebdc5c2 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 08:16:12 -0500 Subject: [PATCH 039/102] Add log scale, freeze range, and autoplay to TUI Three new keybindings in --tui mode: [l] toggle log scale (1D and 2D, with LogNorm for 2D heatmaps) [f] freeze/unfreeze color range at current frame vmin/vmax [space] toggle autoplay (0.5s interval, loops) Colorbar midpoint uses geometric mean when log scale is active. Status bar shows active flags (log / frozen / playing). Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/tui.py | 94 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index c1cc1ad295..518bc2acb2 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -53,7 +53,7 @@ def _load(step: int, read_func: Callable) -> object: # Plot widget # --------------------------------------------------------------------------- -class MFCPlot(PlotextPlot): +class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim / ._varname / ._step before calling .refresh().""" @@ -74,8 +74,13 @@ def __init__(self, **kwargs): self._varname: str = "" self._step: int = 0 self._cmap_name: str = _CMAPS[0] + self._log_scale: bool = False + self._vmin: Optional[float] = None + self._vmax: Optional[float] = None + self._last_vmin: float = 0.0 + self._last_vmax: float = 1.0 - def render(self): # pylint: disable=too-many-branches,too-many-locals + def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data x_cc = self._x_cc self.plt.clear_figure() @@ -83,10 +88,23 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals # 1D: use normal plotext path — gives proper axes and title for free. if data is None or x_cc is None or self._ndim == 1: if data is not None and x_cc is not None: - self.plt.plot(x_cc.tolist(), data.tolist()) + if self._log_scale: + plot_y = np.where(data > 0, np.log10(np.maximum(data, 1e-300)), np.nan) + ylabel = f"log\u2081\u2080({self._varname})" + else: + plot_y = data + ylabel = self._varname + finite = plot_y[np.isfinite(plot_y)] + self._last_vmin = float(finite.min()) if finite.size else 0.0 + self._last_vmax = float(finite.max()) if finite.size else 1.0 + self.plt.plot(x_cc.tolist(), plot_y.tolist()) self.plt.xlabel("x") - self.plt.ylabel(self._varname) + self.plt.ylabel(ylabel) self.plt.title(f"{self._varname} (step {self._step})") + if self._vmin is not None or self._vmax is not None: + lo = self._vmin if self._vmin is not None else self._last_vmin + hi = self._vmax if self._vmax is not None else self._last_vmax + self.plt.ylim(lo, hi) else: self.plt.title("No data loaded") return super().render() @@ -108,9 +126,17 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals iy = np.linspace(0, data.shape[1] - 1, h_plot, dtype=int) ds = data[np.ix_(ix, iy)] # pylint: disable=unsubscriptable-object - vmin, vmax = float(ds.min()), float(ds.max()) + vmin = self._vmin if self._vmin is not None else float(ds.min()) + vmax = self._vmax if self._vmax is not None else float(ds.max()) + if vmax <= vmin: + vmax = vmin + 1e-10 + self._last_vmin = vmin + self._last_vmax = vmax cmap = mcm.get_cmap(self._cmap_name) - norm = mcolors.Normalize(vmin=vmin, vmax=vmax) + if self._log_scale and vmin > 0: + norm = mcolors.LogNorm(vmin=vmin, vmax=vmax) + else: + norm = mcolors.Normalize(vmin=vmin, vmax=vmax) # Transpose + flip so y=0 appears at the bottom of the display. rgba = cmap(norm(ds.T[::-1])) # (h_plot, w_map, 4) @@ -137,7 +163,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals elif row == h_plot - 1: lbl = f" {vmin:.3g}" elif row == h_plot // 2: - lbl = f" {(vmin + vmax) / 2:.3g}" + mid = np.sqrt(vmin * vmax) if (self._log_scale and vmin > 0) else (vmin + vmax) / 2 + lbl = f" {mid:.3g}" else: lbl = "" line.append(lbl.ljust(_CB_LBL)[:_CB_LBL]) @@ -205,12 +232,17 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes Binding("period", "next_step", "step ▶"), Binding("left", "prev_step", "◀ step", show=False), Binding("right", "next_step", "step ▶", show=False), + Binding("space", "toggle_play", "▶/⏸"), Binding("c", "cycle_cmap", "cmap"), + Binding("l", "toggle_log", "log"), + Binding("f", "toggle_freeze", "freeze"), ] step_idx: reactive[int] = reactive(0, always_update=True) var_name: reactive[str] = reactive("", always_update=True) cmap_name: reactive[str] = reactive(_CMAPS[0], always_update=True) + log_scale: reactive[bool] = reactive(False, always_update=True) + playing: reactive[bool] = reactive(False, always_update=True) def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, @@ -229,6 +261,8 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument # Store init_var but don't set the reactive yet — the DOM doesn't exist # until on_mount, and the watcher calls query_one which needs the DOM. self._init_var = init_var or (varnames[0] if varnames else "") + self._frozen_range: Optional[tuple] = None + self._play_timer = None def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -266,6 +300,17 @@ def watch_var_name(self, _old: str, _new: str) -> None: def watch_cmap_name(self, _old: str, _new: str) -> None: self._push_data() + def watch_log_scale(self, _old: bool, _new: bool) -> None: + self._push_data() + + def watch_playing(self, _old: bool, new: bool) -> None: + if new: + self._play_timer = self.set_interval(0.5, self._auto_advance) + else: + if self._play_timer is not None: + self._play_timer.stop() + self._play_timer = None + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -273,11 +318,19 @@ def watch_cmap_name(self, _old: str, _new: str) -> None: def _status_text(self) -> str: step = self._steps[self.step_idx] if self._steps else 0 total = len(self._steps) + flags = [] + if self.log_scale: + flags.append("log") + if self._frozen_range is not None: + flags.append("frozen") + if self.playing: + flags.append("▶") + flag_str = (" " + " ".join(flags)) if flags else "" return ( f" step {step} [{self.step_idx + 1}/{total}]" f" var: {self.var_name}" f" cmap: {self.cmap_name}" - f" [,] prev [.] next [c] cmap" + f"{flag_str}" ) def _push_data(self) -> None: @@ -302,6 +355,12 @@ def _push_data(self) -> None: plot._varname = self.var_name # pylint: disable=protected-access plot._step = step # pylint: disable=protected-access plot._cmap_name = self.cmap_name # pylint: disable=protected-access + plot._log_scale = self.log_scale # pylint: disable=protected-access + if self._frozen_range is not None: + plot._vmin, plot._vmax = self._frozen_range # pylint: disable=protected-access + else: + plot._vmin = None # pylint: disable=protected-access + plot._vmax = None # pylint: disable=protected-access plot.refresh() self.query_one("#status", Static).update(self._status_text()) @@ -328,6 +387,23 @@ def action_cycle_cmap(self) -> None: idx = (_CMAPS.index(self.cmap_name) + 1) % len(_CMAPS) self.cmap_name = _CMAPS[idx] + def action_toggle_log(self) -> None: + self.log_scale = not self.log_scale + + def action_toggle_freeze(self) -> None: + if self._frozen_range is not None: + self._frozen_range = None + else: + plot = self.query_one("#plot", MFCPlot) + self._frozen_range = (plot._last_vmin, plot._last_vmax) # pylint: disable=protected-access + self._push_data() + + def action_toggle_play(self) -> None: + self.playing = not self.playing + + def _auto_advance(self) -> None: + self.step_idx = (self.step_idx + 1) % len(self._steps) + # --------------------------------------------------------------------------- # Public entry point @@ -358,7 +434,7 @@ def run_tui( f"[bold]Launching TUI[/bold] — {len(steps)} step(s), " f"{len(varnames)} variable(s)" ) - cons.print("[dim] ,/. or ←/→ prev/next step • ↑↓ select variable • q quit[/dim]") + cons.print("[dim] ,/. or ←/→ prev/next step • space play • l log • f freeze • ↑↓ variable • q quit[/dim]") _cache.clear() _cache[steps[0]] = first From 2498a0f30d4e0d82b1a7f13ed225939ff60ba79b Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 08:42:59 -0500 Subject: [PATCH 040/102] Fix Python 3.10+ compatibility and packaging issues - Replace deprecated matplotlib.cm.get_cmap() with matplotlib.colormaps[] (deprecated since matplotlib 3.7, removal pending) - Drop imageio.v2 shim in renderer.py; use top-level imageio instead (v2 is a backwards-compat shim; top-level API is stable) - Add requires-python = ">=3.10" to pyproject.toml - Bump dash>=1.12.0 to dash>=2.0 (callback_context semantics changed in 2.0) - Remove duplicate typos entry in pyproject.toml Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 2 +- toolchain/mfc/viz/tui.py | 4 ++-- toolchain/pyproject.toml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index b7b3541daf..e2e81f5257 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -13,7 +13,7 @@ import numpy as np -import imageio.v2 as imageio +import imageio import matplotlib matplotlib.use('Agg') diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 518bc2acb2..eb9d8d8e13 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -110,7 +110,7 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- return super().render() # 2D: pure-Rich heatmap with vertical colorbar. - import matplotlib.cm as mcm # pylint: disable=import-outside-toplevel + import matplotlib # pylint: disable=import-outside-toplevel import matplotlib.colors as mcolors # pylint: disable=import-outside-toplevel # Content area = widget size minus 1-char border on each side. @@ -132,7 +132,7 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- vmax = vmin + 1e-10 self._last_vmin = vmin self._last_vmax = vmax - cmap = mcm.get_cmap(self._cmap_name) + cmap = matplotlib.colormaps[self._cmap_name] if self._log_scale and vmin > 0: norm = mcolors.LogNorm(vmin=vmin, vmax=vmax) else: diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 23ef466713..44a0df26eb 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -5,11 +5,11 @@ build-backend = "hatchling.build" [project] name = "mfc" dynamic = ["version"] +requires-python = ">=3.10" dependencies = [ # General "rich", "wheel", - "typos", "PyYAML", "argparse", "dataclasses", @@ -53,7 +53,7 @@ dependencies = [ # Frontier Profiling "astunparse==1.6.2", "colorlover", - "dash>=1.12.0", + "dash>=2.0", "pymongo", "tabulate", "tqdm", From 42d09236dd520d3256912453bb69530bafe13140 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:00:20 -0500 Subject: [PATCH 041/102] Add sensible defaults to ./mfc.sh viz flags - --step defaults to 'last' (most recent timestep); interactive/TUI still override to 'all' internally - --var auto-selects first available variable for 2D/3D static rendering instead of erroring; prints which variable was chosen - --cmap defaults to 'viridis', --dpi to 150, --fps to 10, --slice-axis to 'z' (moving implicit renderer defaults into the CLI so they appear in --help) - Remove no-action quick-start guide (defaults now do something useful) - Remove berlin/managua/vanimo from --cmap completion list - Clean up or-fallbacks and guard conditions made redundant by defaults Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 14 +++--- toolchain/mfc/viz/viz.py | 81 +++++++++++------------------------ 2 files changed, 32 insertions(+), 63 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 9ac6673086..feea50c269 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -882,9 +882,9 @@ ), Argument( name="step", - help="Timestep(s): single int, start:end:stride, or 'all'.", + help="Timestep(s): single int, start:end:stride, 'last', or 'all' (default: last).", type=str, - default=None, + default='last', metavar="STEP", ), Argument( @@ -909,7 +909,7 @@ name="cmap", help="Matplotlib colormap name (default: viridis).", type=str, - default=None, + default='viridis', metavar="CMAP", completion=Completion(type=CompletionType.CHOICES, choices=[ # Perceptually uniform sequential @@ -934,7 +934,7 @@ "turbo", "jet", "rainbow", "nipy_spectral", "gist_ncar", "gist_rainbow", "gist_stern", "gist_earth", "ocean", "terrain", "gnuplot", "gnuplot2", "CMRmap", "cubehelix", "brg", "flag", "prism", - "berlin", "managua", "vanimo", "Wistia", + "Wistia", ]), ), Argument( @@ -955,14 +955,14 @@ name="dpi", help="Image resolution in DPI (default: 150).", type=int, - default=None, + default=150, metavar="DPI", ), Argument( name="slice-axis", help="Axis for 3D slice: x, y, or z (default: z).", type=str, - default=None, + default='z', choices=["x", "y", "z"], dest="slice_axis", completion=Completion(type=CompletionType.CHOICES, choices=["x", "y", "z"]), @@ -993,7 +993,7 @@ name="fps", help="Frames per second for MP4 output (default: 10).", type=int, - default=None, + default=10, metavar="FPS", ), Argument( diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 3ca97dd26f..1f2d6e8615 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -73,28 +73,6 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f"[bold]Format:[/bold] {fmt}") - # Quick guide when no action is specified - no_action = (not ARG('list_steps') and not ARG('list_vars') - and ARG('var') is None and ARG('step') is None - and not ARG('interactive') and not ARG('tui')) - if no_action: - cons.print() - d = case_dir - cons.print("[bold]Quick start:[/bold]") - cons.print(f" [green]./mfc.sh viz {d} --list-steps[/green]" - " [dim]see available timesteps[/dim]") - cons.print(f" [green]./mfc.sh viz {d} --list-vars --step 0[/green]" - " [dim]see available variables[/dim]") - cons.print(f" [green]./mfc.sh viz {d} --var pres --step last[/green]" - " [dim]render a PNG[/dim]") - cons.print(f" [green]./mfc.sh viz {d} --var pres --step all --mp4[/green]" - " [dim]render an MP4[/dim]") - cons.print(f" [green]./mfc.sh viz {d} --var pres --step 0 --slice-axis z[/green]" - " [dim]3D midplane slice[/dim]") - cons.print() - cons.print("[dim]Run [bold]./mfc.sh viz --help[/bold] for all options.[/dim]") - return - # Handle --list-steps if ARG('list_steps'): steps = discover_timesteps(case_dir, fmt) @@ -155,12 +133,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') tiled = varname is None or varname == 'all' - if step_arg is None: - if ARG('interactive') or ARG('tui'): - step_arg = 'all' # default to all steps in interactive/TUI mode - else: - raise MFCException("--step is required for rendering. " - "Use --list-steps to see available timesteps, or pass --step all.") + if ARG('interactive') or ARG('tui'): + step_arg = 'all' # always load all steps in interactive/TUI mode steps = discover_timesteps(case_dir, fmt) if not steps: @@ -174,31 +148,21 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc raise MFCException(msg) # Collect rendering options - render_opts = {} - cmap = ARG('cmap') - if cmap: - render_opts['cmap'] = cmap - vmin = ARG('vmin') - if vmin is not None: - render_opts['vmin'] = float(vmin) - vmax = ARG('vmax') - if vmax is not None: - render_opts['vmax'] = float(vmax) - dpi = ARG('dpi') - if dpi is not None: - render_opts['dpi'] = int(dpi) + render_opts = { + 'cmap': ARG('cmap'), + 'dpi': ARG('dpi'), + 'slice_axis': ARG('slice_axis'), + } + if ARG('vmin') is not None: + render_opts['vmin'] = float(ARG('vmin')) + if ARG('vmax') is not None: + render_opts['vmax'] = float(ARG('vmax')) if ARG('log_scale'): render_opts['log_scale'] = True - - slice_axis = ARG('slice_axis') - slice_index = ARG('slice_index') - slice_value = ARG('slice_value') - if slice_axis: - render_opts['slice_axis'] = slice_axis - if slice_index is not None: - render_opts['slice_index'] = int(slice_index) - if slice_value is not None: - render_opts['slice_value'] = float(slice_value) + if ARG('slice_index') is not None: + render_opts['slice_index'] = int(ARG('slice_index')) + if ARG('slice_value') is not None: + render_opts['slice_value'] = float(ARG('slice_value')) interactive = ARG('interactive') @@ -222,11 +186,16 @@ def read_step(step): f"Refusing to load {len(requested_steps)} timesteps for 3D data " "(limit is 500). Use --step with a range or stride to reduce.") - # Tiled mode for non-TUI, non-interactive rendering only works for 1D + # Tiled mode for non-TUI, non-interactive rendering only works for 1D. + # For 2D/3D, auto-select the first available variable. if tiled and not interactive and not ARG('tui'): if test_assembled.ndim != 1: - raise MFCException("--var is required for 2D/3D rendering. " - "Use --list-vars to see available variables.") + varname = avail[0] if avail else None + if varname is None: + raise MFCException("No variables found in output.") + tiled = False + cons.print(f"[dim]Auto-selected variable: [bold]{varname}[/bold]" + " (use --var to specify)[/dim]") if not tiled and not interactive and not ARG('tui') and varname not in test_assembled.variables: raise MFCException(f"Variable '{varname}' not found. " @@ -247,7 +216,7 @@ def read_step(step): # Interactive mode — launch Dash web server if interactive: from .interactive import run_interactive # pylint: disable=import-outside-toplevel - port = ARG('port') or 8050 + port = ARG('port') # Default to first available variable if --var was not specified init_var = varname if varname in avail else (avail[0] if avail else None) run_interactive(init_var, requested_steps, read_step, port=int(port)) @@ -261,7 +230,7 @@ def read_step(step): # MP4 mode if ARG('mp4'): - fps = ARG('fps') or 10 + fps = ARG('fps') label = 'all' if tiled else varname mp4_path = os.path.join(output_base, f'{label}.mp4') cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") From fb065bc71fd0883ff80811e89a93b99c516daa38 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:11:41 -0500 Subject: [PATCH 042/102] Improve viz error messages: colormap validation, step hints, missing output guidance - Add _validate_cmap() with rapidfuzz fuzzy suggestions for unknown colormaps - Add _steps_hint() usage in --step not-found and --list-vars errors - Improve "no timesteps found" error with format name and post_process reminder - Improve discover_format failure: detect case.py and suggest running post_process - Fix line-too-long in _parse_steps error message Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/viz.py | 69 +++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 1f2d6e8615..41566574a4 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -11,6 +11,39 @@ from mfc.printer import cons +_CMAP_POPULAR = ( + 'viridis, plasma, inferno, magma, turbo, ' + 'coolwarm, RdBu_r, bwr, hot, jet, gray, seismic' +) + + +def _validate_cmap(name): + """Raise a helpful MFCException if *name* is not a known matplotlib colormap.""" + import matplotlib # pylint: disable=import-outside-toplevel + if name in matplotlib.colormaps: + return + try: + from rapidfuzz import process # pylint: disable=import-outside-toplevel + matches = process.extract(name, list(matplotlib.colormaps), limit=3) + suggestions = ', '.join(m[0] for m in matches) + hint = f" Did you mean: {suggestions}?" + except ImportError: + hint = f" Popular choices: {_CMAP_POPULAR}." + raise MFCException(f"Unknown colormap '{name}'.{hint}") + + +def _steps_hint(steps, n=8): + """Short inline preview of available steps for error messages.""" + if not steps: + return "none found" + if len(steps) <= n: + return ', '.join(str(s) for s in steps) + half = n // 2 + head = ', '.join(str(s) for s in steps[:half]) + tail = ', '.join(str(s) for s in steps[-half:]) + return f"{head}, ... [{len(steps)} total] ..., {tail}" + + def _parse_steps(step_arg, available_steps): """ Parse the --step argument into a list of timestep integers. @@ -37,8 +70,10 @@ def _parse_steps(step_arg, available_steps): single = int(step_arg) except ValueError as exc: - raise MFCException(f"Invalid --step value '{step_arg}'. " - "Expected an integer, a range (start:end:stride), 'last', or 'all'.") from exc + raise MFCException( + f"Invalid --step value '{step_arg}'. " + "Expected an integer, a range (start:end:stride), 'last', or 'all'." + ) from exc if available_steps and single not in set(available_steps): return [] @@ -69,7 +104,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc try: fmt = discover_format(case_dir) except FileNotFoundError as exc: - raise MFCException(str(exc)) from exc + msg = str(exc) + if os.path.isfile(os.path.join(case_dir, 'case.py')): + msg += (" This looks like an MFC case directory. " + "Did you forget to run post_process first?") + raise MFCException(msg) from exc cons.print(f"[bold]Format:[/bold] {fmt}") @@ -96,7 +135,9 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') steps = discover_timesteps(case_dir, fmt) if not steps: - raise MFCException("No timesteps found.") + raise MFCException( + f"No timesteps found in '{case_dir}' ({fmt} format). " + "Ensure post_process has been run and produced output files.") if step_arg is None or step_arg == 'all': step = steps[0] @@ -112,8 +153,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc "Expected an integer, 'last', or 'all'.") from exc if step not in steps: raise MFCException( - f"Timestep {step} not found. Available range: " - f"{steps[0]} to {steps[-1]} ({len(steps)} timesteps)") + f"Timestep {step} not found. " + f"Available steps: {_steps_hint(steps)}") if fmt == 'silo': from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel @@ -138,14 +179,15 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc steps = discover_timesteps(case_dir, fmt) if not steps: - raise MFCException("No timesteps found.") + raise MFCException( + f"No timesteps found in '{case_dir}' ({fmt} format). " + "Ensure post_process has been run and produced output files.") requested_steps = _parse_steps(step_arg, steps) if not requested_steps: - msg = f"No matching timesteps for --step {step_arg}" - if steps: - msg += f". Available range: {steps[0]} to {steps[-1]} ({len(steps)} timesteps)" - raise MFCException(msg) + raise MFCException( + f"No matching timesteps for --step {step_arg!r}. " + f"Available steps: {_steps_hint(steps)}") # Collect rendering options render_opts = { @@ -222,6 +264,11 @@ def read_step(step): run_interactive(init_var, requested_steps, read_step, port=int(port)) return + # Validate colormap before any rendering + cmap_name = ARG('cmap') + if cmap_name: + _validate_cmap(cmap_name) + # Create output directory output_base = ARG('output') if output_base is None: From b63d392668a48b37cd0092dceaf77cf8f070c28c Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:16:21 -0500 Subject: [PATCH 043/102] Add TUI section to viz docs and update defaults - Add Terminal UI (--tui) section with keybindings table - Update basic usage to note --step defaults to 'last' - Note viridis/150dpi defaults in basic usage section Co-Authored-By: Claude Sonnet 4.6 --- docs/documentation/visualization.md | 34 +++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 413bbe91dc..93aaff0f24 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -14,8 +14,8 @@ MFC includes a built-in visualization command that renders images and videos dir ### Basic usage ```bash -# Plot pressure at the last available timestep -./mfc.sh viz case_dir/ --var pres --step last +# Plot pressure at the last available timestep (--step defaults to 'last') +./mfc.sh viz case_dir/ --var pres # Plot density at all available timesteps ./mfc.sh viz case_dir/ --var rho --step all @@ -23,6 +23,7 @@ MFC includes a built-in visualization command that renders images and videos dir The command auto-detects the output format (binary or Silo-HDF5) and dimensionality (1D, 2D, or 3D). Output images are saved to `case_dir/viz/` by default. +The default colormap is `viridis`, default DPI is 150, and `--step` defaults to `last`. ### Exploring available data @@ -139,6 +140,35 @@ The interactive viewer provides a Dash web UI with: > [!NOTE] > Interactive mode requires the `dash` Python package. +### Terminal UI (TUI) + +For environments without a browser — such as SSH sessions or HPC login nodes — use `--tui` to launch a live terminal UI: + +```bash +./mfc.sh viz case_dir/ --tui + +# Start with a specific variable pre-selected +./mfc.sh viz case_dir/ --var pres --tui +``` + +The TUI loads all timesteps and renders plots directly in the terminal using Unicode block characters. +It supports 1D and 2D data only (use `--interactive` for 3D). + +**Keyboard shortcuts:** + +| Key | Action | +|-----|--------| +| `n` / `→` | Next timestep | +| `p` / `←` | Previous timestep | +| `Space` | Toggle autoplay | +| `l` | Toggle logarithmic scale | +| `f` | Freeze / unfreeze color range | +| `v` | Cycle to next variable | +| `q` / `Escape` | Quit | + +> [!NOTE] +> The TUI requires the `textual` and `textual-plotext` Python packages (included in MFC's default dependencies). + ### Plot styling Axis labels use LaTeX-style math notation — for example, `pres` is labeled as \f$p\f$, `vel1` as \f$u\f$, and `alpha1` as \f$\alpha_1\f$. From 085437e161def22ac0a908d592409a141c3bf3aa Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:24:42 -0500 Subject: [PATCH 044/102] Fix log scale: add 1D support, fix TUI vmin clamping, add log indicators renderer.py: - render_1d/render_1d_tiled: apply ax.set_yscale('log') when --log-scale - render_2d/render_3d_slice: append '[log]' to colorbar label when log scale active tui.py: - 2D: clamp vmin to minimum positive value when log is on (was silently falling back to linear if data contained zeros, e.g. velocity fields) - 2D: show '[log]' or '[log n/a]' tag in header to confirm active state - 1D: show '[log]' in plot title when log is active Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 12 ++++++++++-- toolchain/mfc/viz/tui.py | 23 +++++++++++++++++------ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index e2e81f5257..5345734543 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -92,6 +92,9 @@ def render_1d(x_cc, data, varname, step, output, **opts): # pylint: disable=too 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: @@ -120,6 +123,7 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t figsize=opts.get('figsize', (fig_w, fig_h)), sharex=True, squeeze=False) + log_scale = opts.get('log_scale', False) for idx, vn in enumerate(varnames): row, col = divmod(idx, ncols) ax = axes[row][col] @@ -127,6 +131,8 @@ def render_1d_tiled(x_cc, variables, step, output, **opts): # pylint: disable=t ax.set_ylabel(pretty_label(vn), fontsize=9) ax.tick_params(labelsize=8) ax.grid(True, alpha=0.3) + if log_scale: + ax.set_yscale('log') # Hide unused subplots for idx in range(n, nrows * ncols): @@ -183,7 +189,8 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') label = pretty_label(varname) - fig.colorbar(pcm, ax=ax, label=label) + cb_label = f'{label} [log]' if log_scale else label + fig.colorbar(pcm, ax=ax, label=cb_label) ax.set_xlabel(r'$x$') ax.set_ylabel(r'$y$') ax.set_title(f'{label} (step {step})') @@ -256,7 +263,8 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') label = pretty_label(varname) - fig.colorbar(pcm, ax=ax, label=label) + cb_label = f'{label} [log]' if log_scale else label + fig.colorbar(pcm, ax=ax, label=cb_label) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) slice_coord = coord_along[idx] diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index eb9d8d8e13..a10acd3454 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -91,16 +91,18 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- if self._log_scale: plot_y = np.where(data > 0, np.log10(np.maximum(data, 1e-300)), np.nan) ylabel = f"log\u2081\u2080({self._varname})" + title_tag = " [log]" else: plot_y = data ylabel = self._varname + title_tag = "" finite = plot_y[np.isfinite(plot_y)] self._last_vmin = float(finite.min()) if finite.size else 0.0 self._last_vmax = float(finite.max()) if finite.size else 1.0 self.plt.plot(x_cc.tolist(), plot_y.tolist()) self.plt.xlabel("x") self.plt.ylabel(ylabel) - self.plt.title(f"{self._varname} (step {self._step})") + self.plt.title(f"{self._varname} (step {self._step}){title_tag}") if self._vmin is not None or self._vmax is not None: lo = self._vmin if self._vmin is not None else self._last_vmin hi = self._vmax if self._vmax is not None else self._last_vmax @@ -130,13 +132,21 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- vmax = self._vmax if self._vmax is not None else float(ds.max()) if vmax <= vmin: vmax = vmin + 1e-10 - self._last_vmin = vmin - self._last_vmax = vmax cmap = matplotlib.colormaps[self._cmap_name] - if self._log_scale and vmin > 0: - norm = mcolors.LogNorm(vmin=vmin, vmax=vmax) + log_active = False + if self._log_scale: + pos = ds[ds > 0] + lo = float(np.nanmin(pos)) if pos.size > 0 else None + if lo is not None and lo < vmax: + norm = mcolors.LogNorm(vmin=lo, vmax=vmax) + vmin = lo + log_active = True + else: + norm = mcolors.Normalize(vmin=vmin, vmax=vmax) else: norm = mcolors.Normalize(vmin=vmin, vmax=vmax) + self._last_vmin = vmin + self._last_vmax = vmax # Transpose + flip so y=0 appears at the bottom of the display. rgba = cmap(norm(ds.T[::-1])) # (h_plot, w_map, 4) @@ -171,9 +181,10 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- lines.append(line) y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) + log_tag = " [log]" if log_active else (" [log n/a]" if self._log_scale else "") header = RichText( f" {self._varname} (step {self._step})" - f" [{vmin:.3g}, {vmax:.3g}]", + f" [{vmin:.3g}, {vmax:.3g}]{log_tag}", style="bold" ) footer = RichText( From 12e09fae3a472883b5fe1a5d116ff343d5a95c99 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:27:08 -0500 Subject: [PATCH 045/102] Remove [log] suffix from PNG/video colorbar labels Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 5345734543..742cf35e94 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -189,8 +189,7 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab pcm = ax.pcolormesh(x_cc, y_cc, data.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') label = pretty_label(varname) - cb_label = f'{label} [log]' if log_scale else label - fig.colorbar(pcm, ax=ax, label=cb_label) + fig.colorbar(pcm, ax=ax, label=label) ax.set_xlabel(r'$x$') ax.set_ylabel(r'$y$') ax.set_title(f'{label} (step {step})') @@ -263,8 +262,7 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: pcm = ax.pcolormesh(x_plot, y_plot, sliced.T, cmap=cmap, vmin=vmin, vmax=vmax, norm=norm, shading='auto') label = pretty_label(varname) - cb_label = f'{label} [log]' if log_scale else label - fig.colorbar(pcm, ax=ax, label=cb_label) + fig.colorbar(pcm, ax=ax, label=label) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) slice_coord = coord_along[idx] From 911b10a5713170717f519c707b14fdaf7e9d12ec Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:42:25 -0500 Subject: [PATCH 046/102] Add TUI frozen indicator; fix spell check scanning build_test/ tui.py: show '[frozen]' tag in 2D header and 1D title when color range is locked .typos.toml: exclude build/ and build_test/ to prevent false positives from Doxygen-generated HTML hash strings Co-Authored-By: Claude Sonnet 4.6 --- .typos.toml | 2 +- toolchain/mfc/viz/tui.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.typos.toml b/.typos.toml index 6123410cb1..595090c271 100644 --- a/.typos.toml +++ b/.typos.toml @@ -31,4 +31,4 @@ tru = "tru" # typo for "true" in "when_tru" - tests dependency keys PNGs = "PNGs" [files] -extend-exclude = ["docs/documentation/references*", "docs/references.bib", "tests/", "toolchain/cce_simulation_workgroup_256.sh", "build-docs/"] +extend-exclude = ["docs/documentation/references*", "docs/references.bib", "tests/", "toolchain/cce_simulation_workgroup_256.sh", "build-docs/", "build/", "build_test/"] diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index a10acd3454..86eef18053 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -96,6 +96,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- plot_y = data ylabel = self._varname title_tag = "" + if self._vmin is not None or self._vmax is not None: + title_tag += " [frozen]" finite = plot_y[np.isfinite(plot_y)] self._last_vmin = float(finite.min()) if finite.size else 0.0 self._last_vmax = float(finite.max()) if finite.size else 1.0 @@ -182,9 +184,10 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) log_tag = " [log]" if log_active else (" [log n/a]" if self._log_scale else "") + frozen_tag = " [frozen]" if self._vmin is not None else "" header = RichText( f" {self._varname} (step {self._step})" - f" [{vmin:.3g}, {vmax:.3g}]{log_tag}", + f" [{vmin:.3g}, {vmax:.3g}]{log_tag}{frozen_tag}", style="bold" ) footer = RichText( From 3fec6f1cfde46f07960ee5b4ecf6f1ade8ab35eb Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:51:02 -0500 Subject: [PATCH 047/102] Address review: bounded caches, MP4 cleanup, avoid double-read - interactive.py/tui.py: cap _cache at 50 entries (FIFO eviction) to prevent OOM in long sessions loading many large timesteps - renderer.py: wrap frame-rendering loop in try/finally so temp dir is always cleaned up even if a render_* call raises (e.g. ValueError from render_3d_slice) - viz.py: reuse test_assembled for first step instead of reading it twice Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 8 ++- toolchain/mfc/viz/renderer.py | 86 +++++++++++++++++--------------- toolchain/mfc/viz/tui.py | 8 ++- toolchain/mfc/viz/viz.py | 3 +- 4 files changed, 62 insertions(+), 43 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 21e6dc07e6..8e66628b19 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -49,14 +49,20 @@ _YELLOW = '#f9e2af' # --------------------------------------------------------------------------- -# Server-side data cache {step -> AssembledData} +# Server-side data cache {step -> AssembledData} (bounded to avoid OOM) # --------------------------------------------------------------------------- +_CACHE_MAX = 50 _cache: dict = {} +_cache_order: list = [] def _load(step: int, read_func: Callable): if step not in _cache: + if len(_cache) >= _CACHE_MAX: + evict = _cache_order.pop(0) + _cache.pop(evict, None) _cache[step] = read_func(step) + _cache_order.append(step) return _cache[step] diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 742cf35e94..bf870f2805 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -338,42 +338,57 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum os.makedirs(output_dir, exist_ok=True) viz_dir = tempfile.mkdtemp(dir=output_dir, prefix='_frames_') + 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 + try: from tqdm import tqdm # pylint: disable=import-outside-toplevel step_iter = tqdm(steps, desc='Rendering frames') except ImportError: step_iter = steps - for i, step in enumerate(step_iter): - assembled = read_func(step) - frame_path = os.path.join(viz_dir, f'{i:06d}.png') - - if tiled and assembled.ndim == 1: - render_1d_tiled(assembled.x_cc, assembled.variables, - step, frame_path, **opts) - elif assembled.ndim == 1: - var_data = assembled.variables.get(varname) - if var_data is None: - continue - render_1d(assembled.x_cc, var_data, - varname, step, frame_path, **opts) - elif assembled.ndim == 2: - var_data = assembled.variables.get(varname) - if var_data is None: - continue - render_2d(assembled.x_cc, assembled.y_cc, - var_data, - varname, step, frame_path, **opts) - elif assembled.ndim == 3: - var_data = assembled.variables.get(varname) - if var_data is None: - continue - render_3d_slice(assembled, varname, step, frame_path, **opts) - else: - raise ValueError( - f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " - "Expected 1, 2, or 3." - ) + try: + for i, step in enumerate(step_iter): + assembled = read_func(step) + frame_path = os.path.join(viz_dir, f'{i:06d}.png') + + if tiled and assembled.ndim == 1: + render_1d_tiled(assembled.x_cc, assembled.variables, + step, frame_path, **opts) + elif assembled.ndim == 1: + var_data = assembled.variables.get(varname) + if var_data is None: + continue + render_1d(assembled.x_cc, var_data, + varname, step, frame_path, **opts) + elif assembled.ndim == 2: + var_data = assembled.variables.get(varname) + if var_data is None: + continue + render_2d(assembled.x_cc, assembled.y_cc, + var_data, + varname, step, frame_path, **opts) + elif assembled.ndim == 3: + var_data = assembled.variables.get(varname) + if var_data is None: + continue + render_3d_slice(assembled, varname, step, frame_path, **opts) + else: + raise ValueError( + f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " + "Expected 1, 2, or 3." + ) + except Exception: + _cleanup() + raise # Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg) frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) @@ -390,14 +405,5 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum except (OSError, ValueError, RuntimeError) as exc: print(f"imageio MP4 write failed: {exc}") finally: - # Always clean up temporary frame files - for fname in frame_files: - try: - os.remove(os.path.join(viz_dir, fname)) - except OSError: - pass - try: - os.rmdir(viz_dir) - except OSError: - pass + _cleanup() return success diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 86eef18053..21f66f0677 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -38,14 +38,20 @@ ] # --------------------------------------------------------------------------- -# Step cache {step -> AssembledData} +# Step cache {step -> AssembledData} (bounded to avoid OOM) # --------------------------------------------------------------------------- +_CACHE_MAX = 50 _cache: Dict[int, object] = {} +_cache_order: List[int] = [] def _load(step: int, read_func: Callable) -> object: if step not in _cache: + if len(_cache) >= _CACHE_MAX: + evict = _cache_order.pop(0) + _cache.pop(evict, None) _cache[step] = read_func(step) + _cache_order.append(step) return _cache[step] diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 41566574a4..3865d4d705 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -302,7 +302,8 @@ def read_step(step): label = 'all' if tiled else varname for step in step_iter: try: - assembled = read_step(step) + # Reuse the already-loaded probe data for the first step + assembled = test_assembled if step == requested_steps[0] else read_step(step) except (FileNotFoundError, EOFError, ValueError) as exc: cons.print(f"[yellow]Warning:[/yellow] Skipping step {step}: {exc}") failures.append(step) From caacd9269282710c078d02ad816ec16924aa5337 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 09:59:09 -0500 Subject: [PATCH 048/102] Add unit tests for _steps_hint, _validate_cmap, TUI cache, and log scale Extends test_viz.py with four new test classes covering the features added in this PR: - TestStepsHint: edge cases for the error-message step preview helper - TestValidateCmap: valid/invalid colormap detection and typo suggestions - TestTuiCache: bounded FIFO eviction at _CACHE_MAX=50 (stores, hit, and evict-oldest cases) - TestRenderLogScale: smoke tests confirming render_1d/render_2d produce valid PNG output when log_scale=True Total: 48 tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/test_viz.py | 149 +++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 6cda7cc6c4..be0e6d981f 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -5,7 +5,7 @@ data assembly (binary + silo, 1D/2D/3D), and 1D rendering. Uses checked-in fixture data generated from minimal MFC runs. """ -# pylint: disable=import-outside-toplevel +# pylint: disable=import-outside-toplevel,protected-access import os import tempfile @@ -384,5 +384,152 @@ def test_render_3d_slice_png(self): os.unlink(out) +# --------------------------------------------------------------------------- +# Tests: _steps_hint +# --------------------------------------------------------------------------- + +class TestStepsHint(unittest.TestCase): + """Test _steps_hint() step preview for error messages.""" + + def _hint(self, steps, n=8): + from .viz import _steps_hint + return _steps_hint(steps, n) + + def test_empty(self): + """Empty steps returns 'none found'.""" + self.assertEqual(self._hint([]), "none found") + + def test_short_list_shows_all(self): + """Short list shows all steps without truncation.""" + result = self._hint([0, 100, 200]) + self.assertIn('0', result) + self.assertIn('200', result) + self.assertNotIn('...', result) + + def test_long_list_truncated(self): + """Long list includes count and truncation marker.""" + steps = list(range(0, 2000, 100)) # 20 steps + result = self._hint(steps, n=8) + self.assertIn('...', result) + self.assertIn('[20 total]', result) + self.assertIn('0', result) # head present + self.assertIn('1900', result) # tail present + + +# --------------------------------------------------------------------------- +# Tests: _validate_cmap +# --------------------------------------------------------------------------- + +class TestValidateCmap(unittest.TestCase): + """Test _validate_cmap() colormap validation.""" + + def _validate(self, name): + from .viz import _validate_cmap + _validate_cmap(name) + + def test_known_cmaps_pass(self): + """Known colormaps do not raise.""" + for name in ('viridis', 'plasma', 'coolwarm', 'gray'): + with self.subTest(name=name): + self._validate(name) + + def test_unknown_cmap_raises(self): + """Unknown colormap raises MFCException.""" + from mfc.common import MFCException + with self.assertRaises(MFCException): + self._validate('notacolormap_xyz_1234') + + def test_typo_suggests_correct(self): + """Typo in colormap name suggests the correct spelling.""" + from mfc.common import MFCException + try: + self._validate('virids') # typo of viridis + except MFCException as exc: + self.assertIn('viridis', str(exc)) + + +# --------------------------------------------------------------------------- +# Tests: bounded TUI cache +# --------------------------------------------------------------------------- + +class TestTuiCache(unittest.TestCase): + """Test that the TUI step cache respects CACHE_MAX.""" + + def setUp(self): + import mfc.viz.tui as tui_mod + self._mod = tui_mod + tui_mod._cache.clear() + tui_mod._cache_order.clear() + + def tearDown(self): + self._mod._cache.clear() + self._mod._cache_order.clear() + + def _read(self, step): + return f"data_{step}" + + def test_cache_stores_entry(self): + """Loaded step is stored in cache.""" + self._mod._load(0, self._read) + self.assertIn(0, self._mod._cache) + + def test_cache_hit_avoids_reload(self): + """Second load of same step does not call read_func again.""" + calls = [0] + def counting(step): + calls[0] += 1 + return step + self._mod._load(5, counting) + self._mod._load(5, counting) + self.assertEqual(calls[0], 1) + + def test_cache_evicts_oldest_at_cap(self): + """Oldest entry is evicted when CACHE_MAX is exceeded.""" + cap = self._mod._CACHE_MAX + for i in range(cap + 3): + self._mod._load(i, self._read) + self.assertLessEqual(len(self._mod._cache), cap) + self.assertNotIn(0, self._mod._cache) # first evicted + self.assertIn(cap + 2, self._mod._cache) # most recent kept + + +# --------------------------------------------------------------------------- +# Tests: log scale rendering (new feature smoke tests) +# --------------------------------------------------------------------------- + +class TestRenderLogScale(unittest.TestCase): + """Smoke test: log scale option produces valid PNG output.""" + + def test_render_1d_log_scale(self): + """render_1d with log_scale=True produces a non-empty PNG.""" + from .reader import assemble + from .renderer import render_1d + data = assemble(FIX_1D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_1d(data.x_cc, data.variables['pres'], 'pres', 0, out, + log_scale=True) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + def test_render_2d_log_scale(self): + """render_2d with log_scale=True produces a non-empty PNG.""" + from .reader import assemble + from .renderer import render_2d + data = assemble(FIX_2D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_2d(data.x_cc, data.y_cc, data.variables['pres'], + 'pres', 0, out, log_scale=True) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + if __name__ == "__main__": unittest.main() From 189dc24eaa3d5c2ca4893b7bb26bbbc7e116c7c7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 10:21:08 -0500 Subject: [PATCH 049/102] Raise on missing processor files instead of silently skipping A missing rank file leaves a gap in the assembled domain, producing silently incorrect visualizations. Upgrade from warnings.warn+skip to FileNotFoundError in both the binary and silo readers. Addresses cubic-dev-ai review finding on PR #1233. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/reader.py | 6 ++++-- toolchain/mfc/viz/silo_reader.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 6e13f2f0fd..0fcdd73b1d 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -406,8 +406,10 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t for rank in ranks: fpath = os.path.join(case_dir, 'binary', f'p{rank}', f'{step}.dat') if not os.path.isfile(fpath): - warnings.warn(f"Processor file not found, skipping: {fpath}", stacklevel=2) - continue + raise FileNotFoundError( + f"Processor file not found: {fpath}. " + "Incomplete output (missing rank) would produce incorrect data." + ) 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", stacklevel=2) diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 31cf03951e..37571465d6 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -146,8 +146,10 @@ def assemble_silo( for rank in ranks: silo_file = os.path.join(base, f"p{rank}", f"{step}.silo") if not os.path.isfile(silo_file): - warnings.warn(f"Processor file not found, skipping: {silo_file}", stacklevel=2) - continue + raise FileNotFoundError( + f"Processor file not found: {silo_file}. " + "Incomplete output (missing rank) would produce incorrect data." + ) pdata = read_silo_file(silo_file, var_filter=var) proc_data.append((rank, pdata)) From 85c715e83b5b3b9d37a537a72e03ea20c956878e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 10:32:05 -0500 Subject: [PATCH 050/102] Fix dedup large-domain math, add --host flag, add multi-rank assembly tests #3: Remove max(0,...) clamp from _dedup decimals formula so that large-extent domains (>1e12) use negative decimal rounding (numpy np.round supports this) instead of collapsing all coordinates to 0. #6: Add TestMultiRankAssembly with two synthetic tests: - two_rank_1d_dedup: verifies ghost-cell overlap is removed and the assembled variable array has the correct 4-cell result - large_extent_dedup: exercises the negative-decimals code path (scale=1e7) to confirm deduplication works at large domain extents #8: Add --host argument (default 127.0.0.1) to the interactive Dash server so users can bind to 0.0.0.0 for direct HPC access without SSH tunneling. Only show the SSH tunnel hint when host is localhost. Addresses Claude Code review findings on PR #1233. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 6 ++++ toolchain/mfc/viz/interactive.py | 8 +++-- toolchain/mfc/viz/reader.py | 4 ++- toolchain/mfc/viz/test_viz.py | 61 ++++++++++++++++++++++++++++++++ toolchain/mfc/viz/viz.py | 4 ++- 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index feea50c269..2f6bf888fd 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -1031,6 +1031,12 @@ default=8050, metavar="PORT", ), + Argument( + name="host", + help="Host address for the interactive web server (default: 127.0.0.1).", + default="127.0.0.1", + metavar="HOST", + ), Argument( name="tui", help="Launch an interactive terminal UI (1D/2D only). Works over SSH with no browser.", diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 8e66628b19..52a23c2b98 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -206,6 +206,7 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements steps: List[int], read_func: Callable, port: int = 8050, + host: str = '127.0.0.1', ): """Launch the interactive Dash visualization server.""" app = Dash( @@ -613,7 +614,8 @@ def _tf(arr): return arr # ------------------------------------------------------------------ cons.print(f'\n[bold green]Interactive viz server:[/bold green] ' - f'[bold]http://localhost:{port}[/bold]') - cons.print(f'[dim]SSH tunnel: ssh -L {port}:localhost:{port} [/dim]') + f'[bold]http://{host}:{port}[/bold]') + if host in ('127.0.0.1', 'localhost'): + cons.print(f'[dim]SSH tunnel: ssh -L {port}:localhost:{port} [/dim]') cons.print('[dim]Ctrl+C to stop.[/dim]\n') - app.run(debug=False, port=port, host='127.0.0.1') + app.run(debug=False, port=port, host=host) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 0fcdd73b1d..c5ac185048 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -318,10 +318,12 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-stateme # Build unique sorted global coordinate arrays (handles ghost overlap). # Use scale-aware rounding: 12 significant digits relative to the domain # extent, so precision is preserved for both micro-scale and large domains. + # np.round supports negative decimals (rounds to tens, hundreds, etc.), + # which is correct for large-extent domains (e.g. extent > 1e12). def _dedup(arr): extent = arr.max() - arr.min() if extent > 0: - decimals = max(0, int(np.ceil(-np.log10(extent))) + 12) + decimals = int(np.ceil(-np.log10(extent))) + 12 else: decimals = 12 return np.unique(np.round(arr, decimals)), decimals diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index be0e6d981f..89368ab49d 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -531,5 +531,66 @@ def test_render_2d_log_scale(self): os.unlink(out) +# --------------------------------------------------------------------------- +# Tests: multi-rank assembly (ghost-cell deduplication) +# --------------------------------------------------------------------------- + +class TestMultiRankAssembly(unittest.TestCase): + """Test assemble_from_proc_data with synthetic multi-processor data.""" + + def _make_proc(self, x_cb, pres): + """Build a minimal 1D ProcessorData from boundary coordinates.""" + import numpy as np + from .reader import ProcessorData + return ProcessorData( + m=len(x_cb) - 1, + n=0, + p=0, + x_cb=np.array(x_cb, dtype=np.float64), + y_cb=np.array([0.0]), + z_cb=np.array([0.0]), + variables={'pres': np.array(pres, dtype=np.float64)}, + ) + + def test_two_rank_1d_dedup(self): + """Two processors with one overlapping ghost cell assemble correctly.""" + import numpy as np + from .reader import assemble_from_proc_data + # Domain: 4 cells with centers at 0.125, 0.375, 0.625, 0.875 + # Proc 0 sees cells 0-2 (center 0.625 is ghost from proc 1) + # Proc 1 sees cells 1-3 (center 0.375 is ghost from proc 0) + p0 = self._make_proc([0.00, 0.25, 0.50, 0.75], + [1.0, 2.0, 3.0]) # centers: 0.125, 0.375, 0.625 + p1 = self._make_proc([0.25, 0.50, 0.75, 1.00], + [2.0, 3.0, 4.0]) # centers: 0.375, 0.625, 0.875 + + result = assemble_from_proc_data([(0, p0), (1, p1)]) + + self.assertEqual(result.ndim, 1) + self.assertEqual(len(result.x_cc), 4) + np.testing.assert_allclose(result.x_cc, [0.125, 0.375, 0.625, 0.875]) + np.testing.assert_allclose(result.variables['pres'], [1.0, 2.0, 3.0, 4.0]) + + def test_large_extent_dedup(self): + """Deduplication works correctly for large-extent domains (>1e6).""" + import numpy as np + from .reader import assemble_from_proc_data + # Scale up by 1e7 to exercise the negative-decimals code path + scale = 1e7 + p0 = self._make_proc( + [0.00 * scale, 0.25 * scale, 0.50 * scale, 0.75 * scale], + [1.0, 2.0, 3.0], + ) + p1 = self._make_proc( + [0.25 * scale, 0.50 * scale, 0.75 * scale, 1.00 * scale], + [2.0, 3.0, 4.0], + ) + result = assemble_from_proc_data([(0, p0), (1, p1)]) + self.assertEqual(len(result.x_cc), 4) + np.testing.assert_allclose( + result.variables['pres'], [1.0, 2.0, 3.0, 4.0] + ) + + if __name__ == "__main__": unittest.main() diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 3865d4d705..91b65c255e 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -259,9 +259,11 @@ def read_step(step): if interactive: from .interactive import run_interactive # pylint: disable=import-outside-toplevel port = ARG('port') + host = ARG('host') # Default to first available variable if --var was not specified init_var = varname if varname in avail else (avail[0] if avail else None) - run_interactive(init_var, requested_steps, read_step, port=int(port)) + run_interactive(init_var, requested_steps, read_step, + port=int(port), host=str(host)) return # Validate colormap before any rendering From a84b3f369b05e0c979f99f018853a10cf2384c36 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 10:48:19 -0500 Subject: [PATCH 051/102] Move viz deps to optional extra; install on first ./mfc.sh viz pyproject.toml: matplotlib, seaborn, h5py, imageio, imageio-ffmpeg, plotext, textual, textual-plotext, dash, plotly, and tqdm moved from [project.dependencies] to [project.optional-dependencies] viz = [...]. None of these are imported by any toolchain command other than viz. viz.py: _ensure_viz_deps() runs at the top of viz() before any viz imports. It checks for matplotlib as a sentinel; if absent, installs the local toolchain[viz] extra via uv (or pip fallback) using the same UV_LINK_MODE=copy strategy as bootstrap/python.sh. On first run the user sees a one-time "Installing viz dependencies..." message; subsequent runs are instant (sentinel import succeeds immediately). This means ./mfc.sh build, run, test, etc. no longer pull in ~700 MB of visualization packages that most HPC users never need. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/viz.py | 42 +++++++++++++++++++++++++++++++++++++++- toolchain/pyproject.toml | 40 ++++++++++++++++++++++++-------------- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 91b65c255e..853a853388 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -5,12 +5,50 @@ """ import os +import importlib +import shutil +import subprocess +import sys from mfc.state import ARG -from mfc.common import MFCException +from mfc.common import MFC_ROOT_DIR, MFCException from mfc.printer import cons +def _ensure_viz_deps() -> None: + """Install the [viz] optional extras on first use. + + Checks for matplotlib as the sentinel package. If it is missing the + whole viz extra is assumed to be absent and gets installed via uv (or pip + as a fallback) from the local toolchain directory. + """ + try: + import matplotlib # noqa: F401 # pylint: disable=import-outside-toplevel,unused-import + return # already installed + except ImportError: + pass + + toolchain_path = os.path.join(MFC_ROOT_DIR, "toolchain") + cons.print("[bold]Installing viz dependencies[/bold] " + "(first run — this may take a minute)...") + + env = {**os.environ, "UV_LINK_MODE": "copy"} + if shutil.which("uv"): + cmd = ["uv", "pip", "install", f"{toolchain_path}[viz]"] + else: + cmd = [sys.executable, "-m", "pip", "install", f"{toolchain_path}[viz]"] + + result = subprocess.run(cmd, env=env, check=False) + if result.returncode != 0: + raise MFCException( + "Failed to install viz dependencies. " + f"Try manually: pip install '{toolchain_path}[viz]'" + ) + + importlib.invalidate_caches() + cons.print("[bold green]Viz dependencies installed.[/bold green]\n") + + _CMAP_POPULAR = ( 'viridis, plasma, inferno, magma, turbo, ' 'coolwarm, RdBu_r, bwr, hot, jet, gray, seismic' @@ -82,6 +120,8 @@ def _parse_steps(step_arg, available_steps): def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches """Main viz command dispatcher.""" + _ensure_viz_deps() + from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel from .renderer import render_1d, render_1d_tiled, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 44a0df26eb..0d8f592911 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -33,18 +33,6 @@ dependencies = [ "numpy", "pandas", - # Plotting - "seaborn", - "matplotlib", - - # Visualization - "h5py", - "imageio>=2.33", - "imageio-ffmpeg>=0.5.0", - "plotext>=5.2.0", - "textual>=0.47.0", - "textual-plotext>=0.2.0", - # Chemistry "cantera>=3.1.0", #"pyrometheus == 1.0.5", @@ -53,16 +41,40 @@ dependencies = [ # Frontier Profiling "astunparse==1.6.2", "colorlover", - "dash>=2.0", "pymongo", "tabulate", - "tqdm", "dash-svg", "dash-bootstrap-components", "kaleido", "plotille" ] +[project.optional-dependencies] +viz = [ + # 2D/3D plotting (renderer, TUI) + "matplotlib", + "seaborn", + + # Silo-HDF5 reader + "h5py", + + # MP4 export + "imageio>=2.33", + "imageio-ffmpeg>=0.5.0", + + # Terminal UI (--tui) + "plotext>=5.2.0", + "textual>=0.47.0", + "textual-plotext>=0.2.0", + + # Interactive web UI (--interactive) + "dash>=2.0", + "plotly", + + # Progress bar (PNG/MP4 batch rendering) + "tqdm", +] + [tool.hatch.metadata] allow-direct-references = true From 1d121751a4c9c673eedebcc7ca5e865802fadba1 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 11:30:21 -0500 Subject: [PATCH 052/102] Address Claude Code review: bounded caches, MP4 cleanup, avoid double-read #2 (weak sentinel): _ensure_viz_deps() now checks all five key packages (matplotlib, imageio, h5py, textual, dash) so a user with matplotlib pre-installed (Anaconda, etc.) but missing the other viz deps still triggers the install rather than getting raw ImportErrors later. #5 (duplicate cache): Extract bounded FIFO cache to _step_cache.py with load(), seed(), and CACHE_MAX. tui.py and interactive.py now import from the shared module. test_viz.py TestTuiCache tests _step_cache directly and gains a test_seed_clears_and_populates case. Also removes unused Dict import from tui.py (no longer needed after the _cache type annotation moved to _step_cache.py). Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/_step_cache.py | 32 ++++++++++++++++++++++++++++++++ toolchain/mfc/viz/interactive.py | 16 +++------------- toolchain/mfc/viz/test_viz.py | 27 +++++++++++++++++---------- toolchain/mfc/viz/tui.py | 24 +++++------------------- toolchain/mfc/viz/viz.py | 15 +++++++-------- 5 files changed, 64 insertions(+), 50 deletions(-) create mode 100644 toolchain/mfc/viz/_step_cache.py diff --git a/toolchain/mfc/viz/_step_cache.py b/toolchain/mfc/viz/_step_cache.py new file mode 100644 index 0000000000..279b7966f3 --- /dev/null +++ b/toolchain/mfc/viz/_step_cache.py @@ -0,0 +1,32 @@ +"""Bounded FIFO step cache shared by tui.py and interactive.py. + +Keeps up to CACHE_MAX assembled timesteps in memory, evicting the oldest +entry when the cap is reached. The module-level state is intentional: +both the TUI and the interactive server are single-instance; a per-session +cache avoids redundant disk reads while bounding peak memory usage. +""" + +from typing import Callable + +CACHE_MAX: int = 50 +_cache: dict = {} +_cache_order: list = [] + + +def load(step: int, read_func: Callable) -> object: + """Return cached data for *step*, calling *read_func* on a miss.""" + if step not in _cache: + if len(_cache) >= CACHE_MAX: + evict = _cache_order.pop(0) + _cache.pop(evict, None) + _cache[step] = read_func(step) + _cache_order.append(step) + return _cache[step] + + +def seed(step: int, data: object) -> None: + """Clear the cache and pre-populate it with already-loaded data.""" + _cache.clear() + _cache_order.clear() + _cache[step] = data + _cache_order.append(step) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 52a23c2b98..100eb5e916 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -15,6 +15,7 @@ from dash import Dash, dcc, html, Input, Output, State, callback_context, no_update from mfc.printer import cons +from . import _step_cache # --------------------------------------------------------------------------- # Colormaps available in the picker @@ -51,19 +52,8 @@ # --------------------------------------------------------------------------- # Server-side data cache {step -> AssembledData} (bounded to avoid OOM) # --------------------------------------------------------------------------- -_CACHE_MAX = 50 -_cache: dict = {} -_cache_order: list = [] - - -def _load(step: int, read_func: Callable): - if step not in _cache: - if len(_cache) >= _CACHE_MAX: - evict = _cache_order.pop(0) - _cache.pop(evict, None) - _cache[step] = read_func(step) - _cache_order.append(step) - return _cache[step] +_load = _step_cache.load +_CACHE_MAX = _step_cache.CACHE_MAX # --------------------------------------------------------------------------- diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 89368ab49d..33ecaaa4a6 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -453,13 +453,13 @@ def test_typo_suggests_correct(self): # --------------------------------------------------------------------------- class TestTuiCache(unittest.TestCase): - """Test that the TUI step cache respects CACHE_MAX.""" + """Test that the shared step cache respects CACHE_MAX.""" def setUp(self): - import mfc.viz.tui as tui_mod - self._mod = tui_mod - tui_mod._cache.clear() - tui_mod._cache_order.clear() + import mfc.viz._step_cache as cache_mod + self._mod = cache_mod + cache_mod._cache.clear() + cache_mod._cache_order.clear() def tearDown(self): self._mod._cache.clear() @@ -470,7 +470,7 @@ def _read(self, step): def test_cache_stores_entry(self): """Loaded step is stored in cache.""" - self._mod._load(0, self._read) + self._mod.load(0, self._read) self.assertIn(0, self._mod._cache) def test_cache_hit_avoids_reload(self): @@ -479,19 +479,26 @@ def test_cache_hit_avoids_reload(self): def counting(step): calls[0] += 1 return step - self._mod._load(5, counting) - self._mod._load(5, counting) + self._mod.load(5, counting) + self._mod.load(5, counting) self.assertEqual(calls[0], 1) def test_cache_evicts_oldest_at_cap(self): """Oldest entry is evicted when CACHE_MAX is exceeded.""" - cap = self._mod._CACHE_MAX + cap = self._mod.CACHE_MAX for i in range(cap + 3): - self._mod._load(i, self._read) + self._mod.load(i, self._read) self.assertLessEqual(len(self._mod._cache), cap) self.assertNotIn(0, self._mod._cache) # first evicted self.assertIn(cap + 2, self._mod._cache) # most recent kept + def test_seed_clears_and_populates(self): + """seed() clears existing cache and pre-loads one entry.""" + self._mod.load(99, self._read) # put something in first + self._mod.seed(0, "preloaded") + self.assertEqual(len(self._mod._cache), 1) + self.assertEqual(self._mod._cache[0], "preloaded") + # --------------------------------------------------------------------------- # Tests: log scale rendering (new feature smoke tests) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 21f66f0677..1a022d4b34 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -11,7 +11,7 @@ """ from __future__ import annotations -from typing import Callable, Dict, List, Optional +from typing import Callable, List, Optional import numpy as np @@ -30,6 +30,7 @@ from textual_plotext import PlotextPlot from mfc.printer import cons +from . import _step_cache # Colormaps available via [c] cycling _CMAPS: List[str] = [ @@ -37,22 +38,8 @@ 'coolwarm', 'RdBu_r', 'seismic', 'gray', ] -# --------------------------------------------------------------------------- -# Step cache {step -> AssembledData} (bounded to avoid OOM) -# --------------------------------------------------------------------------- -_CACHE_MAX = 50 -_cache: Dict[int, object] = {} -_cache_order: List[int] = [] - - -def _load(step: int, read_func: Callable) -> object: - if step not in _cache: - if len(_cache) >= _CACHE_MAX: - evict = _cache_order.pop(0) - _cache.pop(evict, None) - _cache[step] = read_func(step) - _cache_order.append(step) - return _cache[step] +_load = _step_cache.load +_CACHE_MAX = _step_cache.CACHE_MAX # --------------------------------------------------------------------------- @@ -456,8 +443,7 @@ def run_tui( ) cons.print("[dim] ,/. or ←/→ prev/next step • space play • l log • f freeze • ↑↓ variable • q quit[/dim]") - _cache.clear() - _cache[steps[0]] = first + _step_cache.seed(steps[0], first) app = MFCTuiApp( steps=steps, diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 853a853388..cd1b80af79 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -6,6 +6,7 @@ import os import importlib +import importlib.util import shutil import subprocess import sys @@ -18,15 +19,13 @@ def _ensure_viz_deps() -> None: """Install the [viz] optional extras on first use. - Checks for matplotlib as the sentinel package. If it is missing the - whole viz extra is assumed to be absent and gets installed via uv (or pip - as a fallback) from the local toolchain directory. + Checks one sentinel per feature group so that a user who has matplotlib + pre-installed (e.g. via Anaconda) but lacks imageio, textual, or h5py + still triggers the install. """ - try: - import matplotlib # noqa: F401 # pylint: disable=import-outside-toplevel,unused-import - return # already installed - except ImportError: - pass + _SENTINELS = ("matplotlib", "imageio", "h5py", "textual", "dash") + if all(importlib.util.find_spec(p) is not None for p in _SENTINELS): + return # all present toolchain_path = os.path.join(MFC_ROOT_DIR, "toolchain") cons.print("[bold]Installing viz dependencies[/bold] " From 45070f97bf729b6d2b598c0ab17841f8293dd80e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 11:53:48 -0500 Subject: [PATCH 053/102] Add render_2d_tiled: tile all variables for 2D --var all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When --var is omitted or 'all' with 2D data, render a tiled subplot grid of all variables instead of auto-selecting the first one. - renderer.py: add render_2d_tiled() with pcolormesh subplots (≤3 cols) - renderer.py: wire render_2d_tiled into render_mp4 tiled path - viz.py: add tiled 2D dispatch branch in the rendering loop - viz.py: only auto-select first variable for 3D (not 2D anymore) Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 55 +++++++++++++++++++++++++++++++++++ toolchain/mfc/viz/viz.py | 9 +++--- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index bf870f2805..5c9fbe048e 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -200,6 +200,59 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab plt.close(fig) +def render_2d_tiled(assembled, step, output, **opts): # pylint: disable=too-many-locals + """Render all 2D variables in a tiled subplot grid and save as PNG.""" + varnames = sorted(assembled.variables.keys()) + n = len(varnames) + if n == 0: + return + if n == 1: + render_2d(assembled.x_cc, assembled.y_cc, + assembled.variables[varnames[0]], varnames[0], step, output, **opts) + return + + ncols = min(n, 3) + nrows = math.ceil(n / ncols) + cell_w, cell_h = _figsize_for_domain(assembled.x_cc, assembled.y_cc, base=4) + fig, axes = plt.subplots(nrows, ncols, + figsize=opts.get('figsize', (cell_w * ncols, cell_h * nrows)), + squeeze=False) + + cmap = opts.get('cmap', 'viridis') + log_scale = opts.get('log_scale', False) + for idx, vn in enumerate(varnames): + row, col = divmod(idx, ncols) + ax = axes[row][col] + data = assembled.variables[vn] + norm = None + vmin = opts.get('vmin') + vmax = opts.get('vmax') + if log_scale and np.any(data > 0): + lo = float(np.nanmin(data[data > 0])) + hi = float(np.nanmax(data)) + if np.isfinite(lo) and np.isfinite(hi) and lo < hi: + norm = LogNorm(vmin=lo, vmax=hi) + vmin = None + vmax = None + pcm = ax.pcolormesh(assembled.x_cc, assembled.y_cc, data.T, + cmap=cmap, vmin=vmin, vmax=vmax, + norm=norm, shading='auto') + label = pretty_label(vn) + fig.colorbar(pcm, ax=ax, label=label) + ax.set_title(label, fontsize=9) + ax.set_aspect('equal', adjustable='box') + ax.tick_params(labelsize=7) + + for idx in range(n, nrows * ncols): + row, col = divmod(idx, ncols) + axes[row][col].set_visible(False) + + fig.suptitle(f'step {step}', fontsize=11, y=1.01) + fig.tight_layout() + fig.savefig(output, dpi=opts.get('dpi', 150), bbox_inches='tight') + plt.close(fig) + + def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches slice_index=None, slice_value=None, **opts): """Extract a 2D slice from 3D data and render as a colormap.""" @@ -363,6 +416,8 @@ def _cleanup(): if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, step, frame_path, **opts) + elif tiled and assembled.ndim == 2: + render_2d_tiled(assembled, step, frame_path, **opts) elif assembled.ndim == 1: var_data = assembled.variables.get(varname) if var_data is None: diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index cd1b80af79..9e8c0c32f2 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -122,7 +122,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc _ensure_viz_deps() from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel - from .renderer import render_1d, render_1d_tiled, render_2d, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel + from .renderer import render_1d, render_1d_tiled, render_2d, render_2d_tiled, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel case_dir = ARG('input') if case_dir is None: @@ -267,10 +267,9 @@ def read_step(step): f"Refusing to load {len(requested_steps)} timesteps for 3D data " "(limit is 500). Use --step with a range or stride to reduce.") - # Tiled mode for non-TUI, non-interactive rendering only works for 1D. - # For 2D/3D, auto-select the first available variable. + # Tiled mode works for 1D and 2D. For 3D, auto-select the first variable. if tiled and not interactive and not ARG('tui'): - if test_assembled.ndim != 1: + if test_assembled.ndim == 3: varname = avail[0] if avail else None if varname is None: raise MFCException("No variables found in output.") @@ -355,6 +354,8 @@ def read_step(step): if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, step, output_path, **render_opts) + elif tiled and assembled.ndim == 2: + render_2d_tiled(assembled, step, output_path, **render_opts) elif assembled.ndim == 1: render_1d(assembled.x_cc, assembled.variables[varname], varname, step, output_path, **render_opts) From 59086c73dbfcc672bc652ec0d9b0da2d15c70839 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 12:05:18 -0500 Subject: [PATCH 054/102] Add comma-list and ellipsis step syntax to --step Supports two new --step formats: 0,100,200,1000 explicit comma-separated list 0,100,200,...,1000 ellipsis expands with inferred stride (=100) Both formats return the intersection with available timesteps, matching the existing range (start:end:stride) behavior. - viz.py: extract _parse_step_list(); update _parse_steps() docstring and error message; handle ',' branch before ':' branch - commands.py: update --step help text, key_options, and add example - test_viz.py: 6 new tests (comma list, filtering, ellipsis expansion, partial availability, error cases for bad ellipsis positions) - lint.sh: register mfc.viz.test_viz in the unit-test suite Co-Authored-By: Claude Sonnet 4.6 --- toolchain/bootstrap/lint.sh | 1 + toolchain/mfc/cli/commands.py | 7 ++-- toolchain/mfc/viz/test_viz.py | 35 ++++++++++++++++ toolchain/mfc/viz/viz.py | 77 +++++++++++++++++++++++++++++++---- 4 files changed, 109 insertions(+), 11 deletions(-) diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 2a28bd18ef..89649cd5aa 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -32,6 +32,7 @@ if [ "$RUN_TESTS" = true ]; then cd "$(pwd)/toolchain" python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v + python3 -m unittest mfc.viz.test_viz -v cd - > /dev/null fi diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 2f6bf888fd..bc57c5f783 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -882,7 +882,7 @@ ), Argument( name="step", - help="Timestep(s): single int, start:end:stride, 'last', or 'all' (default: last).", + help="Timestep(s): int, start:end:stride, 0,100,200, 0,100,...,1000, 'last', or 'all' (default: last).", type=str, default='last', metavar="STEP", @@ -1048,13 +1048,14 @@ Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Plot pressure at step 1000"), Example("./mfc.sh viz case_dir/ --list-vars --step 0", "List available variables"), Example("./mfc.sh viz case_dir/ --list-steps", "List available timesteps"), - Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video"), + Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video from range"), + Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Tile all vars at specific steps"), Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z", "3D slice at z midplane"), Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D)"), ], key_options=[ ("--var NAME", "Variable to visualize"), - ("--step STEP", "Timestep(s): int, start:end:stride, or 'all'"), + ("--step STEP", "Timestep(s): int, start:end:stride, 0,100,...,1000, or 'all'"), ("--list-vars", "List available variables"), ("--list-steps", "List available timesteps"), ("--mp4", "Generate MP4 video"), diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 33ecaaa4a6..1d7cdc14ed 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -68,6 +68,41 @@ def test_range_no_stride(self): result = self._parse('0:2', [0, 1, 2, 3]) self.assertEqual(result, [0, 1, 2]) + def test_comma_list(self): + """Comma-separated list selects the intersection with available steps.""" + result = self._parse('0,100,200,1000', [0, 100, 200, 300, 1000]) + self.assertEqual(result, [0, 100, 200, 1000]) + + def test_comma_list_filters_unavailable(self): + """Comma list silently drops steps not in available.""" + result = self._parse('0,999', [0, 100, 200]) + self.assertEqual(result, [0]) + + def test_ellipsis_expansion(self): + """Ellipsis infers stride and expands the range.""" + result = self._parse('0,100,200,...,1000', + list(range(0, 1001, 100))) + self.assertEqual(result, list(range(0, 1001, 100))) + + def test_ellipsis_partial_available(self): + """Ellipsis expansion filters to only available steps.""" + # only even-numbered hundreds available + avail = [0, 200, 400, 600, 800, 1000] + result = self._parse('0,100,...,1000', avail) + self.assertEqual(result, [0, 200, 400, 600, 800, 1000]) + + def test_ellipsis_requires_two_prefix_values(self): + """Ellipsis with only one prefix value raises MFCException.""" + from mfc.common import MFCException + with self.assertRaises(MFCException): + self._parse('0,...,1000', [0, 100, 1000]) + + def test_ellipsis_must_be_second_to_last(self): + """Ellipsis not in second-to-last position raises MFCException.""" + from mfc.common import MFCException + with self.assertRaises(MFCException): + self._parse('0,100,...,500,1000', [0, 100, 500, 1000]) + def test_invalid_value(self): """Non-numeric, non-keyword input raises MFCException.""" from mfc.common import MFCException diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 9e8c0c32f2..1e92a4f779 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -81,14 +81,68 @@ def _steps_hint(steps, n=8): return f"{head}, ... [{len(steps)} total] ..., {tail}" +def _parse_step_list(s, available_steps): + """ + Parse a comma-separated step list, with optional '...' ellipsis expansion. + + Examples: + "0,100,200,1000" -> [0, 100, 200, 1000] (intersection with available) + "0,100,200,...,1000" -> range(0, 1001, 100) (infers stride=100 from last pair) + """ + parts = [p.strip() for p in s.split(',')] + avail_set = set(available_steps) + + if '...' in parts: + idx = parts.index('...') + if idx < 2: + raise MFCException( + f"Invalid --step value '{s}'. " + "Ellipsis '...' requires at least two values before it, " + "e.g. '0,100,...,1000'." + ) + if idx != len(parts) - 2: + raise MFCException( + f"Invalid --step value '{s}'. " + "Ellipsis '...' must be the second-to-last item, " + "e.g. '0,100,...,1000'." + ) + try: + prefix = [int(p) for p in parts[:idx]] + end = int(parts[idx + 1]) + except ValueError as exc: + raise MFCException( + f"Invalid --step value '{s}': all values must be integers." + ) from exc + + stride = prefix[-1] - prefix[-2] + if stride <= 0: + raise MFCException( + f"Invalid --step value '{s}': " + f"ellipsis stride must be positive (got {stride})." + ) + requested = list(range(prefix[0], end + 1, stride)) + else: + try: + requested = [int(p) for p in parts] + except ValueError as exc: + raise MFCException( + f"Invalid --step value '{s}': all values must be integers." + ) from exc + + return [t for t in requested if t in avail_set] + + def _parse_steps(step_arg, available_steps): """ Parse the --step argument into a list of timestep integers. Formats: - - Single int: "1000" - - Range: "0:10000:500" (start:end:stride) - - "all": all available timesteps + - Single int: "1000" + - Range: "0:10000:500" (start:end:stride) + - Comma list: "0,100,200,1000" + - Ellipsis list: "0,100,200,...,1000" (stride inferred from last pair) + - "last": last available timestep + - "all": all available timesteps """ if step_arg is None or step_arg == 'all': return available_steps @@ -96,20 +150,27 @@ def _parse_steps(step_arg, available_steps): if step_arg == 'last': return [available_steps[-1]] if available_steps else [] + s = str(step_arg) + + if ',' in s: + return _parse_step_list(s, available_steps) + try: - if ':' in str(step_arg): - parts = str(step_arg).split(':') + if ':' in s: + parts = s.split(':') start = int(parts[0]) end = int(parts[1]) stride = int(parts[2]) if len(parts) > 2 else 1 requested = list(range(start, end + 1, stride)) - return [s for s in requested if s in set(available_steps)] + return [t for t in requested if t in set(available_steps)] - single = int(step_arg) + single = int(s) except ValueError as exc: raise MFCException( f"Invalid --step value '{step_arg}'. " - "Expected an integer, a range (start:end:stride), 'last', or 'all'." + "Expected an integer, a range (start:end:stride), " + "a comma list (0,100,200), an ellipsis list (0,100,...,1000), " + "'last', or 'all'." ) from exc if available_steps and single not in set(available_steps): From dc02a3468260f4375e3924ff93b006d6526d89ef Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 12:14:37 -0500 Subject: [PATCH 055/102] Fix MP4 frame-size inconsistency from bbox_inches='tight' bbox_inches='tight' trims to varying pixel dimensions per frame (colorbar tick labels change). macro_block_size=2 was independently rounding each odd-width frame, producing differing sizes -> 'All images in a movie should have same size' error. Read first frame as reference size (rounded up to even for yuv420p), pad any diverging frames with white, and use macro_block_size=1 to disable imageio's own resize since we handle even dims ourselves. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 5c9fbe048e..520c60785d 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -450,12 +450,35 @@ def _cleanup(): success = False try: + if not frame_files: + return False + + # Determine reference dimensions from the first frame. + # Round up to the nearest even pixel: yuv420p requires even width and height. + first_arr = imageio.imread(os.path.join(viz_dir, frame_files[0])) + ref_h = first_arr.shape[0] + first_arr.shape[0] % 2 + ref_w = first_arr.shape[1] + first_arr.shape[1] % 2 + n_ch = first_arr.shape[2] if first_arr.ndim == 3 else 1 + + def _uniform_frame(arr): + """Pad with white to (ref_h, ref_w) so all frames are identical in size.""" + h, w = arr.shape[:2] + if h == ref_h and w == ref_w: + return arr + out = np.full((ref_h, ref_w, n_ch), 255, dtype=arr.dtype) + out[:h, :w] = arr + return out + + # macro_block_size=1 disables imageio's own resize — we handle even dims above. with imageio.get_writer( output, fps=fps, codec='libx264', pixelformat='yuv420p', - macro_block_size=2, ffmpeg_log_level='error', + macro_block_size=1, ffmpeg_log_level='error', ) as writer: - for fname in frame_files: - writer.append_data(imageio.imread(os.path.join(viz_dir, fname))) + writer.append_data(_uniform_frame(first_arr)) + for fname in frame_files[1:]: + writer.append_data(_uniform_frame( + imageio.imread(os.path.join(viz_dir, fname)) + )) success = True except (OSError, ValueError, RuntimeError) as exc: print(f"imageio MP4 write failed: {exc}") From 475bfd739b8135f52348abf4d735afc24c8af469 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 12:18:10 -0500 Subject: [PATCH 056/102] Fix MP4 uniform frame size: scan all frames for max dims The previous fix used the first frame as the reference but some later frames were larger (not just smaller), causing the pad-only logic to fail with a broadcast shape error. Two-pass approach: scan all PNG frames to find the true max (h, w), round up to even for yuv420p, then pad every frame to that size. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 520c60785d..58e5a2460d 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -453,12 +453,16 @@ def _cleanup(): if not frame_files: return False - # Determine reference dimensions from the first frame. + # First pass: find the maximum frame dimensions across all frames. # Round up to the nearest even pixel: yuv420p requires even width and height. - first_arr = imageio.imread(os.path.join(viz_dir, frame_files[0])) - ref_h = first_arr.shape[0] + first_arr.shape[0] % 2 - ref_w = first_arr.shape[1] + first_arr.shape[1] % 2 - n_ch = first_arr.shape[2] if first_arr.ndim == 3 else 1 + max_h, max_w, n_ch = 0, 0, 3 + for fname in frame_files: + arr = imageio.imread(os.path.join(viz_dir, fname)) + max_h = max(max_h, arr.shape[0]) + max_w = max(max_w, arr.shape[1]) + n_ch = arr.shape[2] if arr.ndim == 3 else 1 + ref_h = max_h + max_h % 2 + ref_w = max_w + max_w % 2 def _uniform_frame(arr): """Pad with white to (ref_h, ref_w) so all frames are identical in size.""" @@ -469,13 +473,13 @@ def _uniform_frame(arr): out[:h, :w] = arr return out - # macro_block_size=1 disables imageio's own resize — we handle even dims above. + # Second pass: encode. macro_block_size=1 disables imageio's own resize + # since we already ensured even dimensions above. with imageio.get_writer( output, fps=fps, codec='libx264', pixelformat='yuv420p', macro_block_size=1, ffmpeg_log_level='error', ) as writer: - writer.append_data(_uniform_frame(first_arr)) - for fname in frame_files[1:]: + for fname in frame_files: writer.append_data(_uniform_frame( imageio.imread(os.path.join(viz_dir, fname)) )) From 412f36aedb08afe69dd0906b8c11bda8752deb07 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 12:29:12 -0500 Subject: [PATCH 057/102] Address review: normalize MP4 frames to RGB; test negative _dedup decimals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3: Add _to_rgb() helper in render_mp4 that converts RGBA→RGB (drop alpha) and grayscale→RGB before encoding. _uniform_frame now always produces uint8 RGB, so a mixed RGBA/RGB frame sequence can no longer cause a channel-count shape error. #4: Add test_very_large_extent_dedup_negative_decimals for scale=1e13 where _dedup computes decimals=-1 (np.round to nearest 10). Cell widths of 2.5e12 >> 10, so distinct centers must not be collapsed. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/renderer.py | 20 ++++++++++++++++---- toolchain/mfc/viz/test_viz.py | 27 ++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 58e5a2460d..736631d811 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -453,23 +453,35 @@ def _cleanup(): if not frame_files: return False + def _to_rgb(arr): + """Normalise an image array to uint8 RGB (3-channel). + + imageio may return RGBA (4-ch) or even grayscale depending on the + PNG source. libx264/yuv420p requires consistent 3-channel input. + """ + if arr.ndim == 2: # grayscale → RGB + arr = np.stack([arr, arr, arr], axis=-1) + elif arr.shape[2] == 4: # RGBA → RGB (drop alpha) + arr = arr[:, :, :3] + return arr.astype(np.uint8) + # First pass: find the maximum frame dimensions across all frames. # Round up to the nearest even pixel: yuv420p requires even width and height. - max_h, max_w, n_ch = 0, 0, 3 + max_h, max_w = 0, 0 for fname in frame_files: arr = imageio.imread(os.path.join(viz_dir, fname)) max_h = max(max_h, arr.shape[0]) max_w = max(max_w, arr.shape[1]) - n_ch = arr.shape[2] if arr.ndim == 3 else 1 ref_h = max_h + max_h % 2 ref_w = max_w + max_w % 2 def _uniform_frame(arr): - """Pad with white to (ref_h, ref_w) so all frames are identical in size.""" + """Convert to RGB and pad with white to (ref_h, ref_w).""" + arr = _to_rgb(arr) h, w = arr.shape[:2] if h == ref_h and w == ref_w: return arr - out = np.full((ref_h, ref_w, n_ch), 255, dtype=arr.dtype) + out = np.full((ref_h, ref_w, 3), 255, dtype=np.uint8) out[:h, :w] = arr return out diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 1d7cdc14ed..a882731893 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -617,7 +617,7 @@ def test_large_extent_dedup(self): """Deduplication works correctly for large-extent domains (>1e6).""" import numpy as np from .reader import assemble_from_proc_data - # Scale up by 1e7 to exercise the negative-decimals code path + # Scale up by 1e7: extent=1e7, decimals = ceil(-log10(1e7)) + 12 = 5 scale = 1e7 p0 = self._make_proc( [0.00 * scale, 0.25 * scale, 0.50 * scale, 0.75 * scale], @@ -633,6 +633,31 @@ def test_large_extent_dedup(self): result.variables['pres'], [1.0, 2.0, 3.0, 4.0] ) + def test_very_large_extent_dedup_negative_decimals(self): + """Deduplication works for extent ~1e13 where decimals becomes negative. + + At scale=1e13: extent = 1e13, decimals = ceil(-log10(1e13)) + 12 = -1. + np.round with negative decimals rounds to the nearest 10^|d|, so + np.round(x, -1) rounds to the nearest 10. Cell widths of 2.5e12 + are >> 10, so distinct cell-centers must not be collapsed. + """ + import numpy as np + from .reader import assemble_from_proc_data + scale = 1e13 + p0 = self._make_proc( + [0.00 * scale, 0.25 * scale, 0.50 * scale, 0.75 * scale], + [1.0, 2.0, 3.0], + ) + p1 = self._make_proc( + [0.25 * scale, 0.50 * scale, 0.75 * scale, 1.00 * scale], + [2.0, 3.0, 4.0], + ) + result = assemble_from_proc_data([(0, p0), (1, p1)]) + self.assertEqual(len(result.x_cc), 4) + np.testing.assert_allclose( + result.variables['pres'], [1.0, 2.0, 3.0, 4.0] + ) + if __name__ == "__main__": unittest.main() From 7d04f9a0fbe9eedfe2dfdf8b011eeb20eb5a500e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 18:33:42 -0500 Subject: [PATCH 058/102] TUI 2D: preserve physical domain aspect ratio in heatmap Terminal character cells are ~2x taller than wide (_CELL_RATIO=2.0). Previously the heatmap always filled the full widget, stretching it. Now the 2D render computes the physical x/y extent ratio, clamps it to [_ASPECT_MIN, _ASPECT_MAX] = [0.2, 5.0] to avoid unusable slivers, multiplies by _CELL_RATIO to get the desired col:row character ratio, then fits within the available character budget (height-constrained first, width-constrained fallback). The heatmap leaves blank space rather than stretching to fill the terminal. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/tui.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 1a022d4b34..6cd990803c 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -41,6 +41,16 @@ _load = _step_cache.load _CACHE_MAX = _step_cache.CACHE_MAX +# Terminal character cells are approximately twice as tall as they are wide in +# pixels (e.g. 8 px wide × 16 px tall). A square physical domain should +# therefore occupy a ~2:1 (col:row) character grid to look correct. +_CELL_RATIO: float = 2.0 + +# Physical domain aspect ratio is clamped to this range so that very elongated +# domains don't produce unusable slivers. +_ASPECT_MIN: float = 0.2 +_ASPECT_MAX: float = 5.0 + # --------------------------------------------------------------------------- # Plot widget @@ -113,11 +123,31 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- # Content area = widget size minus 1-char border on each side. # Reserve 1 row each for header and footer → h_plot rows for the image. w_plot = max(self.size.width - 2, 4) - h_plot = max(self.size.height - 4, 4) # -2 border, -2 header+footer + h_plot_avail = max(self.size.height - 4, 4) # -2 border, -2 header+footer # Right side: gap + gradient strip + value labels _CB_GAP, _CB_W, _CB_LBL = 1, 2, 9 - w_map = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) + w_map_avail = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) + + # Preserve the physical x/y aspect ratio so the heatmap is not + # stretched to fill the terminal. The domain ratio is clamped to + # avoid extremely wide or tall slivers. + y_cc_2d = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) + x_extent = max(abs(float(x_cc[-1]) - float(x_cc[0])), 1e-30) # pylint: disable=unsubscriptable-object + y_extent = max(abs(float(y_cc_2d[-1]) - float(y_cc_2d[0])), 1e-30) + domain_ratio = float(np.clip(x_extent / y_extent, _ASPECT_MIN, _ASPECT_MAX)) + # Convert to character-grid ratio: 1 row ≈ _CELL_RATIO columns wide. + char_ratio = domain_ratio * _CELL_RATIO # desired w_map / h_plot + + # Fit within the available character budget: try height-constrained first, + # fall back to width-constrained if the ideal width exceeds w_map_avail. + w_ideal = int(round(h_plot_avail * char_ratio)) + if w_ideal <= w_map_avail: + w_map = max(w_ideal, 4) + h_plot = h_plot_avail + else: + h_plot = max(int(round(w_map_avail / char_ratio)), 4) + w_map = w_map_avail ix = np.linspace(0, data.shape[0] - 1, w_map, dtype=int) iy = np.linspace(0, data.shape[1] - 1, h_plot, dtype=int) From a67bc37134c9e7879cab5e83ee27842d618c7133 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 20:11:27 -0500 Subject: [PATCH 059/102] Remove legacy viz scripts and viz_legacy module; fix lint for viz extras - Delete analyze.py, export.py (nD_perfect_reactor) and viz.py (1D shocktube examples) which all used mfc.viz_legacy - Delete toolchain/mfc/viz_legacy.py (superseded by ./mfc.sh viz) - Update nD_perfect_reactor/README.md to remove analyze.py invocation - lint.sh: install mfc[viz] optional extras before running pylint and test_viz so optional imports (imageio, h5py, textual) resolve cleanly - tui.py: suppress R0903 on MFCPlot (inherits many methods from parent) Co-Authored-By: Claude Sonnet 4.6 --- examples/1D_inert_shocktube/viz.py | 80 ------------- examples/1D_reactive_shocktube/viz.py | 80 ------------- examples/nD_perfect_reactor/README.md | 8 -- examples/nD_perfect_reactor/analyze.py | 107 ------------------ examples/nD_perfect_reactor/export.py | 76 ------------- toolchain/bootstrap/lint.sh | 3 + toolchain/mfc/viz/tui.py | 2 +- toolchain/mfc/viz_legacy.py | 150 ------------------------- 8 files changed, 4 insertions(+), 502 deletions(-) delete mode 100644 examples/1D_inert_shocktube/viz.py delete mode 100644 examples/1D_reactive_shocktube/viz.py delete mode 100644 examples/nD_perfect_reactor/analyze.py delete mode 100644 examples/nD_perfect_reactor/export.py delete mode 100644 toolchain/mfc/viz_legacy.py diff --git a/examples/1D_inert_shocktube/viz.py b/examples/1D_inert_shocktube/viz.py deleted file mode 100644 index eb477f2c2a..0000000000 --- a/examples/1D_inert_shocktube/viz.py +++ /dev/null @@ -1,80 +0,0 @@ -import mfc.viz_legacy as mfc_viz -import os - -import subprocess -import seaborn as sns -import matplotlib.pyplot as plt -from tqdm import tqdm - -from case import sol_L as sol - -case = mfc_viz.Case(".") - -os.makedirs("viz", exist_ok=True) - -# sns.set_theme(style=mfc_viz.generate_cpg_style()) - -Y_VARS = ["H2", "O2", "H2O", "N2"] - -variables = [ - ("rho", "prim.1"), - ("u_x", "prim.2"), - ("p", "prim.3"), - ("E", "cons.3"), - *[(f"Y_{name}", f"prim.{5 + sol.species_index(name)}") for name in Y_VARS], - ("T", "prim.15"), -] - -for variable in tqdm(variables, desc="Loading Variables"): - case.load_variable(*variable) - -for step in tqdm(case.get_timesteps(), desc="Rendering Frames"): - fig, axes = plt.subplots(2, 3, figsize=(16, 9)) - - def pad_ylim(ylim, pad=0.1): - return ( - ylim[0] - pad * (ylim[1] - ylim[0]), - ylim[1] + pad * (ylim[1] - ylim[0]), - ) - - case.plot_step(step, "rho", ax=axes[0, 0]) - axes[0, 0].set_ylim(*pad_ylim(case.get_minmax_time("rho"))) - axes[0, 0].set_ylabel("$\\rho$") - case.plot_step(step, "u_x", ax=axes[0, 1]) - axes[0, 1].set_ylim(*pad_ylim(case.get_minmax_time("u_x"))) - axes[0, 1].set_ylabel("$u_x$") - case.plot_step(step, "p", ax=axes[1, 0]) - axes[1, 0].set_ylim(*pad_ylim(case.get_minmax_time("p"))) - axes[1, 0].set_ylabel("$p$") - for y in Y_VARS: - case.plot_step(step, f"Y_{y}", ax=axes[1, 1], label=y) - axes[1, 1].set_ylim(0, 1.1 * max(case.get_minmax_time(f"Y_{y}")[1] for y in Y_VARS)) - axes[1, 1].set_ylabel("$Y_k$") - case.plot_step(step, "T", ax=axes[1, 2]) - axes[1, 2].set_ylim(*pad_ylim(case.get_minmax_time("T"))) - axes[1, 2].set_ylabel("$T$") - case.plot_step(step, "E", ax=axes[0, 2]) - axes[0, 2].set_ylim(*pad_ylim(case.get_minmax_time("E"))) - axes[0, 2].set_ylabel("$E$") - - plt.tight_layout() - plt.savefig(f"viz/{step:06d}.png") - plt.close() - -subprocess.run( - [ - "ffmpeg", - "-y", - "-framerate", - "60", - "-pattern_type", - "glob", - "-i", - "viz/*.png", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "viz.mp4", - ] -) diff --git a/examples/1D_reactive_shocktube/viz.py b/examples/1D_reactive_shocktube/viz.py deleted file mode 100644 index 2a38e21b2c..0000000000 --- a/examples/1D_reactive_shocktube/viz.py +++ /dev/null @@ -1,80 +0,0 @@ -import mfc.viz_legacy as mfc_viz -import os - -import subprocess -import seaborn as sns -import matplotlib.pyplot as plt -from tqdm import tqdm - -from case import sol_L as sol - -case = mfc_viz.Case(".") - -os.makedirs("viz", exist_ok=True) - -sns.set_theme(style=mfc_viz.generate_cpg_style()) - -Y_VARS = ["H2", "O2", "H2O", "N2"] - -variables = [ - ("rho", "prim.1"), - ("u_x", "prim.2"), - ("p", "prim.3"), - ("E", "cons.3"), - *[(f"Y_{name}", f"prim.{5 + sol.species_index(name)}") for name in Y_VARS], - ("T", "prim.15"), -] - -for variable in tqdm(variables, desc="Loading Variables"): - case.load_variable(*variable) - -for step in tqdm(case.get_timesteps(), desc="Rendering Frames"): - fig, axes = plt.subplots(2, 3, figsize=(16, 9)) - - def pad_ylim(ylim, pad=0.1): - return ( - ylim[0] - pad * (ylim[1] - ylim[0]), - ylim[1] + pad * (ylim[1] - ylim[0]), - ) - - case.plot_step(step, "rho", ax=axes[0, 0]) - axes[0, 0].set_ylim(*pad_ylim(case.get_minmax_time("rho"))) - axes[0, 0].set_ylabel("$\\rho$") - case.plot_step(step, "u_x", ax=axes[0, 1]) - axes[0, 1].set_ylim(*pad_ylim(case.get_minmax_time("u_x"))) - axes[0, 1].set_ylabel("$u_x$") - case.plot_step(step, "p", ax=axes[1, 0]) - axes[1, 0].set_ylim(*pad_ylim(case.get_minmax_time("p"))) - axes[1, 0].set_ylabel("$p$") - for y in Y_VARS: - case.plot_step(step, f"Y_{y}", ax=axes[1, 1], label=y) - axes[1, 1].set_ylim(0, 1.1 * max(case.get_minmax_time(f"Y_{y}")[1] for y in Y_VARS)) - axes[1, 1].set_ylabel("$Y_k$") - case.plot_step(step, "T", ax=axes[1, 2]) - axes[1, 2].set_ylim(*pad_ylim(case.get_minmax_time("T"))) - axes[1, 2].set_ylabel("$T$") - case.plot_step(step, "E", ax=axes[0, 2]) - axes[0, 2].set_ylim(*pad_ylim(case.get_minmax_time("E"))) - axes[0, 2].set_ylabel("$E$") - - plt.tight_layout() - plt.savefig(f"viz/{step:06d}.png") - plt.close() - -subprocess.run( - [ - "ffmpeg", - "-y", - "-framerate", - "60", - "-pattern_type", - "glob", - "-i", - "viz/*.png", - "-c:v", - "libx264", - "-pix_fmt", - "yuv420p", - "viz.mp4", - ] -) diff --git a/examples/nD_perfect_reactor/README.md b/examples/nD_perfect_reactor/README.md index 00c2cec69a..869a411f5a 100644 --- a/examples/nD_perfect_reactor/README.md +++ b/examples/nD_perfect_reactor/README.md @@ -3,12 +3,4 @@ Reference: > G. B. Skinner and G. H. Ringrose, “Ignition Delays of a Hydrogen—Oxygen—Argon Mixture at Relatively Low Temperatures”, J. Chem. Phys., vol. 42, no. 6, pp. 2190–2192, Mar. 1965. Accessed: Oct. 13, 2024. -```bash -$ python3 analyze.py -Induction Times ([OH] >= 1e-6): - + Skinner et al.: 5.200e-05 s - + Cantera: 5.130e-05 s - + (Che)MFC: 5.130e-05 s -``` - diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py deleted file mode 100644 index 51ec9f3456..0000000000 --- a/examples/nD_perfect_reactor/analyze.py +++ /dev/null @@ -1,107 +0,0 @@ -import cantera as ct -import seaborn as sns -from tqdm import tqdm -import matplotlib.pyplot as plt - -import mfc.viz_legacy as mfc_viz -from case import dt, Tend, SAVE_COUNT, sol - -case = mfc_viz.Case(".", dt) - -sns.set_theme(style=mfc_viz.generate_cpg_style()) - -Y_MAJORS = set(["H", "O", "OH", "HO2"]) -Y_MINORS = set(["H2O", "H2O2"]) -Y_VARS = Y_MAJORS | Y_MINORS - -for name in tqdm(Y_VARS, desc="Loading Variables"): - case.load_variable(f"Y_{name}", f"prim.{5 + sol.species_index(name)}") -case.load_variable("rho", "prim.1") - -fig, axes = plt.subplots(1, 2, figsize=(12, 6)) - -mfc_plots = [[], []] -for y in Y_MAJORS: - mfc_plots[0].append(case.plot_time(f"Y_{y}", ax=axes[0], label=f"${y}$")) -for y in Y_MINORS: - mfc_plots[1].append(case.plot_time(f"Y_{y}", ax=axes[1], label=f"${y}$")) - -time_save = Tend / SAVE_COUNT - -oh_idx = sol.species_index("OH") - - -def generate_ct_saves() -> tuple: - reactor = ct.IdealGasReactor(sol) - reactor_network = ct.ReactorNet([reactor]) - - ct_time = 0.0 - ct_ts, ct_Ys, ct_rhos = [0.0], [reactor.thermo.Y], [reactor.thermo.density] - - while ct_time < Tend: - reactor_network.advance(ct_time + time_save) - ct_time += time_save - ct_ts.append(ct_time) - ct_Ys.append(reactor.thermo.Y) - ct_rhos.append(reactor.thermo.density) - - return ct_ts, ct_Ys, ct_rhos - - -ct_ts, ct_Ys, ct_rhos = generate_ct_saves() -for y in Y_VARS: - sns.lineplot( - x=ct_ts, - y=[yt[sol.species_index(y)] for yt in ct_Ys], - linestyle=":", - ax=axes[0 if y in Y_MAJORS else 1], - color="white", - alpha=0.5, - label=f"{y} (Cantera)", - ) - - -def find_induction_time(ts: list, Ys: list, rhos: list) -> float: - for t, y, rho in zip(ts, Ys, rhos): - if (y * rho / sol.molecular_weights[oh_idx]) >= 1e-6: - return t - - return None - - -skinner_induction_time = 0.052e-3 -ct_induction_time = find_induction_time(ct_ts, [y[oh_idx] for y in ct_Ys], [rho for rho in ct_rhos]) -mfc_induction_time = find_induction_time( - sorted(case.get_timestamps()), - [case.get_data()[step]["Y_OH"][0] for step in sorted(case.get_timesteps())], - [case.get_data()[step]["rho"][0] for step in sorted(case.get_timesteps())], -) - -print("Induction Times ([OH] >= 1e-6):") -print(f" + Skinner et al.: {skinner_induction_time:.3e} s") -print(f" + Cantera: {ct_induction_time:.3e} s") -print(f" + (Che)MFC: {mfc_induction_time:.3e} s") - -axes[0].add_artist( - axes[0].legend( - [ - axes[0].axvline(x=skinner_induction_time, color="r", linestyle="-"), - axes[0].axvline(x=mfc_induction_time, color="b", linestyle="-."), - axes[0].axvline(x=ct_induction_time, color="g", linestyle=":"), - ], - ["Skinner et al.", "(Che)MFC", "Cantera"], - title="Induction Times", - loc="lower right", - ) -) - -for i in range(2): - axes[i].legend(title="Species", ncol=2) - axes[i].set_ylabel("$Y_k$") - axes[i].set_xscale("log") - axes[i].set_yscale("log") - axes[i].set_xlabel("Time") - -plt.tight_layout() -plt.savefig(f"plots.png", dpi=300) -plt.close() diff --git a/examples/nD_perfect_reactor/export.py b/examples/nD_perfect_reactor/export.py deleted file mode 100644 index 1d042791a8..0000000000 --- a/examples/nD_perfect_reactor/export.py +++ /dev/null @@ -1,76 +0,0 @@ -import csv -import cantera as ct -from tqdm import tqdm - -import mfc.viz_legacy as mfc_viz -from case import dt, NS, Tend, SAVE_COUNT, sol - -case = mfc_viz.Case(".", dt) - -for name in tqdm(sol.species_names, desc="Loading Variables"): - case.load_variable(f"Y_{name}", f"prim.{5 + sol.species_index(name)}") -case.load_variable("rho", "prim.1") - -time_save = Tend / SAVE_COUNT - -oh_idx = sol.species_index("OH") - - -def generate_ct_saves() -> tuple: - reactor = ct.IdealGasReactor(sol) - reactor_network = ct.ReactorNet([reactor]) - - ct_time = 0.0 - ct_ts, ct_Ys, ct_rhos = [0.0], [reactor.thermo.Y], [reactor.thermo.density] - - while ct_time < Tend: - reactor_network.advance(ct_time + time_save) - ct_time += time_save - ct_ts.append(ct_time) - ct_Ys.append(reactor.thermo.Y) - ct_rhos.append(reactor.thermo.density) - - return ct_ts, ct_Ys, ct_rhos - - -ct_ts, ct_Ys, ct_rhos = generate_ct_saves() - -with open("mfc.csv", "w") as f: - writer = csv.writer(f) - keys = ["t"] + list(set(case.get_data()[0].keys()) - set(["x"])) - writer.writerow(keys) - for i, t_step in enumerate(sorted(case.get_timesteps())): - t = t_step * dt - row = [t] + [case.get_data()[t_step][key][0] for key in keys[1:]] - writer.writerow(row) - -with open("cantera.csv", "w") as f: - writer = csv.writer(f) - keys = ["t"] + [f"Y_{_}" for _ in list(sol.species_names)] + ["rho"] - writer.writerow(keys) - for step in range(len(ct_ts)): - row = [ct_ts[step]] + [ct_Ys[step][i] for i in range(len(sol.species_names))] + [ct_rhos[step]] - print([ct_ts[step]], row) - writer.writerow(row) - - -def find_induction_time(ts: list, Ys: list, rhos: list) -> float: - for t, y, rho in zip(ts, Ys, rhos): - if (y * rho / sol.molecular_weights[oh_idx]) >= 1e-6: - return t - - return None - - -skinner_induction_time = 0.052e-3 -ct_induction_time = find_induction_time(ct_ts, [y[oh_idx] for y in ct_Ys], [rho for rho in ct_rhos]) -mfc_induction_time = find_induction_time( - sorted(case.get_timestamps()), - [case.get_data()[step]["Y_OH"][0] for step in sorted(case.get_timesteps())], - [case.get_data()[step]["rho"][0] for step in sorted(case.get_timesteps())], -) - -print("Induction Times ([OH] >= 1e-6):") -print(f" + Skinner et al.: {skinner_induction_time} s") -print(f" + Cantera: {ct_induction_time} s") -print(f" + (Che)MFC: {mfc_induction_time} s") diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 89649cd5aa..74dfdc252b 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -12,6 +12,9 @@ for arg in "$@"; do esac done +log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." +uv pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null || pip install -q "$(pwd)/toolchain[viz]" + log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 "$(pwd)/toolchain/" diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 6cd990803c..84cf4d0a57 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -56,7 +56,7 @@ # Plot widget # --------------------------------------------------------------------------- -class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes +class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too-few-public-methods """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim / ._varname / ._step before calling .refresh().""" diff --git a/toolchain/mfc/viz_legacy.py b/toolchain/mfc/viz_legacy.py deleted file mode 100644 index 1c97862db3..0000000000 --- a/toolchain/mfc/viz_legacy.py +++ /dev/null @@ -1,150 +0,0 @@ -import os -import glob -import typing - -import pandas as pd -import seaborn as sns - - -def generate_cpg_style() -> dict: - BG_COLOR = '#1a1a1a' - TX_COLOR = '#FFFFFF' - - return { - 'axes.facecolor': '#121212', - 'axes.edgecolor': BG_COLOR, - 'axes.labelcolor': TX_COLOR, - 'text.color': TX_COLOR, - 'xtick.color': TX_COLOR, - 'ytick.color': TX_COLOR, - 'grid.color': BG_COLOR, - 'figure.facecolor': BG_COLOR, - 'figure.edgecolor': BG_COLOR, - 'savefig.facecolor': BG_COLOR, - 'savefig.edgecolor': BG_COLOR, - } - - -# pylint: disable=too-many-instance-attributes -class Case: - def __init__(self, dirpath: str, dt = None, parallel_io: bool = False): - assert not parallel_io, "Parallel I/O is not supported yet." - - self._dirpath = dirpath - self._data = {} - self._procs = set() - self._timesteps = set() - self._timestamps = set() - self._ndims = 0 - self._axes = [] - self._coords = [set(), set(), set()] - self._dt = dt - - self._minmax_time = {} - self._minmax_step = {} - - for f in glob.glob(os.path.join(self._dirpath, 'D', f'cons.1.*.*.dat')): - self._procs.add(int(f.split('.')[-3])) - step = int(f.split('.')[-2]) - self._timesteps.add(step) - self._timestamps.add(step * (self._dt or 1)) - - df_t0_p0 = self._read_csv('cons.1', 0, 0) - self._ndims = len(df_t0_p0.columns) - 1 - for dim in range(self._ndims): - self._coords[dim] = set(df_t0_p0.iloc[:, dim]) - self._axes.append(['x', 'y', 'z'][dim]) - - for t_step in self._timesteps: - df = pd.DataFrame() - for proc in self._procs: - df = pd.concat([ - df, - self._read_csv( - 'cons.1', proc, t_step, - names=self._axes, usecols=self._axes - ) - ]) - - self._data[t_step] = df - self._minmax_step[t_step] = {} - - for axis in self._axes: - self._compute_minmax(axis) - - def _read_csv(self, path: str, proc: int, t_step: int, **kwargs) -> pd.DataFrame: - return pd.read_csv( - os.path.join(self._dirpath, 'D', f'{path}.{proc:02d}.{t_step:06d}.dat'), - sep=r'\s+', header=None, **kwargs - ) - - def get_ndims(self) -> int: return self._ndims - def get_coords(self) -> set: return self._coords - def get_timesteps(self) -> set: return self._timesteps - def get_timestamps(self) -> set: return self._timestamps - def get_procs(self) -> set: return self._procs - def get_data(self) -> dict: return self._data - - def define_variable(self, name: str, func: typing.Callable): - for t_step, data in self._data.items(): - data[name] = data.apply( - lambda row: func(t_step, row[self._axes]), - axis=1 - ) - - self._compute_minmax(name) - - def load_variable(self, name: str, path: str): - for t_step in self._timesteps: - dfs = [] - for proc in self._procs: - dfs.append(self._read_csv( - path, proc, t_step, - names=[*self._axes, name] - )) - - self._data[t_step] = pd.merge(self._data[t_step], pd.concat(dfs)) - - self._compute_minmax(name) - - def _compute_minmax(self, varname: str): - lmins, lmaxs = set(), set() - for t_step in self._timesteps: - lmin, lmax = self._data[t_step][varname].min(), self._data[t_step][varname].max() - self._minmax_step[t_step][varname] = (lmin, lmax) - lmins.add(lmin); lmaxs.add(lmax) - - self._minmax_time[varname] = (min(lmins), max(lmaxs)) - - def get_minmax_time(self, varname: str) -> tuple: - return self._minmax_time[varname] - - def get_minmax_step(self, varname: str, t_step: int) -> tuple: - return self._minmax_step[t_step][varname] - - def plot_time(self, varname: str, aggregator: typing.Callable = None, **kwargs): - if aggregator is None: - aggregator = lambda x: x.mean() - - return sns.lineplot( - x=list((self._dt or 1) * t for t in self._timesteps), - y=[ - aggregator(self._data[t_step][varname]) - for t_step in self._timesteps - ], **kwargs) - - def plot_step(self, t_step: int, varname: str, axes: str = None, **kwargs): - axes = axes or self._axes - - if len(axes) == 1: - return sns.lineplot(self._data[t_step], x=axes[0], y=varname, **kwargs) - - if len(axes) == 2: - return sns.heatmap( - self._data[t_step].pivot( - index=axes[0], columns=axes[1], values=varname - ), - **kwargs - ) - - assert False, "3D plotting is not supported yet." From 9f0f3dcfce8beed8f3e0a25f0bb1bfc65aceba64 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 20:24:29 -0500 Subject: [PATCH 060/102] Add timeout to viz deps install subprocess On HPC nodes with outbound network restrictions pip/uv can hang indefinitely. timeout=300 ensures a clean TimeoutExpired error instead of a silent hang. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/viz.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 1e92a4f779..f7be2c0689 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -37,7 +37,13 @@ def _ensure_viz_deps() -> None: else: cmd = [sys.executable, "-m", "pip", "install", f"{toolchain_path}[viz]"] - result = subprocess.run(cmd, env=env, check=False) + try: + result = subprocess.run(cmd, env=env, check=False, timeout=300) + except subprocess.TimeoutExpired as exc: + raise MFCException( + "Timed out installing viz dependencies (network may be restricted). " + f"Try manually: pip install '{toolchain_path}[viz]'" + ) from exc if result.returncode != 0: raise MFCException( "Failed to install viz dependencies. " From 7db60f8eb5eb8b94358b421e38cd9131d02636bc Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 20:38:52 -0500 Subject: [PATCH 061/102] Use MFCException in tui.py; remove 2>/dev/null from lint.sh tui.py: replace ValueError with MFCException so user-facing errors go through the toolchain top-level handler for clean output. lint.sh: remove 2>/dev/null from viz extras install so uv failures are visible instead of silently falling through to import errors. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/bootstrap/lint.sh | 2 +- toolchain/mfc/viz/tui.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 74dfdc252b..d888526c63 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -13,7 +13,7 @@ for arg in "$@"; do done log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." -uv pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null || pip install -q "$(pwd)/toolchain[viz]" +uv pip install -q "$(pwd)/toolchain[viz]" || pip install -q "$(pwd)/toolchain[viz]" log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 84cf4d0a57..edfa88f6b7 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -29,6 +29,7 @@ from textual_plotext import PlotextPlot +from mfc.common import MFCException from mfc.printer import cons from . import _step_cache @@ -454,7 +455,7 @@ def run_tui( ) -> None: """Launch the Textual TUI for MFC visualization (1D/2D only).""" if ndim not in (1, 2): - raise ValueError( + raise MFCException( f"--tui only supports 1D and 2D data (got ndim={ndim}). " "Use --interactive for 3D data." ) @@ -463,7 +464,7 @@ def run_tui( first = _load(steps[0], read_func) varnames = sorted(first.variables.keys()) if not varnames: - raise ValueError("No variables found in data") + raise MFCException("No variables found in data") if init_var not in varnames: init_var = varnames[0] From 4924a135653fb08d07978e44450851e8d5594eb2 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 21:34:06 -0500 Subject: [PATCH 062/102] Address bot review: sentinels, seaborn, dep cleanup, TUI docs, guards - viz.py: add plotext and plotly to _SENTINELS so partial installs (e.g. Anaconda with dash but no plotext) trigger auto-repair instead of failing at import time in --tui / --interactive paths - viz.py: tighten 3D interactive step limit to 50 (vs 500 for batch) since interactive caches all steps simultaneously in memory - renderer.py: wrap matplotlib.use('Agg') in try/except so importing the module in a context where the backend is already set does not raise (e.g. test harness, notebook) - pyproject.toml: remove seaborn from viz extras (not used anywhere in the new viz code); remove colorlover, dash-svg, dash-bootstrap-components, kaleido, plotille from core deps (Frontier profiling leftovers that implicitly required dash, which is now optional-only) - visualization.md: fix TUI keyboard shortcut table to match actual bindings (comma/period not n/p; add c for colormap; remove v and Escape which are not bound); add 3D step-limit note Co-Authored-By: Claude Sonnet 4.6 --- docs/documentation/visualization.md | 16 +++++++++++----- toolchain/mfc/viz/renderer.py | 5 ++++- toolchain/mfc/viz/viz.py | 10 ++++++---- toolchain/pyproject.toml | 8 +------- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 93aaff0f24..fc4582829d 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -75,7 +75,12 @@ Customize the appearance of plots: ### 3D slicing For 3D simulations, `viz` extracts a 2D slice for plotting. -By default, it slices at the midplane along the z-axis: +By default, it slices at the midplane along the z-axis. + +> [!NOTE] +> To limit memory use, 3D batch rendering is capped at 500 timesteps and +> `--interactive` mode at 50. Use `--step start:end:stride` to stay within +> these limits when processing many steps. ```bash # Default z-midplane slice @@ -158,13 +163,14 @@ It supports 1D and 2D data only (use `--interactive` for 3D). | Key | Action | |-----|--------| -| `n` / `→` | Next timestep | -| `p` / `←` | Previous timestep | +| `.` / `→` | Next timestep | +| `,` / `←` | Previous timestep | | `Space` | Toggle autoplay | | `l` | Toggle logarithmic scale | | `f` | Freeze / unfreeze color range | -| `v` | Cycle to next variable | -| `q` / `Escape` | Quit | +| `↑` / `↓` | Select variable (in sidebar) | +| `c` | Cycle colormap | +| `q` | Quit | > [!NOTE] > The TUI requires the `textual` and `textual-plotext` Python packages (included in MFC's default dependencies). diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 736631d811..59891de01b 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -16,7 +16,10 @@ import imageio import matplotlib -matplotlib.use('Agg') +try: + matplotlib.use('Agg') +except Exception: # pylint: disable=broad-except + pass import matplotlib.pyplot as plt # pylint: disable=wrong-import-position from matplotlib.colors import LogNorm # pylint: disable=wrong-import-position diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index f7be2c0689..911e3154d8 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -23,7 +23,7 @@ def _ensure_viz_deps() -> None: pre-installed (e.g. via Anaconda) but lacks imageio, textual, or h5py still triggers the install. """ - _SENTINELS = ("matplotlib", "imageio", "h5py", "textual", "dash") + _SENTINELS = ("matplotlib", "imageio", "h5py", "textual", "dash", "plotext", "plotly") if all(importlib.util.find_spec(p) is not None for p in _SENTINELS): return # all present @@ -328,11 +328,13 @@ def read_step(step): test_assembled = read_step(requested_steps[0]) avail = sorted(test_assembled.variables.keys()) - # Guard against loading too many 3D timesteps (memory) - if test_assembled.ndim == 3 and len(requested_steps) > 500: + # Guard against loading too many 3D timesteps (memory). + # Interactive mode caches all steps simultaneously, so use a tighter limit. + _3d_limit = 50 if interactive else 500 + if test_assembled.ndim == 3 and len(requested_steps) > _3d_limit: raise MFCException( f"Refusing to load {len(requested_steps)} timesteps for 3D data " - "(limit is 500). Use --step with a range or stride to reduce.") + f"(limit is {_3d_limit}). Use --step with a range or stride to reduce.") # Tiled mode works for 1D and 2D. For 3D, auto-select the first variable. if tiled and not interactive and not ARG('tui'): diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 0d8f592911..7704421bfd 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -40,20 +40,14 @@ dependencies = [ # Frontier Profiling "astunparse==1.6.2", - "colorlover", "pymongo", - "tabulate", - "dash-svg", - "dash-bootstrap-components", - "kaleido", - "plotille" + "tabulate" ] [project.optional-dependencies] viz = [ # 2D/3D plotting (renderer, TUI) "matplotlib", - "seaborn", # Silo-HDF5 reader "h5py", From 33639954ed3df7da2a2fc6e9655bd5ba73a5ef93 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Thu, 26 Feb 2026 21:51:20 -0500 Subject: [PATCH 063/102] Fix bot review findings #5 and #7 - docs/visualization.md: correct TUI dep note to say textual is an optional [viz] extra auto-installed on first use, not a default dep - toolchain/mfc/args.py: use dummy_dir/ placeholder for viz positional (viz expects a directory, not a .py file) Co-Authored-By: Claude Sonnet 4.6 --- docs/documentation/visualization.md | 2 +- toolchain/mfc/args.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index fc4582829d..77d2359c5f 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -173,7 +173,7 @@ It supports 1D and 2D data only (use `--interactive` for 3D). | `q` | Quit | > [!NOTE] -> The TUI requires the `textual` and `textual-plotext` Python packages (included in MFC's default dependencies). +> The TUI requires the `textual` and `textual-plotext` Python packages, which are part of the optional `[viz]` extras and are auto-installed on the first `./mfc.sh viz --tui` run. ### Plot styling diff --git a/toolchain/mfc/args.py b/toolchain/mfc/args.py index 4601943d3f..80e6e0888c 100644 --- a/toolchain/mfc/args.py +++ b/toolchain/mfc/args.py @@ -121,7 +121,7 @@ def custom_error(message): try: # Commands with required positional input need a dummy value if name in ["run", "validate", "viz"]: - vals, _ = subparser.parse_known_args(["dummy_input.py"]) + vals, _ = subparser.parse_known_args(["dummy_dir/"]) elif name == "build": vals, _ = subparser.parse_known_args([]) else: From ac585c4ed707cceb4491a1538e4c5ced7d3c6b6c Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 00:26:38 -0500 Subject: [PATCH 064/102] Address PR review findings: thread safety, precision, error handling, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - viz.py: honour explicit --step in interactive/TUI mode so users can reduce the set for 3D cases that exceed the 50-step limit; the old unconditional override to 'all' made the error message unactionable - _step_cache.py: add threading.Lock so concurrent Dash callbacks cannot corrupt _cache_order; call read_func before eviction so a failed read does not discard a valid cache entry - reader.py: fix _dedup precision for large domains — normalize to [0,1] before rounding (12 sig digits relative to extent) instead of using absolute decimals that go negative for extent > 1e12 - interactive.py: wrap _load and variable lookup in _update callback with try/except that surfaces errors in the status bar instead of crashing - renderer.py: broaden MP4 except to Exception (catches ImportError from missing imageio-ffmpeg); change bare print() to warnings.warn(); use BaseException in frame-rendering except so KeyboardInterrupt still triggers cleanup via the finally path - silo_reader.py: handle np.bytes_ from h5py in _resolve_path - test_viz.py: fix TestValidateCmap.test_typo_suggests_correct to use assertRaises context manager - visualization.md: correct tiled-mode docs to say 1D and 2D (not 1D only) - commands.py: fix example description for ellipsis step format - lint.sh: use python3 -m pip for fallback to ensure correct interpreter Co-Authored-By: Claude Sonnet 4.6 --- docs/documentation/visualization.md | 2 +- toolchain/bootstrap/lint.sh | 2 +- toolchain/mfc/cli/commands.py | 2 +- toolchain/mfc/viz/_step_cache.py | 34 ++++++++++++++++------ toolchain/mfc/viz/interactive.py | 11 +++++++- toolchain/mfc/viz/reader.py | 44 ++++++++++++++++++----------- toolchain/mfc/viz/renderer.py | 7 +++-- toolchain/mfc/viz/silo_reader.py | 2 +- toolchain/mfc/viz/test_viz.py | 7 ++--- toolchain/mfc/viz/viz.py | 7 +++-- 10 files changed, 79 insertions(+), 39 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 77d2359c5f..6a77f47eff 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -124,7 +124,7 @@ For 1D cases, omitting `--var` (or passing `--var all`) renders all variables in ``` Each variable gets its own subplot with automatic LaTeX-style axis labels. -Tiled mode is only available for 1D data. +Tiled mode is available for 1D and 2D data. For 3D data, omitting `--var` auto-selects the first variable. ### Interactive mode diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index d888526c63..267e2759e1 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -13,7 +13,7 @@ for arg in "$@"; do done log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." -uv pip install -q "$(pwd)/toolchain[viz]" || pip install -q "$(pwd)/toolchain[viz]" +uv pip install -q "$(pwd)/toolchain[viz]" || python3 -m pip install -q "$(pwd)/toolchain[viz]" log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index bc57c5f783..c59929ffd9 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -1049,7 +1049,7 @@ Example("./mfc.sh viz case_dir/ --list-vars --step 0", "List available variables"), Example("./mfc.sh viz case_dir/ --list-steps", "List available timesteps"), Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video from range"), - Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Tile all vars at specific steps"), + Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Render steps 0–1000 (stride inferred from ellipsis)"), Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z", "3D slice at z midplane"), Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D)"), ], diff --git a/toolchain/mfc/viz/_step_cache.py b/toolchain/mfc/viz/_step_cache.py index 279b7966f3..228c94930d 100644 --- a/toolchain/mfc/viz/_step_cache.py +++ b/toolchain/mfc/viz/_step_cache.py @@ -4,29 +4,47 @@ entry when the cap is reached. The module-level state is intentional: both the TUI and the interactive server are single-instance; a per-session cache avoids redundant disk reads while bounding peak memory usage. + +Thread safety: Dash runs Flask with threading enabled. All mutations are +protected by _lock so concurrent callbacks from multiple browser tabs cannot +corrupt _cache_order or exceed CACHE_MAX. """ +import threading from typing import Callable CACHE_MAX: int = 50 _cache: dict = {} _cache_order: list = [] +_lock = threading.Lock() def load(step: int, read_func: Callable) -> object: - """Return cached data for *step*, calling *read_func* on a miss.""" - if step not in _cache: + """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] = read_func(step) + _cache[step] = data _cache_order.append(step) - return _cache[step] + return data def seed(step: int, data: object) -> None: """Clear the cache and pre-populate it with already-loaded data.""" - _cache.clear() - _cache_order.clear() - _cache[step] = data - _cache_order.append(step) + with _lock: + _cache.clear() + _cache_order.clear() + _cache[step] = data + _cache_order.append(step) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 100eb5e916..8e269d4204 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -494,7 +494,16 @@ def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many- cmap, log_chk, vmin_in, vmax_in): selected_var = var_sel or varname - ad = _load(step, read_func) + try: + ad = _load(step, read_func) + except Exception as exc: # pylint: disable=broad-except + return no_update, [html.Span(f' Error loading step {step}: {exc}', + style={'color': _RED})] + if selected_var not in ad.variables: + avail = ', '.join(sorted(ad.variables)) + return no_update, [html.Span( + f' Variable {selected_var!r} not in step {step} ' + f'(available: {avail})', style={'color': _RED})] raw = ad.variables[selected_var] log = bool(log_chk and 'log' in log_chk) cmap = cmap or 'viridis' diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index c5ac185048..186333821c 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -316,30 +316,38 @@ def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-stateme proc_centers.append((rank, pd, x_cc, y_cc, z_cc)) # Build unique sorted global coordinate arrays (handles ghost overlap). - # Use scale-aware rounding: 12 significant digits relative to the domain - # extent, so precision is preserved for both micro-scale and large domains. - # np.round supports negative decimals (rounds to tens, hundreds, etc.), - # which is correct for large-extent domains (e.g. extent > 1e12). + # Normalize each axis by its extent before rounding so that precision is + # always 12 significant digits *relative to the domain size*. This is + # correct for both micro-scale domains (extent ~ 1e-10) and large-scale + # domains (extent > 1e12) where the old formula (decimals = -log10(extent) + # + 12) could go negative, causing np.round to round to the nearest 10 or + # 100 and incorrectly merging distinct cell centers. def _dedup(arr): extent = arr.max() - arr.min() if extent > 0: - decimals = int(np.ceil(-np.log10(extent))) + 12 - else: - decimals = 12 - return np.unique(np.round(arr, decimals)), decimals + 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 _norm_round(arr, origin, extent): + """Round *arr* with the same relative tolerance used by _dedup.""" + if extent > 0: + return origin + np.round((arr - origin) / extent, 12) * extent + return arr all_x = np.concatenate([xc for _, _, xc, _, _ in proc_centers]) - global_x, xdec = _dedup(all_x) + global_x, x_orig, x_ext = _dedup(all_x) if ndim >= 2: all_y = np.concatenate([yc for _, _, _, yc, _ in proc_centers]) - global_y, ydec = _dedup(all_y) + global_y, y_orig, y_ext = _dedup(all_y) else: - global_y, ydec = np.array([0.0]), 12 + global_y, y_orig, y_ext = np.array([0.0]), 0.0, 0.0 if ndim >= 3: all_z = np.concatenate([zc for _, _, _, _, zc in proc_centers]) - global_z, zdec = _dedup(all_z) + global_z, z_orig, z_ext = _dedup(all_z) else: - global_z, zdec = np.array([0.0]), 12 + global_z, z_orig, z_ext = np.array([0.0]), 0.0, 0.0 varnames = sorted({vn for _, pd in proc_data for vn in pd.variables}) nx, ny, nz = len(global_x), len(global_y), len(global_z) @@ -353,11 +361,13 @@ def _dedup(arr): else: global_vars[vn] = np.zeros(nx) - # Place each processor's data using per-cell coordinate lookup + # Place each processor's data using per-cell coordinate lookup. + # Apply the same normalized rounding used by _dedup so that lookup + # coordinates match the global grid entries exactly. for _rank, pd, x_cc, y_cc, z_cc in proc_centers: - xi = np.clip(np.searchsorted(global_x, np.round(x_cc, xdec)), 0, nx - 1) - yi = np.clip(np.searchsorted(global_y, np.round(y_cc, ydec)), 0, ny - 1) if ndim >= 2 else np.array([0]) - zi = np.clip(np.searchsorted(global_z, np.round(z_cc, zdec)), 0, nz - 1) if ndim >= 3 else np.array([0]) + xi = np.clip(np.searchsorted(global_x, _norm_round(x_cc, x_orig, x_ext)), 0, nx - 1) + yi = np.clip(np.searchsorted(global_y, _norm_round(y_cc, y_orig, y_ext)), 0, ny - 1) if ndim >= 2 else np.array([0]) + zi = np.clip(np.searchsorted(global_z, _norm_round(z_cc, z_orig, z_ext)), 0, nz - 1) if ndim >= 3 else np.array([0]) for vn, data in pd.variables.items(): if vn not in global_vars: diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 59891de01b..4632563181 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -444,7 +444,7 @@ def _cleanup(): f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " "Expected 1, 2, or 3." ) - except Exception: + except BaseException: _cleanup() raise @@ -499,8 +499,9 @@ def _uniform_frame(arr): imageio.imread(os.path.join(viz_dir, fname)) )) success = True - except (OSError, ValueError, RuntimeError) as exc: - print(f"imageio MP4 write failed: {exc}") + except Exception as exc: # pylint: disable=broad-except + import warnings # pylint: disable=import-outside-toplevel + warnings.warn(f"imageio MP4 write failed: {exc}", stacklevel=2) finally: _cleanup() return success diff --git a/toolchain/mfc/viz/silo_reader.py b/toolchain/mfc/viz/silo_reader.py index 37571465d6..58e4c06b7b 100644 --- a/toolchain/mfc/viz/silo_reader.py +++ b/toolchain/mfc/viz/silo_reader.py @@ -26,7 +26,7 @@ def _resolve_path(h5file, path_bytes): """Resolve a silo internal path (e.g. b'/.silo/#000003') and return its data as a numpy array.""" - path = path_bytes.decode() if isinstance(path_bytes, bytes) else str(path_bytes) + path = path_bytes.decode() if isinstance(path_bytes, (bytes, np.bytes_)) else str(path_bytes) return np.array(h5file[path]) diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index a882731893..7cb3815dd1 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -475,12 +475,11 @@ def test_unknown_cmap_raises(self): self._validate('notacolormap_xyz_1234') def test_typo_suggests_correct(self): - """Typo in colormap name suggests the correct spelling.""" + """Typo in colormap name raises MFCException suggesting the correct spelling.""" from mfc.common import MFCException - try: + with self.assertRaises(MFCException) as ctx: self._validate('virids') # typo of viridis - except MFCException as exc: - self.assertIn('viridis', str(exc)) + self.assertIn('viridis', str(ctx.exception)) # --------------------------------------------------------------------------- diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 911e3154d8..40e36c5197 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -275,13 +275,16 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc cons.print(f" {vn:<20s} min={data.min():.6g} max={data.max():.6g}") return - # For rendering, --step is required; --var is optional for 1D (shows all) + # For rendering, --step is required; --var is optional for 1D/2D (shows all in tiled layout) varname = ARG('var') step_arg = ARG('step') tiled = varname is None or varname == 'all' if ARG('interactive') or ARG('tui'): - step_arg = 'all' # always load all steps in interactive/TUI mode + # Load all steps by default; honour an explicit --step so users can + # reduce the set for large 3D cases before hitting the step limit. + if step_arg == 'last': + step_arg = 'all' steps = discover_timesteps(case_dir, fmt) if not steps: From c4ca836486f68a1aae7f30859c067e8f4c3213bf Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 08:37:15 -0500 Subject: [PATCH 065/102] fix --- toolchain/mfc/viz/test_viz.py | 137 ++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 7cb3815dd1..678a372d50 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -658,5 +658,142 @@ def test_very_large_extent_dedup_negative_decimals(self): ) +# --------------------------------------------------------------------------- +# Tests: render_2d_tiled +# --------------------------------------------------------------------------- + +class TestRender2DTiled(unittest.TestCase): + """Smoke test: render_2d_tiled produces a valid PNG from 2D fixture data.""" + + def test_render_2d_tiled_png(self): + """Tiled render of all 2D variables produces a non-empty PNG.""" + from .reader import assemble + from .renderer import render_2d_tiled + data = assemble(FIX_2D_BIN, 0, 'binary') + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_2d_tiled(data, 0, out) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + +# --------------------------------------------------------------------------- +# Tests: render_3d_slice non-default axes and selectors +# --------------------------------------------------------------------------- + +class TestRender3DSliceAxes(unittest.TestCase): + """Test render_3d_slice with non-default slice axes and selectors.""" + + def setUp(self): + from .reader import assemble + self._data = assemble(FIX_3D_BIN, 0, 'binary') + + def _render(self, **kwargs): + from .renderer import render_3d_slice + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + out = f.name + try: + render_3d_slice(self._data, 'pres', 0, out, **kwargs) + self.assertTrue(os.path.isfile(out)) + self.assertGreater(os.path.getsize(out), 0) + finally: + os.unlink(out) + + def test_x_axis_slice(self): + """X-axis midplane slice produces a non-empty PNG.""" + self._render(slice_axis='x') + + def test_y_axis_slice(self): + """Y-axis midplane slice produces a non-empty PNG.""" + self._render(slice_axis='y') + + def test_slice_by_index(self): + """slice_index=0 selects first plane along default z axis.""" + self._render(slice_index=0) + + def test_slice_by_value(self): + """slice_value selects the plane nearest the given coordinate.""" + z_mid = float(self._data.z_cc[len(self._data.z_cc) // 2]) + self._render(slice_value=z_mid) + + +# --------------------------------------------------------------------------- +# Tests: render_mp4 +# --------------------------------------------------------------------------- + +class TestRenderMp4(unittest.TestCase): + """Smoke test: render_mp4 exercises frame rendering and returns a bool.""" + + def _make_read_func(self, case_dir, fmt): + from .reader import assemble + def _read(step): + return assemble(case_dir, step, fmt) + return _read + + def test_mp4_1d_returns_bool(self): + """render_mp4 with 1D data returns True or False without raising.""" + from .reader import discover_timesteps + from .renderer import render_mp4 + steps = discover_timesteps(FIX_1D_BIN, 'binary')[:2] + read_func = self._make_read_func(FIX_1D_BIN, 'binary') + with tempfile.TemporaryDirectory() as tmpdir: + out = os.path.join(tmpdir, 'test.mp4') + result = render_mp4('pres', steps, out, fps=2, read_func=read_func) + self.assertIsInstance(result, bool) + + def test_mp4_tiled_1d_returns_bool(self): + """render_mp4 with tiled=True returns True or False without raising.""" + from .reader import discover_timesteps + from .renderer import render_mp4 + steps = discover_timesteps(FIX_1D_BIN, 'binary')[:2] + read_func = self._make_read_func(FIX_1D_BIN, 'binary') + with tempfile.TemporaryDirectory() as tmpdir: + out = os.path.join(tmpdir, 'test_tiled.mp4') + result = render_mp4('pres', steps, out, fps=2, + read_func=read_func, tiled=True) + self.assertIsInstance(result, bool) + + def test_mp4_no_read_func_raises(self): + """render_mp4 with read_func=None raises ValueError.""" + from .renderer import render_mp4 + with self.assertRaises(ValueError): + render_mp4('pres', [0], '/tmp/unused.mp4', read_func=None) + + def test_mp4_empty_steps_raises(self): + """render_mp4 with empty steps raises ValueError.""" + from .renderer import render_mp4 + with self.assertRaises(ValueError): + render_mp4('pres', [], '/tmp/unused.mp4', + read_func=lambda s: None) + + +# --------------------------------------------------------------------------- +# Tests: silo assemble_silo var_filter +# --------------------------------------------------------------------------- + +class TestAssembleSiloVarFilter(unittest.TestCase): + """Test assemble_silo with var= filter to cover the silo var_filter path.""" + + def test_1d_var_filter_includes_only_requested(self): + """Silo 1D: var='pres' loads pres and excludes vel1.""" + from .silo_reader import assemble_silo + data = assemble_silo(FIX_1D_SILO, 0, var='pres') + self.assertIn('pres', data.variables) + self.assertNotIn('vel1', data.variables) + + def test_2d_var_filter_includes_only_requested(self): + """Silo 2D: var='pres' loads pres and excludes other variables.""" + from .silo_reader import assemble_silo + filtered = assemble_silo(FIX_2D_SILO, 0, var='pres') + all_data = assemble_silo(FIX_2D_SILO, 0) + self.assertIn('pres', filtered.variables) + other_vars = [v for v in all_data.variables if v != 'pres'] + if other_vars: + self.assertNotIn(other_vars[0], filtered.variables) + + if __name__ == "__main__": unittest.main() From 49299046b51bd0f78671b5b41621890f4d59a7a8 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 11:57:18 -0500 Subject: [PATCH 066/102] Improve viz CLI help text: modes, tiled layout, quick-start, arg descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Description box now lists all 4 output modes (PNG/MP4/interactive/TUI), explains tiled all-var layout, and includes a 3-step quick-start workflow - Examples reordered to start with discovery (--list-steps, --list-vars) then progress to rendering, video, 3D, interactive, and TUI - Key Options grouped into sections: Discovery, Variable/step, Output modes, Appearance, 3D options — replaces flat unlabeled list - --var, --step, --output, --interactive, --tui, --list-vars, --list-steps, --mp4, --log-scale arg descriptions expanded with defaults and cross-refs Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/cli/commands.py | 105 +++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index c59929ffd9..2a41e45aa3 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -864,7 +864,23 @@ VIZ_COMMAND = Command( name="viz", help="Visualize post-processed MFC output.", - description="Render 2D colormaps, 3D slices, 1D line plots, and MP4 videos from MFC post-processed output (binary or Silo-HDF5).", + description=( + "Render post-processed MFC output as PNG images or MP4 video, or explore " + "interactively. Supports 1D line plots, 2D colormaps, 3D midplane slices, " + "and tiled all-variable views. PNG files are saved to case_dir/viz/ by default.\n\n" + "Output modes:\n" + " (default) Save PNG image(s) to case_dir/viz/\n" + " --mp4 Encode frames into an MP4 video\n" + " --interactive Launch a Dash web UI in your browser\n" + " --tui Launch a terminal UI (works over SSH, no browser needed)\n\n" + "Variable selection:\n" + " --var NAME Plot a single variable\n" + " (omit --var) 1D/2D: tiled layout of all variables; 3D: first variable\n\n" + "Quick-start workflow:\n" + " 1. ./mfc.sh viz case_dir/ --list-steps\n" + " 2. ./mfc.sh viz case_dir/ --list-vars --step 0\n" + " 3. ./mfc.sh viz case_dir/ --var pres --step 1000" + ), positionals=[ Positional( name="input", @@ -875,14 +891,26 @@ arguments=[ Argument( name="var", - help="Variable name to visualize (e.g. pres, rho). Omit or pass 'all' for tiled 1D plots.", + help=( + "Variable to visualize (e.g. pres, rho, vel1, schlieren). " + "Omit (or pass 'all') for a tiled layout of all variables " + "(1D and 2D data) or the first variable (3D data). " + "Use --list-vars to see available names." + ), type=str, default=None, metavar="VAR", ), Argument( name="step", - help="Timestep(s): int, start:end:stride, 0,100,200, 0,100,...,1000, 'last', or 'all' (default: last).", + help=( + "Timestep(s) to render. Formats: a single integer (e.g. 1000), " + "a range start:end:stride (e.g. 0:5000:500), " + "a comma list (e.g. 0,100,200), " + "an ellipsis list (e.g. 0,100,...,1000), " + "'last' (default — renders the final step only), or 'all'. " + "Use --list-steps to see available timesteps." + ), type=str, default='last', metavar="STEP", @@ -890,7 +918,7 @@ Argument( name="format", short="f", - help="Output format: binary or silo (auto-detected if omitted).", + help="Input data format: binary or silo (auto-detected from directory structure if omitted).", type=str, default=None, choices=["binary", "silo"], @@ -899,7 +927,7 @@ Argument( name="output", short="o", - help="Output directory for rendered images/videos.", + help="Directory for saved PNG images or MP4 video (default: case_dir/viz/).", type=str, default=None, metavar="DIR", @@ -985,7 +1013,7 @@ ), Argument( name="mp4", - help="Generate an MP4 video instead of individual images.", + help="Encode all rendered frames into an MP4 video (requires --step with multiple timesteps).", action=ArgAction.STORE_TRUE, default=False, ), @@ -998,21 +1026,21 @@ ), Argument( name="list-vars", - help="List available variable names and exit.", + help="Print the variable names available at the given timestep and exit.", action=ArgAction.STORE_TRUE, default=False, dest="list_vars", ), Argument( name="list-steps", - help="List available timesteps and exit.", + help="Print all available timesteps and exit.", action=ArgAction.STORE_TRUE, default=False, dest="list_steps", ), Argument( name="log-scale", - help="Use logarithmic color scale.", + help="Use a logarithmic color/y scale (skips non-positive values).", action=ArgAction.STORE_TRUE, default=False, dest="log_scale", @@ -1020,7 +1048,11 @@ Argument( name="interactive", short="i", - help="Launch an interactive Dash web UI instead of saving PNG/MP4.", + help=( + "Launch an interactive Dash web UI in your browser. " + "Loads all timesteps (or the set given by --step) and lets you " + "scrub through them and switch variables live." + ), action=ArgAction.STORE_TRUE, default=False, ), @@ -1033,36 +1065,53 @@ ), Argument( name="host", - help="Host address for the interactive web server (default: 127.0.0.1).", + help="Host/bind address for the interactive web server (default: 127.0.0.1).", default="127.0.0.1", metavar="HOST", ), Argument( name="tui", - help="Launch an interactive terminal UI (1D/2D only). Works over SSH with no browser.", + help=( + "Launch an interactive terminal UI (1D/2D only). " + "Works over SSH with no browser required. " + "Use arrow keys to step through timesteps." + ), action=ArgAction.STORE_TRUE, default=False, ), ], examples=[ - Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Plot pressure at step 1000"), - Example("./mfc.sh viz case_dir/ --list-vars --step 0", "List available variables"), - Example("./mfc.sh viz case_dir/ --list-steps", "List available timesteps"), - Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Generate video from range"), - Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Render steps 0–1000 (stride inferred from ellipsis)"), - Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis z", "3D slice at z midplane"), - Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D)"), + Example("./mfc.sh viz case_dir/ --list-steps", "Discover available timesteps"), + Example("./mfc.sh viz case_dir/ --list-vars --step 0", "Discover available variables at step 0"), + Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Save pressure PNG at step 1000 → case_dir/viz/"), + Example("./mfc.sh viz case_dir/ --step 1000", "Save tiled PNG of all variables (1D/2D) at step 1000"), + Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Encode schlieren MP4 from range"), + Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Render all steps 0–1000 (stride inferred)"), + Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x", "3D: x-plane slice of pressure"), + Example("./mfc.sh viz case_dir/ --var pres --interactive", "Browser UI — scrub timesteps and switch vars"), + Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D, no browser)"), ], key_options=[ - ("--var NAME", "Variable to visualize"), - ("--step STEP", "Timestep(s): int, start:end:stride, 0,100,...,1000, or 'all'"), - ("--list-vars", "List available variables"), - ("--list-steps", "List available timesteps"), - ("--mp4", "Generate MP4 video"), - ("--interactive / -i", "Launch interactive Dash web UI"), - ("--tui", "Launch terminal UI (1D/2D, works over SSH)"), - ("--cmap NAME", "Matplotlib colormap"), - ("--slice-axis x|y|z", "Axis for 3D slice"), + ("-- Discovery --", ""), + ("--list-steps", "Print available timesteps and exit"), + ("--list-vars", "Print available variable names and exit"), + ("-- Variable / step selection --", ""), + ("--var NAME", "Variable to plot (omit for tiled all-vars layout)"), + ("--step STEP", "last (default), int, start:stop:stride, list, or 'all'"), + ("-- Output modes --", ""), + ("(default)", "Save PNG to case_dir/viz/; use -o DIR to change"), + ("--mp4", "Encode frames into an MP4 video"), + ("--interactive / -i", "Dash web UI in browser (supports 1D/2D/3D)"), + ("--tui", "Terminal UI over SSH — no browser needed (1D/2D)"), + ("-- Appearance --", ""), + ("--cmap NAME", "Matplotlib colormap (default: viridis)"), + ("--vmin / --vmax", "Fix color-scale limits"), + ("--log-scale", "Logarithmic color/y axis"), + ("--dpi N", "Image resolution (default: 150)"), + ("-- 3D options --", ""), + ("--slice-axis x|y|z", "Plane to slice (default: z midplane)"), + ("--slice-value VAL", "Slice at coordinate value"), + ("--slice-index IDX", "Slice at array index"), ], ) From 076fb5568f4c14f55c1a1de5170d050e34e7ba42 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 12:34:53 -0500 Subject: [PATCH 067/102] fix bot reviews --- toolchain/mfc/viz/reader.py | 5 +++-- toolchain/mfc/viz/tui.py | 2 +- toolchain/mfc/viz/viz.py | 18 ++++++++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 186333821c..91432eb236 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -274,8 +274,9 @@ def _discover_processors(case_dir: str, fmt: str) -> List[int]: def _is_1d(case_dir: str) -> bool: - """Check if the output is 1D (has binary/root/ directory).""" - return os.path.isdir(os.path.join(case_dir, 'binary', 'root')) + """Check if the output is 1D (binary/root/ directory exists and contains .dat files).""" + root = os.path.join(case_dir, 'binary', 'root') + return os.path.isdir(root) and any(f.endswith('.dat') for f in os.listdir(root)) def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-statements diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index edfa88f6b7..c108702197 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -199,7 +199,7 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- elif row == h_plot - 1: lbl = f" {vmin:.3g}" elif row == h_plot // 2: - mid = np.sqrt(vmin * vmax) if (self._log_scale and vmin > 0) else (vmin + vmax) / 2 + mid = np.sqrt(vmin * vmax) if (log_active and vmin > 0) else (vmin + vmax) / 2 lbl = f" {mid:.3g}" else: lbl = "" diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 40e36c5197..2e456ed8cc 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -350,8 +350,22 @@ def read_step(step): " (use --var to specify)[/dim]") if not tiled and not interactive and not ARG('tui') and varname not in test_assembled.variables: - raise MFCException(f"Variable '{varname}' not found. " - f"Available variables: {', '.join(avail)}") + # test_assembled was loaded with var_filter=varname so its variables dict + # may be empty. Re-read without filter (errors only, so extra I/O is fine) + # to build a useful "available variables" list for the error message. + if not avail: + if fmt == 'silo': + from .silo_reader import assemble_silo # pylint: disable=import-outside-toplevel + _full = assemble_silo(case_dir, requested_steps[0]) + else: + _full = assemble(case_dir, requested_steps[0], fmt) + avail = sorted(_full.variables.keys()) + avail_str = ', '.join(avail) if avail else '(none — check post_process output)' + raise MFCException( + f"Variable '{varname}' not found. " + f"Available: {avail_str}. " + f"Use --list-vars to see variables at a given step." + ) # TUI mode — launch Textual terminal UI (1D/2D only) if ARG('tui'): From ec1a8222fb2f61d0ef7c57f236cde194caeb5a7a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 13:22:34 -0500 Subject: [PATCH 068/102] fixes --- toolchain/bootstrap/lint.sh | 25 +++++++++++++++++++++---- toolchain/pyproject.toml | 1 - 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 267e2759e1..1fb641d1c9 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -12,12 +12,27 @@ for arg in "$@"; do esac done -log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." -uv pip install -q "$(pwd)/toolchain[viz]" || python3 -m pip install -q "$(pwd)/toolchain[viz]" +# Install viz optional deps only if not already present. On air-gapped systems or +# networks where PyPI is unreachable the install may fail; in that case we skip the +# viz-specific lint and tests rather than aborting the entire precheck. +VIZ_LINT=true +if ! python3 -c "import matplotlib, dash, textual, imageio, h5py" 2>/dev/null; then + log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." + if ! { uv pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null \ + || python3 -m pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null; }; then + log "${YELLOW}Warning:${COLOR_RESET} viz optional dependencies could not be installed (no network?). Skipping viz lint/tests." + VIZ_LINT=false + fi +fi log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." -pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 "$(pwd)/toolchain/" +# Exclude the viz subpackage from pylint when its optional deps are unavailable, +# since pylint needs to import matplotlib/dash/etc. to analyse those modules. +PYLINT_VIZ_OPT="" +[ "$VIZ_LINT" = false ] && PYLINT_VIZ_OPT="--ignore-paths=.*/mfc/viz/.*" +# shellcheck disable=SC2086 +pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 $PYLINT_VIZ_OPT "$(pwd)/toolchain/" log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""examples$COLOR_RESET." @@ -35,7 +50,9 @@ if [ "$RUN_TESTS" = true ]; then cd "$(pwd)/toolchain" python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v - python3 -m unittest mfc.viz.test_viz -v + if [ "$VIZ_LINT" = true ]; then + python3 -m unittest mfc.viz.test_viz -v + fi cd - > /dev/null fi diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 7704421bfd..4788c1d56e 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -5,7 +5,6 @@ build-backend = "hatchling.build" [project] name = "mfc" dynamic = ["version"] -requires-python = ">=3.10" dependencies = [ # General "rich", From a51ed6d4f2bf2048ff4533e764bba6596fa6c584 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 14:09:11 -0500 Subject: [PATCH 069/102] Address Claude review: cache clear() API and MP4 error handling - Add public clear() to _step_cache to avoid tests accessing private internals (_cache, _cache_order); update TestTuiCache setUp/tearDown - Remove redundant warnings.warn from render_mp4 except block; viz.py already raises MFCException on failure, so the warning was noise Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/_step_cache.py | 7 +++++++ toolchain/mfc/viz/renderer.py | 5 ++--- toolchain/mfc/viz/test_viz.py | 6 ++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/toolchain/mfc/viz/_step_cache.py b/toolchain/mfc/viz/_step_cache.py index 228c94930d..ec4780565a 100644 --- a/toolchain/mfc/viz/_step_cache.py +++ b/toolchain/mfc/viz/_step_cache.py @@ -48,3 +48,10 @@ def seed(step: int, data: object) -> None: _cache_order.clear() _cache[step] = data _cache_order.append(step) + + +def clear() -> None: + """Reset the cache to empty (useful for test teardown).""" + with _lock: + _cache.clear() + _cache_order.clear() diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 4632563181..f8ad964d9b 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -499,9 +499,8 @@ def _uniform_frame(arr): imageio.imread(os.path.join(viz_dir, fname)) )) success = True - except Exception as exc: # pylint: disable=broad-except - import warnings # pylint: disable=import-outside-toplevel - warnings.warn(f"imageio MP4 write failed: {exc}", stacklevel=2) + except Exception: # pylint: disable=broad-except + pass finally: _cleanup() return success diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 678a372d50..949d9e1c5f 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -492,12 +492,10 @@ class TestTuiCache(unittest.TestCase): def setUp(self): import mfc.viz._step_cache as cache_mod self._mod = cache_mod - cache_mod._cache.clear() - cache_mod._cache_order.clear() + cache_mod.clear() def tearDown(self): - self._mod._cache.clear() - self._mod._cache_order.clear() + self._mod.clear() def _read(self, step): return f"data_{step}" From d8b0a419f4066c5a8ec7f301813dcdcf9c72f2fd Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 16:35:49 -0500 Subject: [PATCH 070/102] viz: overlay Lagrange bubble positions on 2D/3D plots Reads D/lag_bubble_evol_.dat (written when lag_params%write_bubbles=T) and overlays bubble positions as white circle patches on pressure colormaps. - reader.py: add read_lag_bubbles_at_step() + has_lag_bubble_evol() Uses _nBubs_per_step() (lru_cache'd) to seek efficiently to the right block in each rank file without loading the full ~800MB file. - renderer.py: add _overlay_bubbles() helper; wire into render_2d, render_2d_tiled, render_3d_slice (filters to near-slice bubbles), and render_mp4 via new bubble_func parameter. - viz.py: auto-detect bubble evol files and pass bubble_func through to all render calls (PNG and MP4). Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/reader.py | 83 +++++++++++++++++++++++++++++++++++ toolchain/mfc/viz/renderer.py | 54 ++++++++++++++++++++--- toolchain/mfc/viz/viz.py | 28 +++++++++--- 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 91432eb236..8f60d57d00 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -11,10 +11,13 @@ Records 3..N (vars): varname(50-char) + data((m+1)*(n+1)*(p+1) floats) """ +import glob +import itertools import os import struct import warnings from dataclasses import dataclass, field +from functools import lru_cache from typing import Dict, List, Optional, Tuple import numpy as np @@ -433,3 +436,83 @@ def assemble(case_dir: str, step: int, fmt: str = 'binary', # pylint: disable=t raise FileNotFoundError(f"No valid processor data found for step {step}") return assemble_from_proc_data(proc_data) + + +# --------------------------------------------------------------------------- +# Lagrange bubble position reader +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=32) +def _nBubs_per_step(path: str) -> int: + """Count how many bubble rows share the first time value in *path*. + + The result is cached so repeated calls for the same file (across + different steps in an MP4 render) only scan the file once. + """ + with open(path) as f: + f.readline() # skip header + first = f.readline() + if not first.strip(): + return 0 + t0 = first.split()[0] + n = 1 + for line in f: + parts = line.split() + if parts and parts[0] == t0: + n += 1 + else: + break + return n + + +def read_lag_bubbles_at_step(case_dir: str, step: int) -> Optional[np.ndarray]: + """Read Lagrange bubble positions at a given save-step index. + + Reads ``D/lag_bubble_evol_.dat`` files written by MFC when + ``lag_params%write_bubbles = T``. Each file contains one row per + bubble per simulation time-step (appended after every completed RK + stage). This function seeks efficiently to the block for *step* by + counting the fixed number of bubbles per rank. + + Returns an ``(N, 4)`` float64 array of ``(x, y, z, r)`` in + simulation-normalized units across all MPI ranks, or ``None`` when + no bubble data is found. + """ + d_dir = os.path.join(case_dir, 'D') + if not os.path.isdir(d_dir): + return None + + files = sorted(glob.glob(os.path.join(d_dir, 'lag_bubble_evol_*.dat'))) + if not files: + return None + + chunks: List[np.ndarray] = [] + for fpath in files: + try: + nBubs = _nBubs_per_step(fpath) + if nBubs == 0: + continue + # Line layout: 1 header + step * nBubs prior data rows + skip = 1 + step * nBubs + rows = [] + with open(fpath) as f: + for _ in itertools.islice(f, skip): + pass + for line in itertools.islice(f, nBubs): + parts = line.split() + if len(parts) >= 8: + # cols: time id x y z mv conc r [rdot p] + rows.append((float(parts[2]), float(parts[3]), + float(parts[4]), float(parts[7]))) + if rows: + chunks.append(np.array(rows, dtype=np.float64)) + except (OSError, ValueError): + continue + + return np.concatenate(chunks, axis=0) if chunks else None + + +def has_lag_bubble_evol(case_dir: str) -> bool: + """Return True if ``D/lag_bubble_evol_*.dat`` files exist in *case_dir*.""" + d_dir = os.path.join(case_dir, 'D') + return bool(glob.glob(os.path.join(d_dir, 'lag_bubble_evol_*.dat'))) diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index f8ad964d9b..ac69f960ed 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -73,6 +73,24 @@ ] +def _overlay_bubbles(ax, bubbles, scale: float = 1.0) -> None: + """Overlay Lagrange bubble positions as circles on *ax*. + + Args: + ax: matplotlib Axes to draw on. + bubbles: (N, 4) array of (x, y, z, r) in simulation-normalized units. + scale: Multiply rendered radius by this factor for visibility. + """ + if bubbles is None or len(bubbles) == 0: + return + from matplotlib.patches import Circle # pylint: disable=import-outside-toplevel + from matplotlib.collections import PatchCollection # pylint: disable=import-outside-toplevel + circles = [Circle((b[0], b[1]), b[3] * scale) for b in bubbles] + pc = PatchCollection(circles, facecolors='none', edgecolors='white', + linewidths=0.5, alpha=0.8) + ax.add_collection(pc) + + def pretty_label(varname): """Map an MFC variable name to a LaTeX-style label for plots.""" if varname in _LABEL_MAP: @@ -198,6 +216,8 @@ def render_2d(x_cc, y_cc, data, varname, step, output, **opts): # pylint: disab ax.set_title(f'{label} (step {step})') ax.set_aspect('equal', adjustable='box') + _overlay_bubbles(ax, opts.get('bubbles'), scale=opts.get('bubble_scale', 1.0)) + fig.tight_layout() fig.savefig(output, dpi=opts.get('dpi', 150)) plt.close(fig) @@ -245,6 +265,7 @@ def render_2d_tiled(assembled, step, output, **opts): # pylint: disable=too-man ax.set_title(label, fontsize=9) ax.set_aspect('equal', adjustable='box') ax.tick_params(labelsize=7) + _overlay_bubbles(ax, opts.get('bubbles'), scale=opts.get('bubble_scale', 1.0)) for idx in range(n, nrows * ncols): row, col = divmod(idx, ncols) @@ -325,13 +346,24 @@ def render_3d_slice(assembled, varname, step, output, slice_axis='z', # pylint: ax.set_title(f'{label} (step {step}, {slice_axis}={slice_coord:.4g})') ax.set_aspect('equal', adjustable='box') + # Overlay bubbles that lie within one radius of the slice plane + bubbles = opts.get('bubbles') + if bubbles is not None and len(bubbles) > 0: + slice_col = {'x': 0, 'y': 1, 'z': 2}[slice_axis] + plot_cols = [c for c in (0, 1, 2) if c != slice_col] + near = np.abs(bubbles[:, slice_col] - slice_coord) <= bubbles[:, 3] + _overlay_bubbles(ax, + bubbles[near][:, [plot_cols[0], plot_cols[1], slice_col, 3]] + if near.any() else None, + scale=opts.get('bubble_scale', 1.0)) + fig.tight_layout() fig.savefig(output, dpi=opts.get('dpi', 150)) plt.close(fig) def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-statements,too-many-branches - read_func=None, tiled=False, **opts): + read_func=None, tiled=False, bubble_func=None, **opts): """ Generate an MP4 video by iterating over timesteps. @@ -342,6 +374,8 @@ def render_mp4(varname, steps, output, fps=10, # pylint: disable=too-many-argum fps: Frames per second. read_func: Callable(step) -> AssembledData for loading each frame. tiled: If True, render all 1D variables in a tiled layout per frame. + bubble_func: Optional callable ``(step: int) -> ndarray`` returning + ``(N, 4)`` bubble positions ``(x, y, z, r)`` for each frame. **opts: Rendering options (cmap, vmin, vmax, dpi, log_scale, figsize, slice_axis, slice_index, slice_value). @@ -416,29 +450,37 @@ def _cleanup(): assembled = read_func(step) frame_path = os.path.join(viz_dir, f'{i:06d}.png') + # Inject per-step bubble positions into opts if bubble_func provided + frame_opts = opts + if bubble_func is not None: + try: + frame_opts = dict(opts, bubbles=bubble_func(step)) + except Exception: # pylint: disable=broad-except + pass + if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, - step, frame_path, **opts) + step, frame_path, **frame_opts) elif tiled and assembled.ndim == 2: - render_2d_tiled(assembled, step, frame_path, **opts) + render_2d_tiled(assembled, step, frame_path, **frame_opts) elif assembled.ndim == 1: var_data = assembled.variables.get(varname) if var_data is None: continue render_1d(assembled.x_cc, var_data, - varname, step, frame_path, **opts) + varname, step, frame_path, **frame_opts) elif assembled.ndim == 2: var_data = assembled.variables.get(varname) if var_data is None: continue render_2d(assembled.x_cc, assembled.y_cc, var_data, - varname, step, frame_path, **opts) + varname, step, frame_path, **frame_opts) elif assembled.ndim == 3: var_data = assembled.variables.get(varname) if var_data is None: continue - render_3d_slice(assembled, varname, step, frame_path, **opts) + render_3d_slice(assembled, varname, step, frame_path, **frame_opts) else: raise ValueError( f"Unsupported dimensionality ndim={assembled.ndim} for step {step}. " diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 2e456ed8cc..a3fbfcee74 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -188,7 +188,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc """Main viz command dispatcher.""" _ensure_viz_deps() - from .reader import discover_format, discover_timesteps, assemble # pylint: disable=import-outside-toplevel + from .reader import discover_format, discover_timesteps, assemble, has_lag_bubble_evol, read_lag_bubbles_at_step # pylint: disable=import-outside-toplevel from .renderer import render_1d, render_1d_tiled, render_2d, render_2d_tiled, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel case_dir = ARG('input') @@ -317,6 +317,12 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc interactive = ARG('interactive') + # Lagrange bubble overlay: auto-detect D/lag_bubble_evol_*.dat files + bubble_func = None + if has_lag_bubble_evol(case_dir): + bubble_func = lambda s: read_lag_bubbles_at_step(case_dir, s) # noqa: E731 + cons.print("[dim]Lagrange bubble positions detected — overlaying on plots.[/dim]") + # Load all variables when tiled, interactive, or TUI; filter otherwise. # TUI needs all vars loaded so the sidebar can switch between them. load_all = tiled or interactive or ARG('tui') @@ -409,7 +415,7 @@ def read_step(step): cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") success = render_mp4(varname, requested_steps, mp4_path, fps=int(fps), read_func=read_step, - tiled=tiled, **render_opts) + tiled=tiled, bubble_func=bubble_func, **render_opts) if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") else: @@ -437,20 +443,28 @@ def read_step(step): output_path = os.path.join(output_base, f'{label}_{step}.png') + # Inject bubble positions for this step + step_opts = render_opts + if bubble_func is not None: + try: + step_opts = dict(render_opts, bubbles=bubble_func(step)) + except Exception: # pylint: disable=broad-except + pass + if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, - step, output_path, **render_opts) + step, output_path, **step_opts) elif tiled and assembled.ndim == 2: - render_2d_tiled(assembled, step, output_path, **render_opts) + render_2d_tiled(assembled, step, output_path, **step_opts) elif assembled.ndim == 1: render_1d(assembled.x_cc, assembled.variables[varname], - varname, step, output_path, **render_opts) + varname, step, output_path, **step_opts) elif assembled.ndim == 2: render_2d(assembled.x_cc, assembled.y_cc, assembled.variables[varname], - varname, step, output_path, **render_opts) + varname, step, output_path, **step_opts) elif assembled.ndim == 3: - render_3d_slice(assembled, varname, step, output_path, **render_opts) + render_3d_slice(assembled, varname, step, output_path, **step_opts) else: cons.print(f"[yellow]Warning:[/yellow] Unsupported ndim={assembled.ndim} " f"for step {step}, skipping.") From 8740b960cea58f182b5c7780bae99885538ad842 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 16:59:42 -0500 Subject: [PATCH 071/102] viz: add Lagrange bubble overlay to interactive Dash mode For 2D heatmaps, bubbles render as Plotly layout shapes (white circles in data coordinates). For 3D slice mode, bubbles near the slice plane are shown as Scatter3d markers; isosurface/volume modes show all bubbles. bubble_func is now plumbed from viz.py through to run_interactive. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/interactive.py | 48 ++++++++++++++++++++++++++++++-- toolchain/mfc/viz/viz.py | 3 +- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 8e269d4204..6d0e0189e0 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -8,7 +8,7 @@ """ # pylint: disable=use-dict-literal -from typing import List, Callable +from typing import List, Callable, Optional import numpy as np import plotly.graph_objects as go @@ -191,12 +191,13 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar # Main entry point # --------------------------------------------------------------------------- -def run_interactive( # pylint: disable=too-many-locals,too-many-statements +def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too-many-arguments,too-many-positional-arguments varname: str, steps: List[int], read_func: Callable, port: int = 8050, host: str = '127.0.0.1', + bubble_func: Optional[Callable] = None, ): """Launch the interactive Dash visualization server.""" app = Dash( @@ -545,6 +546,30 @@ def _tf(arr): return arr float(vol_min_frac or 0.0), float(vol_max_frac or 1.0), ) fig.add_trace(trace) + # Bubble overlay for 3D + if bubble_func is not None: + try: + bubbles = bubble_func(step) + if bubbles is not None and len(bubbles) > 0: + if mode == 'slice': + s_axis = slice_axis or 'z' + s_col = {'x': 0, 'y': 1, 'z': 2}[s_axis] + ax_coords = {'x': ad.x_cc, 'y': ad.y_cc, 'z': ad.z_cc}[s_axis] + s_coord = ax_coords[0] + (ax_coords[-1] - ax_coords[0]) * float(slice_pos or 0.5) + near = np.abs(bubbles[:, s_col] - s_coord) <= bubbles[:, 3] + vis = bubbles[near] if near.any() else None + else: + vis = bubbles + if vis is not None and len(vis) > 0: + fig.add_trace(go.Scatter3d( + x=vis[:, 0], y=vis[:, 1], z=vis[:, 2], + mode='markers', + marker=dict(size=4, color='white', opacity=0.6, symbol='circle'), + showlegend=False, + hovertemplate='x=%{x:.3g}
y=%{y:.3g}
z=%{z:.3g}bubble', + )) + except Exception: # pylint: disable=broad-except + pass # Compute aspect ratio from domain extents so slices (which # have a constant coordinate on one axis) don't collapse that axis. dx = float(ad.x_cc[-1] - ad.x_cc[0]) if len(ad.x_cc) > 1 else 1.0 @@ -575,6 +600,25 @@ def _tf(arr): return arr yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER), plot_bgcolor=_BG, ) + # Bubble overlay for 2D + if bubble_func is not None: + try: + bubbles = bubble_func(step) + if bubbles is not None and len(bubbles) > 0: + shapes = [ + dict( + type='circle', + xref='x', yref='y', + x0=float(b[0] - b[3]), y0=float(b[1] - b[3]), + x1=float(b[0] + b[3]), y1=float(b[1] + b[3]), + line=dict(color='white', width=0.8), + fillcolor='rgba(0,0,0,0)', + ) + for b in bubbles + ] + fig.update_layout(shapes=shapes) + except Exception: # pylint: disable=broad-except + pass title = f'{selected_var} · step {step}' else: # 1D diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index a3fbfcee74..9338761c27 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -393,7 +393,8 @@ def read_step(step): # Default to first available variable if --var was not specified init_var = varname if varname in avail else (avail[0] if avail else None) run_interactive(init_var, requested_steps, read_step, - port=int(port), host=str(host)) + port=int(port), host=str(host), + bubble_func=bubble_func) return # Validate colormap before any rendering From e11b4934ea0d352d38059d9128926a2af62b6d9d Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 17:57:18 -0500 Subject: [PATCH 072/102] viz: overlay Lagrange bubble circles in TUI heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamps open-circle glyphs (○) directly onto the Rich-rendered 2D heatmap at bubble positions. Sub-cell bubbles get a single centre dot; larger bubbles get a parametric outline sampled at up to 72 points. Circle radii are computed in character-cell units using the same coord mapping as the heatmap, so they match the physical domain scale. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/tui.py | 56 +++++++++++++++++++++++++++++++++++++++- toolchain/mfc/viz/viz.py | 3 ++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index c108702197..4f9da4df6a 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -83,6 +83,7 @@ def __init__(self, **kwargs): self._vmax: Optional[float] = None self._last_vmin: float = 0.0 self._last_vmax: float = 1.0 + self._bubbles: Optional[np.ndarray] = None # (N,4) x,y,z,r def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data @@ -154,6 +155,44 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- iy = np.linspace(0, data.shape[1] - 1, h_plot, dtype=int) ds = data[np.ix_(ix, iy)] # pylint: disable=unsubscriptable-object + # Compute which screen cells to stamp with an open-circle glyph. + # col → x_cc[ix[col]], row 0 = y_max (flipped), row h_plot-1 = y_min. + bubble_cells: set = set() + bubbles = self._bubbles + if bubbles is not None and len(bubbles) > 0: + x_phys = x_cc[ix] # type: ignore[index] # pylint: disable=unsubscriptable-object + y_phys = y_cc_2d[iy] + x_min, x_max = float(x_phys[0]), float(x_phys[-1]) + y_min, y_max = float(y_phys[0]), float(y_phys[-1]) + x_range = max(abs(x_max - x_min), 1e-30) + y_range = max(abs(y_max - y_min), 1e-30) + for b in bubbles: # pylint: disable=not-an-iterable + bx, by, br = float(b[0]), float(b[1]), float(b[3]) + if bx < x_min - br or bx > x_max + br: + continue + if by < y_min - br or by > y_max + br: + continue + # Screen centre (col increases right, row 0 = top = y_max) + col_c = (bx - x_min) / x_range * (w_map - 1) + row_c = (y_max - by) / y_range * (h_plot - 1) + # Screen radius in character units + col_r = br / x_range * (w_map - 1) + row_r = br / y_range * (h_plot - 1) + if col_r < 0.5 and row_r < 0.5: + # Sub-cell bubble — mark centre only + c, r = int(round(col_c)), int(round(row_c)) + if 0 <= r < h_plot and 0 <= c < w_map: + bubble_cells.add((r, c)) + else: + # Parametric circle outline + n_pts = min(max(12, int(2 * np.pi * max(col_r, row_r))), 72) + for ti in range(n_pts): + angle = 2 * np.pi * ti / n_pts + c = int(round(col_c + col_r * np.cos(angle))) + r = int(round(row_c + row_r * np.sin(angle))) + if 0 <= r < h_plot and 0 <= c < w_map: + bubble_cells.add((r, c)) + vmin = self._vmin if self._vmin is not None else float(ds.min()) vmax = self._vmax if self._vmax is not None else float(ds.max()) if vmax <= vmin: @@ -184,7 +223,11 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- r = int(rgba[row, col, 0] * 255) g = int(rgba[row, col, 1] * 255) b = int(rgba[row, col, 2] * 255) - line.append(" ", style=Style(bgcolor=RichColor.from_rgb(r, g, b))) + bg = RichColor.from_rgb(r, g, b) + if (row, col) in bubble_cells: + line.append("○", style=Style(bgcolor=bg, color="white", bold=True)) + else: + line.append(" ", style=Style(bgcolor=bg)) # Gap line.append(" " * _CB_GAP) # Colorbar gradient strip (t=1 at top = vmax, t=0 at bottom = vmin) @@ -289,6 +332,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument read_func: Callable, ndim: int, init_var: Optional[str] = None, + bubble_func: Optional[Callable] = None, **kwargs, ): super().__init__(**kwargs) @@ -296,6 +340,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._varnames = varnames self._read_func = read_func self._ndim = ndim + self._bubble_func = bubble_func # Store init_var but don't set the reactive yet — the DOM doesn't exist # until on_mount, and the watcher calls query_one which needs the DOM. self._init_var = init_var or (varnames[0] if varnames else "") @@ -394,6 +439,13 @@ def _push_data(self) -> None: plot._step = step # pylint: disable=protected-access plot._cmap_name = self.cmap_name # pylint: disable=protected-access plot._log_scale = self.log_scale # pylint: disable=protected-access + if self._bubble_func is not None and self._ndim == 2: + try: + plot._bubbles = self._bubble_func(step) # pylint: disable=protected-access + except Exception: # pylint: disable=broad-except + plot._bubbles = None # pylint: disable=protected-access + else: + plot._bubbles = None # pylint: disable=protected-access if self._frozen_range is not None: plot._vmin, plot._vmax = self._frozen_range # pylint: disable=protected-access else: @@ -452,6 +504,7 @@ def run_tui( steps: List[int], read_func: Callable, ndim: int, + bubble_func: Optional[Callable] = None, ) -> None: """Launch the Textual TUI for MFC visualization (1D/2D only).""" if ndim not in (1, 2): @@ -482,5 +535,6 @@ def run_tui( read_func=read_func, ndim=ndim, init_var=init_var, + bubble_func=bubble_func, ) app.run() diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 9338761c27..13c23868ad 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -382,7 +382,8 @@ def read_step(step): ) from .tui import run_tui # pylint: disable=import-outside-toplevel init_var = varname if varname in avail else (avail[0] if avail else None) - run_tui(init_var, requested_steps, read_step, ndim=test_assembled.ndim) + run_tui(init_var, requested_steps, read_step, ndim=test_assembled.ndim, + bubble_func=bubble_func) return # Interactive mode — launch Dash web server From 800327e7844e5fc717ab7fdb8f5e87372bc28858 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 18:04:23 -0500 Subject: [PATCH 073/102] viz: enforce 5s minimum MP4 duration when fps is not specified When --fps is not given (default 10), auto-reduce fps so the video is at least 5 seconds long. Prints the adjusted rate and final duration. Passes fps as float so imageio can handle sub-integer rates. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/viz.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 13c23868ad..59dae88a7c 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -411,12 +411,22 @@ def read_step(step): # MP4 mode if ARG('mp4'): - fps = ARG('fps') + fps = float(ARG('fps')) + _FPS_DEFAULT = 10.0 + _MIN_DURATION = 5.0 # seconds + n_frames = len(requested_steps) + if fps == _FPS_DEFAULT and n_frames / fps < _MIN_DURATION: + fps = max(1.0, n_frames / _MIN_DURATION) + cons.print( + f"[dim]Auto-adjusted fps to {fps:.2g} " + f"so video is at least {_MIN_DURATION:.0f}s " + f"(use --fps to override).[/dim]" + ) label = 'all' if tiled else varname mp4_path = os.path.join(output_base, f'{label}.mp4') - cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({len(requested_steps)} frames)") + cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({n_frames} frames @ {fps:.2g} fps = {n_frames/fps:.1f}s)") success = render_mp4(varname, requested_steps, mp4_path, - fps=int(fps), read_func=read_step, + fps=fps, read_func=read_step, tiled=tiled, bubble_func=bubble_func, **render_opts) if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") From 8b217ec9ed0ce35e5145e5cf07e6666eb6a54232 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 18:09:13 -0500 Subject: [PATCH 074/102] viz: default --mp4 to all steps (like --interactive/--tui) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without an explicit --step, MP4 was inheriting the 'last' default and producing a 1-frame video. Match the behaviour of interactive/tui modes by promoting 'last' → 'all' when --mp4 is active. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/viz.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 59dae88a7c..ed1c511e5e 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -280,7 +280,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') tiled = varname is None or varname == 'all' - if ARG('interactive') or ARG('tui'): + if ARG('interactive') or ARG('tui') or ARG('mp4'): # Load all steps by default; honour an explicit --step so users can # reduce the set for large 3D cases before hitting the step limit. if step_arg == 'last': From ae45ed19233fb9ffcaffc4afa565491b9f5beb03 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 22:54:00 -0500 Subject: [PATCH 075/102] viz: address bot review findings (encoding, lint sentinel, cmap, MP4 error logging) --- toolchain/bootstrap/lint.sh | 2 +- toolchain/mfc/viz/interactive.py | 9 ++++++++- toolchain/mfc/viz/reader.py | 12 +++++++++--- toolchain/mfc/viz/renderer.py | 7 ++++--- toolchain/mfc/viz/viz.py | 10 +++++----- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 1fb641d1c9..9fdadc0c7c 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -16,7 +16,7 @@ done # networks where PyPI is unreachable the install may fail; in that case we skip the # viz-specific lint and tests rather than aborting the entire precheck. VIZ_LINT=true -if ! python3 -c "import matplotlib, dash, textual, imageio, h5py" 2>/dev/null; then +if ! python3 -c "import matplotlib, dash, textual, imageio, h5py, plotext, plotly" 2>/dev/null; then log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." if ! { uv pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null \ || python3 -m pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null; }; then diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 6d0e0189e0..9f92bdd790 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -199,7 +199,14 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- host: str = '127.0.0.1', bubble_func: Optional[Callable] = None, ): - """Launch the interactive Dash visualization server.""" + """Launch the interactive Dash visualization server. + + Args: + bubble_func: Optional callable ``(step: int) -> np.ndarray | None``. + Must return a float64 array of shape ``(N, 4)`` with columns + ``[x, y, z, radius]`` in simulation-normalized units, or ``None`` + when no bubble data is available for that step. + """ app = Dash( __name__, title=f'MFC viz · {varname}', diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 8f60d57d00..3c56eca3b8 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -449,7 +449,7 @@ def _nBubs_per_step(path: str) -> int: The result is cached so repeated calls for the same file (across different steps in an MP4 render) only scan the file once. """ - with open(path) as f: + with open(path, encoding='ascii', errors='replace') as f: f.readline() # skip header first = f.readline() if not first.strip(): @@ -492,10 +492,16 @@ def read_lag_bubbles_at_step(case_dir: str, step: int) -> Optional[np.ndarray]: nBubs = _nBubs_per_step(fpath) if nBubs == 0: continue - # Line layout: 1 header + step * nBubs prior data rows + # Line layout: 1 header + step * nBubs prior data rows. + # MFC writes one bubble block per simulation timestep (at the last + # RK stage), so block index == MFC timestep integer. This is + # correct for fresh runs (t_step_start=0). 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 rows = [] - with open(fpath) as f: + with open(fpath, encoding='ascii', errors='replace') as f: for _ in itertools.islice(f, skip): pass for line in itertools.islice(f, nBubs): diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index ac69f960ed..0eb6faa703 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -18,7 +18,7 @@ import matplotlib try: matplotlib.use('Agg') -except Exception: # pylint: disable=broad-except +except ValueError: pass import matplotlib.pyplot as plt # pylint: disable=wrong-import-position from matplotlib.colors import LogNorm # pylint: disable=wrong-import-position @@ -541,8 +541,9 @@ def _uniform_frame(arr): imageio.imread(os.path.join(viz_dir, fname)) )) success = True - except Exception: # pylint: disable=broad-except - pass + except Exception as exc: # pylint: disable=broad-except + import warnings # pylint: disable=import-outside-toplevel + warnings.warn(f"MP4 encoding error: {exc}", stacklevel=2) finally: _cleanup() return success diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index ed1c511e5e..7957a2c286 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -373,6 +373,11 @@ def read_step(step): f"Use --list-vars to see variables at a given step." ) + # Validate colormap early so all modes get a clean error for bad --cmap + cmap_name = ARG('cmap') + if cmap_name: + _validate_cmap(cmap_name) + # TUI mode — launch Textual terminal UI (1D/2D only) if ARG('tui'): if test_assembled.ndim == 3: @@ -398,11 +403,6 @@ def read_step(step): bubble_func=bubble_func) return - # Validate colormap before any rendering - cmap_name = ARG('cmap') - if cmap_name: - _validate_cmap(cmap_name) - # Create output directory output_base = ARG('output') if output_base is None: From 25afc74d89915bf474c7a1aeea44b050ad5ef1f9 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 23:21:19 -0500 Subject: [PATCH 076/102] viz: add value-level silo/binary consistency test and --step format regression test --- toolchain/mfc/viz/test_viz.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 949d9e1c5f..b321f3ee09 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -109,6 +109,12 @@ def test_invalid_value(self): with self.assertRaises(MFCException): self._parse('bogus', [0, 100]) + def test_hyphen_range_raises_clean_error(self): + """'0-100' (hyphen instead of colon) raises MFCException, not raw ValueError.""" + from mfc.common import MFCException + with self.assertRaises(MFCException): + self._parse('0-100', [0, 100]) + # --------------------------------------------------------------------------- # Tests: pretty_label @@ -345,6 +351,23 @@ def test_1d_same_vars(self): self.assertEqual(sorted(bin_data.variables.keys()), sorted(silo_data.variables.keys())) + def test_1d_same_values(self): + """Binary and silo 1D fixtures have the same variable values.""" + import numpy as np + from .reader import assemble + from .silo_reader import assemble_silo + bin_data = assemble(FIX_1D_BIN, 0, 'binary') + silo_data = assemble_silo(FIX_1D_SILO, 0) + common = sorted(set(bin_data.variables) & set(silo_data.variables)) + self.assertGreater(len(common), 0, "No common variables to compare") + for vname in common: + np.testing.assert_allclose( + bin_data.variables[vname], + silo_data.variables[vname], + rtol=1e-5, atol=1e-10, + err_msg=f"Variable '{vname}' differs between binary and silo", + ) + # --------------------------------------------------------------------------- # Tests: 1D rendering (requires matplotlib/imageio) From e1a4842172941ffe3f226848b5dfd766115046d5 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Fri, 27 Feb 2026 23:50:53 -0500 Subject: [PATCH 077/102] =?UTF-8?q?viz:=20tui=20improvements=20=E2=80=94?= =?UTF-8?q?=20background=20loading,=20Digits,=20Sparkline,=20click=20value?= =?UTF-8?q?,=20scroll=20zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toolchain/mfc/viz/tui.py | 331 +++++++++++++++++++++++++++++++-------- 1 file changed, 270 insertions(+), 61 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 4f9da4df6a..5d9cb0a995 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -7,11 +7,12 @@ Supports 1D line plots and 2D heatmaps only. -Requires: textual, textual-plotext, plotext +Requires: textual>=0.43, textual-plotext, plotext """ from __future__ import annotations -from typing import Callable, List, Optional +from collections import deque +from typing import Callable, Deque, List, Optional, Tuple import numpy as np @@ -24,8 +25,13 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical +from textual.message import Message from textual.reactive import reactive -from textual.widgets import Footer, Header, Label, ListItem, ListView, Static +from textual.widgets import ( + Digits, Footer, Header, Label, ListItem, ListView, Sparkline, Static, +) +from textual import work +from textual.worker import get_current_worker from textual_plotext import PlotextPlot @@ -52,6 +58,15 @@ _ASPECT_MIN: float = 0.2 _ASPECT_MAX: float = 5.0 +# Maximum sparkline history entries (one per step visited). +_HISTORY_MAX: int = 60 + +# Textual mouse events give coordinates relative to the widget top-left corner +# (including the border). Our widget has a 1-char solid border on every side, +# and the 2D render always emits a 1-row header above the heatmap. +_BORDER: int = 1 +_HEADER_ROWS: int = 1 + # --------------------------------------------------------------------------- # Plot widget @@ -69,6 +84,15 @@ class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too- } """ + class Clicked(Message): + """Posted when the user clicks on the 2D heatmap.""" + + def __init__(self, x_phys: float, y_phys: float, value: float) -> None: + super().__init__() + self.x_phys = x_phys + self.y_phys = y_phys + self.value = value + def __init__(self, **kwargs): super().__init__(**kwargs) self._x_cc: Optional[np.ndarray] = None @@ -84,6 +108,102 @@ def __init__(self, **kwargs): self._last_vmin: float = 0.0 self._last_vmax: float = 1.0 self._bubbles: Optional[np.ndarray] = None # (N,4) x,y,z,r + # Zoom state: (x_frac0, x_frac1, y_frac0, y_frac1) all in [0,1]. + self._zoom: Tuple[float, float, float, float] = (0.0, 1.0, 0.0, 1.0) + # Last-render dimensions — used to map click/scroll coords back to data. + self._last_w_map: int = 0 + self._last_h_plot: int = 0 + self._last_ix: Optional[np.ndarray] = None + self._last_iy: Optional[np.ndarray] = None + + # ------------------------------------------------------------------ + # Zoom helpers (Feature 6) + # ------------------------------------------------------------------ + + def reset_zoom(self) -> None: + """Reset to full view.""" + self._zoom = (0.0, 1.0, 0.0, 1.0) + self.refresh() + + def _zoom_around( # pylint: disable=too-many-locals + self, cx_frac: float, cy_frac: float, factor: float + ) -> None: + """Zoom by *factor* centred at *(cx_frac, cy_frac)* in [0,1]² of current view.""" + x0, x1, y0, y1 = self._zoom + x_span = x1 - x0 + y_span = y1 - y0 + new_x_span = x_span * factor + new_y_span = y_span * factor + # Enforce minimum zoom (2 % of full range per axis). + if new_x_span < 0.02 or new_y_span < 0.02: + return + cx = x0 + cx_frac * x_span + cy = y0 + cy_frac * y_span + new_x0 = max(0.0, cx - cx_frac * new_x_span) + new_x1 = min(1.0, cx + (1.0 - cx_frac) * new_x_span) + new_y0 = max(0.0, cy - cy_frac * new_y_span) + new_y1 = min(1.0, cy + (1.0 - cy_frac) * new_y_span) + if new_x1 - new_x0 < 0.02 or new_y1 - new_y0 < 0.02: + return + self._zoom = (new_x0, new_x1, new_y0, new_y1) + + def _cursor_frac(self, event_x: int, event_y: int) -> Tuple[float, float]: + """Convert a widget-relative mouse position to [0,1]² fractions within the + current zoom window. The display is y-flipped (row 0 = y_max), so + cy_frac=0 maps to the top of the visible y range.""" + col = event_x - _BORDER + row = event_y - _BORDER - _HEADER_ROWS + w = max(self._last_w_map, 1) + h = max(self._last_h_plot, 1) + cx_frac = max(0.0, min(1.0, col / (w - 1) if w > 1 else 0.5)) + # Row 0 = top = y_max → data cy_frac = 1; row h-1 = bottom = y_min → 0. + cy_frac = max(0.0, min(1.0, 1.0 - row / (h - 1) if h > 1 else 0.5)) + return cx_frac, cy_frac + + # ------------------------------------------------------------------ + # Mouse event handlers (Features 5 & 6) + # ------------------------------------------------------------------ + + def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] + if self._data is None or self._ndim != 2: + return + cx_frac, cy_frac = self._cursor_frac(event.x, event.y) + self._zoom_around(cx_frac, cy_frac, factor=0.75) + event.stop() + self.refresh() + + def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] + if self._data is None or self._ndim != 2: + return + cx_frac, cy_frac = self._cursor_frac(event.x, event.y) + self._zoom_around(cx_frac, cy_frac, factor=1.0 / 0.75) + event.stop() + self.refresh() + + def on_mouse_down(self, event) -> None: # type: ignore[override] + """Feature 5 — show the data value at the clicked grid cell.""" + if self._data is None or self._ndim != 2: + return + if self._last_w_map == 0 or self._last_ix is None or self._last_iy is None: + return + col = event.x - _BORDER + row = event.y - _BORDER - _HEADER_ROWS + if not (0 <= col < self._last_w_map and 0 <= row < self._last_h_plot): + return + # Map screen column → _last_ix index → data x-index. + n_ix = len(self._last_ix) + n_iy = len(self._last_iy) + ix_pos = int(np.round(col * (n_ix - 1) / max(self._last_w_map - 1, 1))) + # Display is y-flipped: row 0 = top = last_iy[-1] (y_max). + iy_pos_flip = int(np.round(row * (n_iy - 1) / max(self._last_h_plot - 1, 1))) + iy_pos = n_iy - 1 - iy_pos_flip + xi = int(self._last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object + yi = int(self._last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object + y_cc_click = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) + x_val = float(self._x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + y_val = float(y_cc_click[yi]) + val = float(self._data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + self.post_message(MFCPlot.Clicked(x_val, y_val, val)) def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data @@ -131,18 +251,13 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- _CB_GAP, _CB_W, _CB_LBL = 1, 2, 9 w_map_avail = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) - # Preserve the physical x/y aspect ratio so the heatmap is not - # stretched to fill the terminal. The domain ratio is clamped to - # avoid extremely wide or tall slivers. + # Preserve the physical x/y aspect ratio. y_cc_2d = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) x_extent = max(abs(float(x_cc[-1]) - float(x_cc[0])), 1e-30) # pylint: disable=unsubscriptable-object y_extent = max(abs(float(y_cc_2d[-1]) - float(y_cc_2d[0])), 1e-30) domain_ratio = float(np.clip(x_extent / y_extent, _ASPECT_MIN, _ASPECT_MAX)) - # Convert to character-grid ratio: 1 row ≈ _CELL_RATIO columns wide. - char_ratio = domain_ratio * _CELL_RATIO # desired w_map / h_plot + char_ratio = domain_ratio * _CELL_RATIO - # Fit within the available character budget: try height-constrained first, - # fall back to width-constrained if the ideal width exceeds w_map_avail. w_ideal = int(round(h_plot_avail * char_ratio)) if w_ideal <= w_map_avail: w_map = max(w_ideal, 4) @@ -151,12 +266,24 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- h_plot = max(int(round(w_map_avail / char_ratio)), 4) w_map = w_map_avail - ix = np.linspace(0, data.shape[0] - 1, w_map, dtype=int) - iy = np.linspace(0, data.shape[1] - 1, h_plot, dtype=int) + # Apply zoom window to data index ranges (Feature 6). + x0_f, x1_f, y0_f, y1_f = self._zoom + x0_i = int(x0_f * (data.shape[0] - 1)) + x1_i = max(x0_i + 1, int(x1_f * (data.shape[0] - 1))) + y0_i = int(y0_f * (data.shape[1] - 1)) + y1_i = max(y0_i + 1, int(y1_f * (data.shape[1] - 1))) + ix = np.linspace(x0_i, x1_i, w_map, dtype=int) + iy = np.linspace(y0_i, y1_i, h_plot, dtype=int) + + # Cache for click/scroll coordinate mapping (Features 5 & 6). + self._last_w_map = w_map + self._last_h_plot = h_plot + self._last_ix = ix + self._last_iy = iy + ds = data[np.ix_(ix, iy)] # pylint: disable=unsubscriptable-object # Compute which screen cells to stamp with an open-circle glyph. - # col → x_cc[ix[col]], row 0 = y_max (flipped), row h_plot-1 = y_min. bubble_cells: set = set() bubbles = self._bubbles if bubbles is not None and len(bubbles) > 0: @@ -172,19 +299,15 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- continue if by < y_min - br or by > y_max + br: continue - # Screen centre (col increases right, row 0 = top = y_max) col_c = (bx - x_min) / x_range * (w_map - 1) row_c = (y_max - by) / y_range * (h_plot - 1) - # Screen radius in character units col_r = br / x_range * (w_map - 1) row_r = br / y_range * (h_plot - 1) if col_r < 0.5 and row_r < 0.5: - # Sub-cell bubble — mark centre only c, r = int(round(col_c)), int(round(row_c)) if 0 <= r < h_plot and 0 <= c < w_map: bubble_cells.add((r, c)) else: - # Parametric circle outline n_pts = min(max(12, int(2 * np.pi * max(col_r, row_r))), 72) for ti in range(n_pts): angle = 2 * np.pi * ti / n_pts @@ -218,7 +341,6 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- lines = [] for row in range(h_plot): line = RichText() - # Heatmap cells — one terminal character per data point. for col in range(w_map): r = int(rgba[row, col, 0] * 255) g = int(rgba[row, col, 1] * 255) @@ -249,17 +371,21 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- line.append(lbl.ljust(_CB_LBL)[:_CB_LBL]) lines.append(line) - y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) log_tag = " [log]" if log_active else (" [log n/a]" if self._log_scale else "") frozen_tag = " [frozen]" if self._vmin is not None else "" + zoomed_tag = " [zoom]" if self._zoom != (0.0, 1.0, 0.0, 1.0) else "" header = RichText( f" {self._varname} (step {self._step})" - f" [{vmin:.3g}, {vmax:.3g}]{log_tag}{frozen_tag}", + f" [{vmin:.3g}, {vmax:.3g}]{log_tag}{frozen_tag}{zoomed_tag}", style="bold" ) + # Show the visible coordinate range (reflects zoom when active). + x_lo = float(x_cc[ix[0]]) # type: ignore[index] # pylint: disable=unsubscriptable-object + x_hi = float(x_cc[ix[-1]]) # type: ignore[index] # pylint: disable=unsubscriptable-object + y_vis = y_cc_2d[iy] footer = RichText( - f" x: [{x_cc[0]:.3f} \u2026 {x_cc[-1]:.3f}]" # pylint: disable=unsubscriptable-object - f" y: [{y_cc[0]:.3f} \u2026 {y_cc[-1]:.3f}]", + f" x: [{x_lo:.3f} \u2026 {x_hi:.3f}]" + f" y: [{float(y_vis[0]):.3f} \u2026 {float(y_vis[-1]):.3f}]", style="dim" ) return RichGroup(header, *lines, footer) @@ -288,6 +414,12 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes padding: 0 1; } + #step-counter { + width: 1fr; + height: 4; + color: $accent; + } + #var-title { text-style: bold; color: $accent; @@ -298,6 +430,17 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes height: 1fr; } + #hist-title { + text-style: dim; + padding: 1 0 0 0; + height: 1; + } + + #sparkline { + height: 3; + width: 1fr; + } + #status { dock: bottom; height: 1; @@ -317,6 +460,7 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes Binding("c", "cycle_cmap", "cmap"), Binding("l", "toggle_log", "log"), Binding("f", "toggle_freeze", "freeze"), + Binding("r", "reset_zoom", "zoom↺", show=False), ] step_idx: reactive[int] = reactive(0, always_update=True) @@ -341,21 +485,24 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._read_func = read_func self._ndim = ndim self._bubble_func = bubble_func - # Store init_var but don't set the reactive yet — the DOM doesn't exist - # until on_mount, and the watcher calls query_one which needs the DOM. self._init_var = init_var or (varnames[0] if varnames else "") - self._frozen_range: Optional[tuple] = None + self._frozen_range: Optional[Tuple[float, float]] = None self._play_timer = None + # Per-variable max-value history for the sparkline (Feature 3). + self._vmax_history: Deque[float] = deque(maxlen=_HISTORY_MAX) def compose(self) -> ComposeResult: yield Header(show_clock=False) with Horizontal(id="content"): with Vertical(id="sidebar"): + yield Digits("0", id="step-counter") # Feature 2 yield Label("Variables", id="var-title") yield ListView( *[ListItem(Label(v), id=f"var-{v}") for v in self._varnames], id="var-list", ) + yield Label("peak", id="hist-title") + yield Sparkline([], summary_function=max, id="sparkline") # Feature 3 yield MFCPlot(id="plot") yield Static(self._status_text(), id="status") yield Footer() @@ -363,7 +510,6 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: # DOM is ready — now safe to set the reactive (fires watcher → _push_data) self.var_name = self._init_var - # Highlight the initial variable in the sidebar list lv = self.query_one("#var-list", ListView) for i, v in enumerate(self._varnames): if v == self.var_name: @@ -378,6 +524,7 @@ def watch_step_idx(self, _old: int, _new: int) -> None: self._push_data() def watch_var_name(self, _old: str, _new: str) -> None: + self._vmax_history.clear() # reset sparkline when variable changes self._push_data() def watch_cmap_name(self, _old: str, _new: str) -> None: @@ -395,66 +542,121 @@ def watch_playing(self, _old: bool, new: bool) -> None: self._play_timer = None # ------------------------------------------------------------------ - # Helpers + # Message handlers # ------------------------------------------------------------------ - def _status_text(self) -> str: - step = self._steps[self.step_idx] if self._steps else 0 - total = len(self._steps) - flags = [] - if self.log_scale: - flags.append("log") - if self._frozen_range is not None: - flags.append("frozen") - if self.playing: - flags.append("▶") - flag_str = (" " + " ".join(flags)) if flags else "" - return ( - f" step {step} [{self.step_idx + 1}/{total}]" - f" var: {self.var_name}" - f" cmap: {self.cmap_name}" - f"{flag_str}" + def on_mfc_plot_clicked(self, event: MFCPlot.Clicked) -> None: + """Feature 5 — show the data value at the clicked grid coordinate.""" + self.query_one("#status", Static).update( + f" x={event.x_phys:.4f} y={event.y_phys:.4f} val={event.value:.6g}" ) + # ------------------------------------------------------------------ + # Background data loading (Feature 4) + # ------------------------------------------------------------------ + + @work(exclusive=True, thread=True) def _push_data(self) -> None: - """Load the current step/var and push data into the plot widget.""" + """Load the current step/var in a background thread and push to the plot.""" if not self._steps or not self.var_name: return - step = self._steps[self.step_idx] + # Snapshot all reactive state before entering the thread to avoid + # reading stale values if the user changes something mid-load. + step_idx = min(self.step_idx, len(self._steps) - 1) + step = self._steps[step_idx] + var = self.var_name + cmap = self.cmap_name + log = self.log_scale + frozen = self._frozen_range + try: assembled = _load(step, self._read_func) except Exception as exc: # pylint: disable=broad-except - self.query_one("#status", Static).update( - f" [red]Error loading step {step}: {exc}[/red]" + self.call_from_thread( + self.query_one("#status", Static).update, + f" [red]Error loading step {step}: {exc}[/red]", ) return - data = assembled.variables.get(self.var_name) + worker = get_current_worker() + if worker.is_cancelled: + return + + data = assembled.variables.get(var) + bubbles = None + if self._bubble_func is not None and self._ndim == 2: + try: + bubbles = self._bubble_func(step) + except Exception: # pylint: disable=broad-except + pass + + self.call_from_thread( + self._apply_data, assembled, data, step, var, cmap, log, frozen, bubbles, + ) + + def _apply_data( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + assembled, + data: Optional[np.ndarray], + step: int, + var: str, + cmap: str, + log: bool, + frozen: Optional[Tuple[float, float]], + bubbles: Optional[np.ndarray], + ) -> None: + """Apply loaded data to the plot widget. Runs on the main thread.""" plot = self.query_one("#plot", MFCPlot) plot._x_cc = assembled.x_cc # pylint: disable=protected-access plot._y_cc = assembled.y_cc # pylint: disable=protected-access plot._data = data # pylint: disable=protected-access plot._ndim = self._ndim # pylint: disable=protected-access - plot._varname = self.var_name # pylint: disable=protected-access + plot._varname = var # pylint: disable=protected-access plot._step = step # pylint: disable=protected-access - plot._cmap_name = self.cmap_name # pylint: disable=protected-access - plot._log_scale = self.log_scale # pylint: disable=protected-access - if self._bubble_func is not None and self._ndim == 2: - try: - plot._bubbles = self._bubble_func(step) # pylint: disable=protected-access - except Exception: # pylint: disable=broad-except - plot._bubbles = None # pylint: disable=protected-access - else: - plot._bubbles = None # pylint: disable=protected-access - if self._frozen_range is not None: - plot._vmin, plot._vmax = self._frozen_range # pylint: disable=protected-access + plot._cmap_name = cmap # pylint: disable=protected-access + plot._log_scale = log # pylint: disable=protected-access + plot._bubbles = bubbles # pylint: disable=protected-access + if frozen is not None: + plot._vmin, plot._vmax = frozen # pylint: disable=protected-access else: plot._vmin = None # pylint: disable=protected-access plot._vmax = None # pylint: disable=protected-access plot.refresh() + # Feature 2 — update the large step counter. + self.query_one("#step-counter", Digits).update(str(step)) + + # Feature 3 — append to sparkline history and refresh. + if data is not None and data.size > 0: + finite = data[np.isfinite(data)] + if finite.size > 0: + self._vmax_history.append(float(finite.max())) + self.query_one("#sparkline", Sparkline).data = list(self._vmax_history) + self.query_one("#status", Static).update(self._status_text()) + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _status_text(self) -> str: + step = self._steps[self.step_idx] if self._steps else 0 + total = len(self._steps) + flags = [] + if self.log_scale: + flags.append("log") + if self._frozen_range is not None: + flags.append("frozen") + if self.playing: + flags.append("▶") + flag_str = (" " + " ".join(flags)) if flags else "" + return ( + f" step {step} [{self.step_idx + 1}/{total}]" + f" var: {self.var_name}" + f" cmap: {self.cmap_name}" + f"{flag_str}" + ) + # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ @@ -491,6 +693,10 @@ def action_toggle_freeze(self) -> None: def action_toggle_play(self) -> None: self.playing = not self.playing + def action_reset_zoom(self) -> None: + """Feature 6 — reset 2D zoom to full view.""" + self.query_one("#plot", MFCPlot).reset_zoom() + def _auto_advance(self) -> None: self.step_idx = (self.step_idx + 1) % len(self._steps) @@ -525,7 +731,10 @@ def run_tui( f"[bold]Launching TUI[/bold] — {len(steps)} step(s), " f"{len(varnames)} variable(s)" ) - cons.print("[dim] ,/. or ←/→ prev/next step • space play • l log • f freeze • ↑↓ variable • q quit[/dim]") + cons.print( + "[dim] ,/. or ←/→ step • space play • l log • f freeze" + " • c cmap • ↑↓ var • scroll zoom • r reset zoom • click value • q quit[/dim]" + ) _step_cache.seed(steps[0], first) From 629a782c0e979a8d415b44838e6098abf70f840a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 00:02:06 -0500 Subject: [PATCH 078/102] =?UTF-8?q?viz:=20tui=20=E2=80=94=20drop=20sparkli?= =?UTF-8?q?ne,=20fix=20click=20via=20get=5Fcontent=5Foffset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toolchain/mfc/viz/tui.py | 107 ++++++++++++--------------------------- 1 file changed, 33 insertions(+), 74 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 5d9cb0a995..b2edbab5f9 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -11,8 +11,7 @@ """ from __future__ import annotations -from collections import deque -from typing import Callable, Deque, List, Optional, Tuple +from typing import Callable, List, Optional, Tuple import numpy as np @@ -21,16 +20,14 @@ from rich.style import Style from rich.text import Text as RichText -from textual import on +from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical -from textual.message import Message from textual.reactive import reactive from textual.widgets import ( - Digits, Footer, Header, Label, ListItem, ListView, Sparkline, Static, + Digits, Footer, Header, Label, ListItem, ListView, Static, ) -from textual import work from textual.worker import get_current_worker from textual_plotext import PlotextPlot @@ -58,13 +55,7 @@ _ASPECT_MIN: float = 0.2 _ASPECT_MAX: float = 5.0 -# Maximum sparkline history entries (one per step visited). -_HISTORY_MAX: int = 60 - -# Textual mouse events give coordinates relative to the widget top-left corner -# (including the border). Our widget has a 1-char solid border on every side, -# and the 2D render always emits a 1-row header above the heatmap. -_BORDER: int = 1 +# Rows of header above the heatmap inside the content area. _HEADER_ROWS: int = 1 @@ -84,15 +75,6 @@ class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too- } """ - class Clicked(Message): - """Posted when the user clicks on the 2D heatmap.""" - - def __init__(self, x_phys: float, y_phys: float, value: float) -> None: - super().__init__() - self.x_phys = x_phys - self.y_phys = y_phys - self.value = value - def __init__(self, **kwargs): super().__init__(**kwargs) self._x_cc: Optional[np.ndarray] = None @@ -147,16 +129,20 @@ def _zoom_around( # pylint: disable=too-many-locals return self._zoom = (new_x0, new_x1, new_y0, new_y1) - def _cursor_frac(self, event_x: int, event_y: int) -> Tuple[float, float]: - """Convert a widget-relative mouse position to [0,1]² fractions within the - current zoom window. The display is y-flipped (row 0 = y_max), so - cy_frac=0 maps to the top of the visible y range.""" - col = event_x - _BORDER - row = event_y - _BORDER - _HEADER_ROWS + def _cursor_frac(self, event) -> Tuple[float, float]: + """Map a mouse event to [0,1]² fractions within the current heatmap view. + + Uses ``event.get_content_offset_capture(self)`` so the result is valid even + when the cursor sits on the border. The display is y-flipped (row 0 = y_max), + so ``cy_frac=0`` maps to the top of the visible y range. + """ + offset = event.get_content_offset_capture(self) + col = offset.x + row = offset.y - _HEADER_ROWS # skip header row inside content area w = max(self._last_w_map, 1) h = max(self._last_h_plot, 1) cx_frac = max(0.0, min(1.0, col / (w - 1) if w > 1 else 0.5)) - # Row 0 = top = y_max → data cy_frac = 1; row h-1 = bottom = y_min → 0. + # Row 0 = top = y_max → cy_frac = 1; row h-1 = bottom = y_min → 0. cy_frac = max(0.0, min(1.0, 1.0 - row / (h - 1) if h > 1 else 0.5)) return cx_frac, cy_frac @@ -167,7 +153,7 @@ def _cursor_frac(self, event_x: int, event_y: int) -> Tuple[float, float]: def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] if self._data is None or self._ndim != 2: return - cx_frac, cy_frac = self._cursor_frac(event.x, event.y) + cx_frac, cy_frac = self._cursor_frac(event) self._zoom_around(cx_frac, cy_frac, factor=0.75) event.stop() self.refresh() @@ -175,22 +161,26 @@ def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] if self._data is None or self._ndim != 2: return - cx_frac, cy_frac = self._cursor_frac(event.x, event.y) + cx_frac, cy_frac = self._cursor_frac(event) self._zoom_around(cx_frac, cy_frac, factor=1.0 / 0.75) event.stop() self.refresh() - def on_mouse_down(self, event) -> None: # type: ignore[override] + def on_click(self, event) -> None: # type: ignore[override] # pylint: disable=too-many-locals """Feature 5 — show the data value at the clicked grid cell.""" if self._data is None or self._ndim != 2: return if self._last_w_map == 0 or self._last_ix is None or self._last_iy is None: return - col = event.x - _BORDER - row = event.y - _BORDER - _HEADER_ROWS - if not (0 <= col < self._last_w_map and 0 <= row < self._last_h_plot): + # get_content_offset returns None if the click is on the border/padding. + offset = event.get_content_offset(self) + if offset is None: return - # Map screen column → _last_ix index → data x-index. + col = offset.x + row = offset.y - _HEADER_ROWS # skip header row + # Clamp to heatmap area (click on colorbar or footer is fine — just clamp). + col = max(0, min(col, self._last_w_map - 1)) + row = max(0, min(row, self._last_h_plot - 1)) n_ix = len(self._last_ix) n_iy = len(self._last_iy) ix_pos = int(np.round(col * (n_ix - 1) / max(self._last_w_map - 1, 1))) @@ -203,7 +193,10 @@ def on_mouse_down(self, event) -> None: # type: ignore[override] x_val = float(self._x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object y_val = float(y_cc_click[yi]) val = float(self._data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object - self.post_message(MFCPlot.Clicked(x_val, y_val, val)) + # Update status bar directly — no message routing needed. + self.app.query_one("#status", Static).update( + f" x={x_val:.4f} y={y_val:.4f} val={val:.6g}" + ) def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data @@ -430,17 +423,6 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes height: 1fr; } - #hist-title { - text-style: dim; - padding: 1 0 0 0; - height: 1; - } - - #sparkline { - height: 3; - width: 1fr; - } - #status { dock: bottom; height: 1; @@ -488,21 +470,17 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._init_var = init_var or (varnames[0] if varnames else "") self._frozen_range: Optional[Tuple[float, float]] = None self._play_timer = None - # Per-variable max-value history for the sparkline (Feature 3). - self._vmax_history: Deque[float] = deque(maxlen=_HISTORY_MAX) def compose(self) -> ComposeResult: yield Header(show_clock=False) with Horizontal(id="content"): with Vertical(id="sidebar"): - yield Digits("0", id="step-counter") # Feature 2 + yield Digits("0", id="step-counter") yield Label("Variables", id="var-title") yield ListView( *[ListItem(Label(v), id=f"var-{v}") for v in self._varnames], id="var-list", ) - yield Label("peak", id="hist-title") - yield Sparkline([], summary_function=max, id="sparkline") # Feature 3 yield MFCPlot(id="plot") yield Static(self._status_text(), id="status") yield Footer() @@ -524,7 +502,6 @@ def watch_step_idx(self, _old: int, _new: int) -> None: self._push_data() def watch_var_name(self, _old: str, _new: str) -> None: - self._vmax_history.clear() # reset sparkline when variable changes self._push_data() def watch_cmap_name(self, _old: str, _new: str) -> None: @@ -541,16 +518,6 @@ def watch_playing(self, _old: bool, new: bool) -> None: self._play_timer.stop() self._play_timer = None - # ------------------------------------------------------------------ - # Message handlers - # ------------------------------------------------------------------ - - def on_mfc_plot_clicked(self, event: MFCPlot.Clicked) -> None: - """Feature 5 — show the data value at the clicked grid coordinate.""" - self.query_one("#status", Static).update( - f" x={event.x_phys:.4f} y={event.y_phys:.4f} val={event.value:.6g}" - ) - # ------------------------------------------------------------------ # Background data loading (Feature 4) # ------------------------------------------------------------------ @@ -560,8 +527,7 @@ def _push_data(self) -> None: """Load the current step/var in a background thread and push to the plot.""" if not self._steps or not self.var_name: return - # Snapshot all reactive state before entering the thread to avoid - # reading stale values if the user changes something mid-load. + # Snapshot all reactive state before entering the thread. step_idx = min(self.step_idx, len(self._steps) - 1) step = self._steps[step_idx] var = self.var_name @@ -623,16 +589,9 @@ def _apply_data( # pylint: disable=too-many-arguments,too-many-positional-argum plot._vmax = None # pylint: disable=protected-access plot.refresh() - # Feature 2 — update the large step counter. + # Update step counter (Feature 2). self.query_one("#step-counter", Digits).update(str(step)) - # Feature 3 — append to sparkline history and refresh. - if data is not None and data.size > 0: - finite = data[np.isfinite(data)] - if finite.size > 0: - self._vmax_history.append(float(finite.max())) - self.query_one("#sparkline", Sparkline).data = list(self._vmax_history) - self.query_one("#status", Static).update(self._status_text()) # ------------------------------------------------------------------ From 1396f68c6e449b9e7a20134efdb220ac5d51d7ca Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 00:09:39 -0500 Subject: [PATCH 079/102] =?UTF-8?q?viz:=20tui=20=E2=80=94=20fix=20click=20?= =?UTF-8?q?via=20@on(Click,'#plot')=20+=20screen=20coords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- toolchain/mfc/viz/tui.py | 115 ++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index b2edbab5f9..cb6480a89c 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -24,6 +24,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical +from textual.events import Click from textual.reactive import reactive from textual.widgets import ( Digits, Footer, Header, Label, ListItem, ListView, Static, @@ -129,74 +130,32 @@ def _zoom_around( # pylint: disable=too-many-locals return self._zoom = (new_x0, new_x1, new_y0, new_y1) - def _cursor_frac(self, event) -> Tuple[float, float]: - """Map a mouse event to [0,1]² fractions within the current heatmap view. + # ------------------------------------------------------------------ + # Mouse scroll handlers — zoom (Feature 6) + # ------------------------------------------------------------------ - Uses ``event.get_content_offset_capture(self)`` so the result is valid even - when the cursor sits on the border. The display is y-flipped (row 0 = y_max), - so ``cy_frac=0`` maps to the top of the visible y range. - """ + def _scroll_zoom(self, event, factor: float) -> None: + """Zoom by *factor* centred on the scroll cursor position.""" + if self._data is None or self._ndim != 2: + return + # get_content_offset_capture never returns None — safe at border too. offset = event.get_content_offset_capture(self) col = offset.x - row = offset.y - _HEADER_ROWS # skip header row inside content area + row = offset.y - _HEADER_ROWS w = max(self._last_w_map, 1) h = max(self._last_h_plot, 1) cx_frac = max(0.0, min(1.0, col / (w - 1) if w > 1 else 0.5)) - # Row 0 = top = y_max → cy_frac = 1; row h-1 = bottom = y_min → 0. + # Row 0 = top = y_max → cy_frac = 1 in zoom space; row h-1 = 0. cy_frac = max(0.0, min(1.0, 1.0 - row / (h - 1) if h > 1 else 0.5)) - return cx_frac, cy_frac - - # ------------------------------------------------------------------ - # Mouse event handlers (Features 5 & 6) - # ------------------------------------------------------------------ - - def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] - if self._data is None or self._ndim != 2: - return - cx_frac, cy_frac = self._cursor_frac(event) - self._zoom_around(cx_frac, cy_frac, factor=0.75) + self._zoom_around(cx_frac, cy_frac, factor=factor) event.stop() self.refresh() - def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] - if self._data is None or self._ndim != 2: - return - cx_frac, cy_frac = self._cursor_frac(event) - self._zoom_around(cx_frac, cy_frac, factor=1.0 / 0.75) - event.stop() - self.refresh() + def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] + self._scroll_zoom(event, factor=0.75) - def on_click(self, event) -> None: # type: ignore[override] # pylint: disable=too-many-locals - """Feature 5 — show the data value at the clicked grid cell.""" - if self._data is None or self._ndim != 2: - return - if self._last_w_map == 0 or self._last_ix is None or self._last_iy is None: - return - # get_content_offset returns None if the click is on the border/padding. - offset = event.get_content_offset(self) - if offset is None: - return - col = offset.x - row = offset.y - _HEADER_ROWS # skip header row - # Clamp to heatmap area (click on colorbar or footer is fine — just clamp). - col = max(0, min(col, self._last_w_map - 1)) - row = max(0, min(row, self._last_h_plot - 1)) - n_ix = len(self._last_ix) - n_iy = len(self._last_iy) - ix_pos = int(np.round(col * (n_ix - 1) / max(self._last_w_map - 1, 1))) - # Display is y-flipped: row 0 = top = last_iy[-1] (y_max). - iy_pos_flip = int(np.round(row * (n_iy - 1) / max(self._last_h_plot - 1, 1))) - iy_pos = n_iy - 1 - iy_pos_flip - xi = int(self._last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object - yi = int(self._last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object - y_cc_click = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) - x_val = float(self._x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object - y_val = float(y_cc_click[yi]) - val = float(self._data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object - # Update status bar directly — no message routing needed. - self.app.query_one("#status", Static).update( - f" x={x_val:.4f} y={y_val:.4f} val={val:.6g}" - ) + def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] + self._scroll_zoom(event, factor=1.0 / 0.75) def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data @@ -518,6 +477,48 @@ def watch_playing(self, _old: bool, new: bool) -> None: self._play_timer.stop() self._play_timer = None + # ------------------------------------------------------------------ + # Click handler — show value at cursor (Feature 5) + # ------------------------------------------------------------------ + + @on(Click, "#plot") + def on_plot_click(self, event: Click) -> None: # pylint: disable=too-many-locals + """Feature 5 — show the data value at the clicked 2D heatmap cell. + + Uses screen coordinates (``event.screen_x/y``) and ``plot.content_region`` + (both in screen space) to avoid any ambiguity about border offsets. + """ + plot = self.query_one("#plot", MFCPlot) + if plot._data is None or plot._ndim != 2: # pylint: disable=protected-access + return + if plot._last_w_map == 0 or plot._last_ix is None or plot._last_iy is None: # pylint: disable=protected-access + return + # content_region is the widget's inner area in screen coordinates. + cr = plot.content_region + col = event.screen_x - cr.x + row = event.screen_y - cr.y - _HEADER_ROWS + # Clamp — clicking on the colorbar / footer still shows a value. + col = max(0, min(col, plot._last_w_map - 1)) # pylint: disable=protected-access + row = max(0, min(row, plot._last_h_plot - 1)) # pylint: disable=protected-access + last_ix = plot._last_ix # pylint: disable=protected-access + last_iy = plot._last_iy # pylint: disable=protected-access + n_ix = len(last_ix) + n_iy = len(last_iy) + ix_pos = int(np.round(col * (n_ix - 1) / max(plot._last_w_map - 1, 1))) # pylint: disable=protected-access + # Display is y-flipped: row 0 = top = y_max. + iy_pos = n_iy - 1 - int(np.round(row * (n_iy - 1) / max(plot._last_h_plot - 1, 1))) # pylint: disable=protected-access + xi = int(last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object + yi = int(last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object + x_cc = plot._x_cc # pylint: disable=protected-access + y_cc = plot._y_cc if plot._y_cc is not None else np.array([0.0, 1.0]) # pylint: disable=protected-access + data = plot._data # pylint: disable=protected-access + x_val = float(x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + y_val = float(y_cc[yi]) + val = float(data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + self.query_one("#status", Static).update( + f" x={x_val:.4f} y={y_val:.4f} val={val:.6g}" + ) + # ------------------------------------------------------------------ # Background data loading (Feature 4) # ------------------------------------------------------------------ From 39bc1859fd157c606e9b8bc6dc2119e75437db69 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 01:13:05 -0500 Subject: [PATCH 080/102] =?UTF-8?q?viz:=20tui=20=E2=80=94=20fix=20click-to?= =?UTF-8?q?-value:=20on=5Fmouse=5Fup,=20ALLOW=5FSELECT=3DFalse,=20visible?= =?UTF-8?q?=20status=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs prevented the click-to-show-value feature from working in iTerm2: 1. Used on_click which relies on Click synthesis from MouseDown+MouseUp; text-selection logic (ALLOW_SELECT=True) intercepted the sequence. Switched to on_mouse_up (button==1 only) which Screen._forward_event delivers directly. 2. Textual maps MFCPlot to 'mfcplot' for handler names, so on_mfc_plot_clicked was silently never matched. Fixed to on_mfcplot_clicked. 3. #status (dock: bottom) was covered by Footer (also dock: bottom). Removed dock: bottom so #status sits between #content and Footer. Co-Authored-By: Claude Sonnet 4.6 --- toolchain/mfc/viz/tui.py | 90 +++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index cb6480a89c..6252b16657 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -24,7 +24,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical -from textual.events import Click +from textual.message import Message from textual.reactive import reactive from textual.widgets import ( Digits, Footer, Header, Label, ListItem, ListView, Static, @@ -68,6 +68,18 @@ class MFCPlot(PlotextPlot): # pylint: disable=too-many-instance-attributes,too- """Plotext plot widget. Caller sets ._x_cc / ._y_cc / ._data / ._ndim / ._varname / ._step before calling .refresh().""" + # Disable text-selection so Textual doesn't intercept left-button events + # before they bubble to our on_mouse_up handler. + ALLOW_SELECT = False + + class Clicked(Message): + """Posted when the user clicks a heatmap cell (Feature 5).""" + def __init__(self, x_val: float, y_val: float, val: float) -> None: + self.x_val = x_val + self.y_val = y_val + self.val = val + super().__init__() + DEFAULT_CSS = """ MFCPlot { border: solid $accent; @@ -157,6 +169,35 @@ def on_mouse_scroll_up(self, event) -> None: # type: ignore[override] def on_mouse_scroll_down(self, event) -> None: # type: ignore[override] self._scroll_zoom(event, factor=1.0 / 0.75) + def on_mouse_up(self, event) -> None: # pylint: disable=too-many-locals + """Feature 5 — post Clicked message with the data value at the heatmap cell.""" + if event.button != 1: + return + if self._data is None or self._ndim != 2: + return + if self._last_w_map == 0 or self._last_ix is None or self._last_iy is None: + return + # Offset relative to this widget's content area (inside the border). + offset = event.get_content_offset_capture(self) + col = offset.x + row = offset.y - _HEADER_ROWS + col = max(0, min(col, self._last_w_map - 1)) + row = max(0, min(row, self._last_h_plot - 1)) + n_ix = len(self._last_ix) + n_iy = len(self._last_iy) + ix_pos = int(np.round(col * (n_ix - 1) / max(self._last_w_map - 1, 1))) + # Display is y-flipped: row 0 = top = y_max. + iy_pos = n_iy - 1 - int(np.round(row * (n_iy - 1) / max(self._last_h_plot - 1, 1))) + xi = int(self._last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object + yi = int(self._last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object + x_cc = self._x_cc + y_cc = self._y_cc if self._y_cc is not None else np.array([0.0, 1.0]) + data = self._data + x_val = float(x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + y_val = float(y_cc[yi]) + val = float(data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object + self.post_message(MFCPlot.Clicked(x_val, y_val, val)) + def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many-statements data = self._data x_cc = self._x_cc @@ -383,7 +424,6 @@ class MFCTuiApp(App): # pylint: disable=too-many-instance-attributes } #status { - dock: bottom; height: 1; background: $panel; color: $text-muted; @@ -429,6 +469,7 @@ def __init__( # pylint: disable=too-many-arguments,too-many-positional-argument self._init_var = init_var or (varnames[0] if varnames else "") self._frozen_range: Optional[Tuple[float, float]] = None self._play_timer = None + self._click_info: str = "" # last clicked cell; included in status bar def compose(self) -> ComposeResult: yield Header(show_clock=False) @@ -458,6 +499,7 @@ def on_mount(self) -> None: # ------------------------------------------------------------------ def watch_step_idx(self, _old: int, _new: int) -> None: + self._click_info = "" self._push_data() def watch_var_name(self, _old: str, _new: str) -> None: @@ -478,46 +520,15 @@ def watch_playing(self, _old: bool, new: bool) -> None: self._play_timer = None # ------------------------------------------------------------------ - # Click handler — show value at cursor (Feature 5) + # MFCPlot.Clicked handler — update status bar (Feature 5) # ------------------------------------------------------------------ - @on(Click, "#plot") - def on_plot_click(self, event: Click) -> None: # pylint: disable=too-many-locals - """Feature 5 — show the data value at the clicked 2D heatmap cell. - - Uses screen coordinates (``event.screen_x/y``) and ``plot.content_region`` - (both in screen space) to avoid any ambiguity about border offsets. - """ - plot = self.query_one("#plot", MFCPlot) - if plot._data is None or plot._ndim != 2: # pylint: disable=protected-access - return - if plot._last_w_map == 0 or plot._last_ix is None or plot._last_iy is None: # pylint: disable=protected-access - return - # content_region is the widget's inner area in screen coordinates. - cr = plot.content_region - col = event.screen_x - cr.x - row = event.screen_y - cr.y - _HEADER_ROWS - # Clamp — clicking on the colorbar / footer still shows a value. - col = max(0, min(col, plot._last_w_map - 1)) # pylint: disable=protected-access - row = max(0, min(row, plot._last_h_plot - 1)) # pylint: disable=protected-access - last_ix = plot._last_ix # pylint: disable=protected-access - last_iy = plot._last_iy # pylint: disable=protected-access - n_ix = len(last_ix) - n_iy = len(last_iy) - ix_pos = int(np.round(col * (n_ix - 1) / max(plot._last_w_map - 1, 1))) # pylint: disable=protected-access - # Display is y-flipped: row 0 = top = y_max. - iy_pos = n_iy - 1 - int(np.round(row * (n_iy - 1) / max(plot._last_h_plot - 1, 1))) # pylint: disable=protected-access - xi = int(last_ix[np.clip(ix_pos, 0, n_ix - 1)]) # pylint: disable=unsubscriptable-object - yi = int(last_iy[np.clip(iy_pos, 0, n_iy - 1)]) # pylint: disable=unsubscriptable-object - x_cc = plot._x_cc # pylint: disable=protected-access - y_cc = plot._y_cc if plot._y_cc is not None else np.array([0.0, 1.0]) # pylint: disable=protected-access - data = plot._data # pylint: disable=protected-access - x_val = float(x_cc[xi]) # type: ignore[index] # pylint: disable=unsubscriptable-object - y_val = float(y_cc[yi]) - val = float(data[xi, yi]) # type: ignore[index] # pylint: disable=unsubscriptable-object - self.query_one("#status", Static).update( - f" x={x_val:.4f} y={y_val:.4f} val={val:.6g}" + def on_mfcplot_clicked(self, event: MFCPlot.Clicked) -> None: + """Receive the heatmap click message and update the status bar.""" + self._click_info = ( + f" │ x={event.x_val:.4f} y={event.y_val:.4f} val={event.val:.6g}" ) + self.query_one("#status", Static).update(self._status_text()) # ------------------------------------------------------------------ # Background data loading (Feature 4) @@ -615,6 +626,7 @@ def _status_text(self) -> str: f" var: {self.var_name}" f" cmap: {self.cmap_name}" f"{flag_str}" + f"{self._click_info}" ) # ------------------------------------------------------------------ From ed7e39da6cbb6fde73414e70442a42f1eb3d8157 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 01:20:20 -0500 Subject: [PATCH 081/102] viz: dynamic colorbar label width to prevent truncation of long values --- toolchain/mfc/viz/tui.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index 6252b16657..d561cf7ef1 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -240,8 +240,27 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- w_plot = max(self.size.width - 2, 4) h_plot_avail = max(self.size.height - 4, 4) # -2 border, -2 header+footer - # Right side: gap + gradient strip + value labels - _CB_GAP, _CB_W, _CB_LBL = 1, 2, 9 + # Right side: gap + gradient strip + value labels. + # Compute label width dynamically so long labels (e.g. "-2.26e-05") + # are never truncated. Use the full-data range as a conservative + # estimate — it is always ≥ the zoomed-window range. + if self._vmin is not None and self._vmax is not None: + _vmin_est, _vmax_est = self._vmin, self._vmax + elif self._data is not None: + _finite = self._data[np.isfinite(self._data)] + _vmin_est = float(_finite.min()) if _finite.size else 0.0 + _vmax_est = float(_finite.max()) if _finite.size else 1.0 + else: + _vmin_est, _vmax_est = 0.0, 1.0 + if _vmax_est <= _vmin_est: + _vmax_est = _vmin_est + 1e-10 + _mid_est = (_vmin_est + _vmax_est) / 2 + _CB_LBL = max( + len(f" {_vmax_est:.3g}"), + len(f" {_vmin_est:.3g}"), + len(f" {_mid_est:.3g}"), + ) + _CB_GAP, _CB_W = 1, 2 w_map_avail = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) # Preserve the physical x/y aspect ratio. From 3c0a82a19851d4d804ebb93bdcf2b385307d9141 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 01:23:52 -0500 Subject: [PATCH 082/102] viz: fix colorbar label width to 11 chars for negative scientific notation --- toolchain/mfc/viz/tui.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index d561cf7ef1..dcc3545693 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -241,26 +241,8 @@ def render(self): # pylint: disable=too-many-branches,too-many-locals,too-many- h_plot_avail = max(self.size.height - 4, 4) # -2 border, -2 header+footer # Right side: gap + gradient strip + value labels. - # Compute label width dynamically so long labels (e.g. "-2.26e-05") - # are never truncated. Use the full-data range as a conservative - # estimate — it is always ≥ the zoomed-window range. - if self._vmin is not None and self._vmax is not None: - _vmin_est, _vmax_est = self._vmin, self._vmax - elif self._data is not None: - _finite = self._data[np.isfinite(self._data)] - _vmin_est = float(_finite.min()) if _finite.size else 0.0 - _vmax_est = float(_finite.max()) if _finite.size else 1.0 - else: - _vmin_est, _vmax_est = 0.0, 1.0 - if _vmax_est <= _vmin_est: - _vmax_est = _vmin_est + 1e-10 - _mid_est = (_vmin_est + _vmax_est) / 2 - _CB_LBL = max( - len(f" {_vmax_est:.3g}"), - len(f" {_vmin_est:.3g}"), - len(f" {_mid_est:.3g}"), - ) - _CB_GAP, _CB_W = 1, 2 + # 11 chars fits negative scientific notation e.g. " -2.26e-05". + _CB_GAP, _CB_W, _CB_LBL = 1, 2, 11 w_map_avail = max(w_plot - _CB_GAP - _CB_W - _CB_LBL, 4) # Preserve the physical x/y aspect ratio. From 87602fd56f2c40fbf948841f7564bb68ef585246 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 10:14:00 -0500 Subject: [PATCH 083/102] viz: fix silent bubble warning, slice-index/value guard, binary seek optimization, _is_1d robustness --- toolchain/mfc/viz/reader.py | 44 +++++++++++++++++++++++++++++++------ toolchain/mfc/viz/viz.py | 6 +++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 3c56eca3b8..7abf0dc4e8 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -170,14 +170,41 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa data_size = (m + 1) * max(n + 1, 1) * max(p + 1, 1) for _ in range(dbvars): - var_raw = _read_record_endian(f, endian) - varname = var_raw[:NAME_LEN].decode('ascii', errors='replace').strip() - + # Read leading record-length marker and variable name only. + # If this variable is filtered out we can seek past the data + # instead of reading the full payload into memory. + raw_len = f.read(4) + if len(raw_len) < 4: + raise EOFError("Unexpected end of file reading variable record marker") + rec_len = struct.unpack(f'{endian}i', raw_len)[0] + if rec_len < NAME_LEN: + raise ValueError(f"Variable record too short: {rec_len} bytes") + + name_raw = f.read(NAME_LEN) + if len(name_raw) < NAME_LEN: + raise EOFError("Unexpected end of file reading variable name") + varname = name_raw.decode('ascii', errors='replace').strip() + + data_bytes = rec_len - NAME_LEN if var_filter is not None and varname != var_filter: + # Seek past remaining data + trailing record-length marker. + f.seek(data_bytes + 4, 1) continue + data_raw = f.read(data_bytes) + if len(data_raw) < data_bytes: + raise EOFError("Unexpected end of file reading variable data") + trail = f.read(4) + if len(trail) < 4: + raise EOFError("Unexpected end of file reading trailing variable record marker") + trail_len = struct.unpack(f'{endian}i', trail)[0] + if trail_len != rec_len: + raise ValueError( + f"Fortran record marker mismatch for '{varname}': " + f"leading={rec_len}, trailing={trail_len}" + ) + # Auto-detect variable data precision from record size - data_bytes = len(var_raw) - NAME_LEN if data_bytes == data_size * 8: var_dtype = np.dtype(f'{endian}f8') elif data_bytes == data_size * 4: @@ -189,7 +216,7 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa f"{data_bytes} bytes for {data_size} values ({var_bpv:.1f} bytes/value)" ) - data = np.frombuffer(var_raw[NAME_LEN:], dtype=var_dtype).astype(np.float64) + data = np.frombuffer(data_raw, dtype=var_dtype).astype(np.float64) # Reshape for multi-dimensional data (Fortran column-major order) if p > 0: @@ -277,9 +304,12 @@ def _discover_processors(case_dir: str, fmt: str) -> List[int]: def _is_1d(case_dir: str) -> bool: - """Check if the output is 1D (binary/root/ directory exists and contains .dat files).""" + """Check if the output is 1D (binary/root/ exists with .dat files, no p0/ present).""" root = os.path.join(case_dir, 'binary', 'root') - return os.path.isdir(root) and any(f.endswith('.dat') for f in os.listdir(root)) + p0 = os.path.join(case_dir, 'binary', 'p0') + return (os.path.isdir(root) + and any(f.endswith('.dat') for f in os.listdir(root)) + and not os.path.isdir(p0)) def assemble_from_proc_data( # pylint: disable=too-many-locals,too-many-statements diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 7957a2c286..1b7cd9e0aa 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -310,6 +310,8 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc render_opts['vmax'] = float(ARG('vmax')) if ARG('log_scale'): render_opts['log_scale'] = True + if ARG('slice_index') is not None and ARG('slice_value') is not None: + raise MFCException("--slice-index and --slice-value are mutually exclusive.") if ARG('slice_index') is not None: render_opts['slice_index'] = int(ARG('slice_index')) if ARG('slice_value') is not None: @@ -460,8 +462,8 @@ def read_step(step): if bubble_func is not None: try: step_opts = dict(render_opts, bubbles=bubble_func(step)) - except Exception: # pylint: disable=broad-except - pass + except Exception as exc: # pylint: disable=broad-except + cons.print(f"[yellow]Warning:[/yellow] Skipping bubble overlay for step {step}: {exc}") if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, From 28200474c82919ea3fe616d966eb27b16c4e7c9d Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 10:34:51 -0500 Subject: [PATCH 084/102] viz: warn on dual format dirs, improve step-mismatch error, restore analyze.py --- examples/nD_perfect_reactor/README.md | 15 ++- examples/nD_perfect_reactor/analyze.py | 153 +++++++++++++++++++++++++ toolchain/mfc/viz/reader.py | 18 ++- toolchain/mfc/viz/test_viz.py | 3 +- toolchain/mfc/viz/viz.py | 38 ++++-- 5 files changed, 212 insertions(+), 15 deletions(-) create mode 100644 examples/nD_perfect_reactor/analyze.py diff --git a/examples/nD_perfect_reactor/README.md b/examples/nD_perfect_reactor/README.md index 869a411f5a..8fe5807b42 100644 --- a/examples/nD_perfect_reactor/README.md +++ b/examples/nD_perfect_reactor/README.md @@ -3,4 +3,17 @@ Reference: > G. B. Skinner and G. H. Ringrose, “Ignition Delays of a Hydrogen—Oxygen—Argon Mixture at Relatively Low Temperatures”, J. Chem. Phys., vol. 42, no. 6, pp. 2190–2192, Mar. 1965. Accessed: Oct. 13, 2024. - + + +## Validation + +After running the simulation, compare MFC species mass fractions and induction +time against a Cantera 0-D ideal-gas reactor reference: + +```bash +python analyze.py +``` + +This reads the Silo output, runs an equivalent Cantera reactor, prints the +induction times (Skinner et al. / Cantera / (Che)MFC), and saves `plots.png`. +All dependencies are installed automatically by the MFC toolchain. diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py new file mode 100644 index 0000000000..98a4060f4d --- /dev/null +++ b/examples/nD_perfect_reactor/analyze.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +""" +Cantera vs. (Che)MFC validation for the perfectly stirred reactor example. + +Reads MFC post-process Silo output, compares species mass fractions and +induction time against a Cantera 0-D ideal-gas reactor reference, and +reproduces the Skinner & Ringrose (1965) induction-time comparison. + +Run from this directory after post_process has been executed: + python analyze.py + +All dependencies (cantera, matplotlib, tqdm, h5py) are installed automatically +by the MFC toolchain. + +Variable names in MFC's Silo output follow the convention: prim.1 = rho, +prim.{5+i} = Y_i (species mass fraction). Run `./mfc.sh viz . --list-vars` +to verify the names present in your output files. +""" +from case import dt, Tend, SAVE_COUNT, sol +from mfc.viz.silo_reader import assemble_silo +from mfc.viz.reader import discover_timesteps +from tqdm import tqdm +import cantera as ct +import matplotlib.pyplot as plt +import sys + +import matplotlib +matplotlib.use('Agg') + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +CASE_DIR = '.' +Y_MAJORS = {'H', 'O', 'OH', 'HO2'} +Y_MINORS = {'H2O', 'H2O2'} +Y_VARS = Y_MAJORS | Y_MINORS +oh_idx = sol.species_index('OH') +skinner_induction_time = 0.052e-3 # Skinner & Ringrose (1965) + + +def _species_var(name): + """MFC Silo variable name for species mass fraction Y_{name}.""" + return f'prim.{5 + sol.species_index(name)}' + + +# --------------------------------------------------------------------------- +# Load MFC output +# --------------------------------------------------------------------------- +steps = discover_timesteps(CASE_DIR, 'silo') +if not steps: + sys.exit('No silo timesteps found — did you run post_process?') + +mfc_times = [] +mfc_rhos = [] +mfc_Ys = {y: [] for y in Y_VARS} + +for step in tqdm(steps, desc='Loading MFC output'): + assembled = assemble_silo(CASE_DIR, step) + # Perfectly stirred reactor: spatially uniform — take the midpoint cell. + mid = assembled.x_cc.size // 2 + mfc_times.append(step * dt) + mfc_rhos.append(float(assembled.variables['prim.1'][mid])) + for y in Y_VARS: + mfc_Ys[y].append(float(assembled.variables[_species_var(y)][mid])) + +# --------------------------------------------------------------------------- +# Cantera 0-D reference +# --------------------------------------------------------------------------- +time_save = Tend / SAVE_COUNT + + +def generate_ct_saves(): + reactor = ct.IdealGasReactor(sol) + net = ct.ReactorNet([reactor]) + ct_time = 0.0 + ct_ts = [0.0] + ct_Ys = [reactor.thermo.Y.copy()] + ct_rhos = [reactor.thermo.density] + while ct_time < Tend: + net.advance(ct_time + time_save) + ct_time += time_save + ct_ts.append(ct_time) + ct_Ys.append(reactor.thermo.Y.copy()) + ct_rhos.append(reactor.thermo.density) + return ct_ts, ct_Ys, ct_rhos + + +ct_ts, ct_Ys, ct_rhos = generate_ct_saves() + +# --------------------------------------------------------------------------- +# Induction time: first step where [OH] molar concentration >= 1e-6 mol/m^3 +# --------------------------------------------------------------------------- + + +def find_induction_time(ts, Ys_OH, rhos): + for t, y_oh, rho in zip(ts, Ys_OH, rhos): + if y_oh * rho / sol.molecular_weights[oh_idx] >= 1e-6: + return t + return None + + +ct_induction = find_induction_time(ct_ts, [Y[oh_idx] for Y in ct_Ys], ct_rhos) +mfc_induction = find_induction_time(mfc_times, mfc_Ys['OH'], mfc_rhos) + +print('Induction Times ([OH] >= 1e-6 mol/m^3):') +print(f' Skinner et al.: {skinner_induction_time:.3e} s') +print(f' Cantera: {ct_induction:.3e} s' + if ct_induction is not None else ' Cantera: not reached') +print(f' (Che)MFC: {mfc_induction:.3e} s' + if mfc_induction is not None else ' (Che)MFC: not reached') + +# --------------------------------------------------------------------------- +# Plot +# --------------------------------------------------------------------------- +fig, axes = plt.subplots(1, 2, figsize=(12, 6)) +_colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] +_color = {y: _colors[i % len(_colors)] for i, y in enumerate(sorted(Y_VARS))} + +for ax, group in zip(axes, [sorted(Y_MAJORS), sorted(Y_MINORS)]): + for y in group: + ax.plot(mfc_times, mfc_Ys[y], color=_color[y], label=f'${y}$') + ax.plot(ct_ts, [Y[sol.species_index(y)] for Y in ct_Ys], + linestyle=':', color=_color[y], alpha=0.6, label=f'{y} (Cantera)') + ax.set_xlabel('Time (s)') + ax.set_ylabel('$Y_k$') + ax.set_xscale('log') + ax.set_yscale('log') + ax.legend(title='Species', ncol=2) + +# Mark induction times on both panels +induction_lines = [ + (skinner_induction_time, 'r', '-', 'Skinner et al.'), + (mfc_induction, 'b', '-.', '(Che)MFC'), + (ct_induction, 'g', ':', 'Cantera'), +] +for ax in axes: + for t, c, ls, _ in induction_lines: + if t is not None: + ax.axvline(t, color=c, linestyle=ls) + +axes[0].legend( + handles=[plt.Line2D([0], [0], color=c, linestyle=ls) + for t, c, ls, _lbl in induction_lines if t is not None], + labels=[lbl for t, _c, _ls, lbl in induction_lines if t is not None], + title='Induction Times', + loc='lower right', +) + +plt.tight_layout() +plt.savefig('plots.png', dpi=300) +plt.close() +print('Saved: plots.png') diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 7abf0dc4e8..8764e5f4ae 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -230,10 +230,22 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa def discover_format(case_dir: str) -> str: - """Detect whether case has binary or silo_hdf5 output.""" - if os.path.isdir(os.path.join(case_dir, 'binary')): + """Detect whether case has binary or silo_hdf5 output. + + Returns 'binary' or 'silo'. Raises FileNotFoundError if neither exists. + When both exist, emits a warnings.warn so callers can surface it. + """ + has_binary = os.path.isdir(os.path.join(case_dir, 'binary')) + has_silo = os.path.isdir(os.path.join(case_dir, 'silo_hdf5')) + if has_binary and has_silo: + warnings.warn( + "Both binary/ and silo_hdf5/ found; using binary. " + "Pass --format silo to override.", + stacklevel=2, + ) + if has_binary: return 'binary' - if os.path.isdir(os.path.join(case_dir, 'silo_hdf5')): + if has_silo: return 'silo' raise FileNotFoundError( f"No 'binary/' or 'silo_hdf5/' directory found in {case_dir}. " diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index b321f3ee09..67da4d79c7 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -32,7 +32,8 @@ class TestParseSteps(unittest.TestCase): def _parse(self, arg, available): from .viz import _parse_steps - return _parse_steps(arg, available) + matched, _ = _parse_steps(arg, available) + return matched def test_all_keyword(self): """'all' returns every available step.""" diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 1b7cd9e0aa..745b7899a4 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -10,6 +10,7 @@ import shutil import subprocess import sys +import warnings from mfc.state import ARG from mfc.common import MFC_ROOT_DIR, MFCException @@ -140,7 +141,11 @@ def _parse_step_list(s, available_steps): def _parse_steps(step_arg, available_steps): """ - Parse the --step argument into a list of timestep integers. + Parse the --step argument into (matched_steps, n_requested). + + matched_steps — list of ints present in available_steps + n_requested — how many steps were in the raw request before filtering + (0 when the format doesn't generate a finite count, e.g. 'all') Formats: - Single int: "1000" @@ -151,15 +156,23 @@ def _parse_steps(step_arg, available_steps): - "all": all available timesteps """ if step_arg is None or step_arg == 'all': - return available_steps + return available_steps, 0 if step_arg == 'last': - return [available_steps[-1]] if available_steps else [] + return ([available_steps[-1]] if available_steps else []), 0 s = str(step_arg) if ',' in s: - return _parse_step_list(s, available_steps) + matched = _parse_step_list(s, available_steps) + # n_requested: count of explicit values (ellipsis form expands to a range) + parts = [p.strip() for p in s.split(',')] + if '...' in parts: + # approximate from the parsed result + unmatched + n_req = len(matched) # conservative; exact count needs re-parsing + else: + n_req = len(parts) + return matched, n_req try: if ':' in s: @@ -168,7 +181,7 @@ def _parse_steps(step_arg, available_steps): end = int(parts[1]) stride = int(parts[2]) if len(parts) > 2 else 1 requested = list(range(start, end + 1, stride)) - return [t for t in requested if t in set(available_steps)] + return [t for t in requested if t in set(available_steps)], len(requested) single = int(s) except ValueError as exc: @@ -180,8 +193,8 @@ def _parse_steps(step_arg, available_steps): ) from exc if available_steps and single not in set(available_steps): - return [] - return [single] + return [], 1 + return [single], 1 def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches @@ -208,7 +221,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc fmt = fmt_arg else: try: - fmt = discover_format(case_dir) + with warnings.catch_warnings(record=True) as _w: + warnings.simplefilter("always") + fmt = discover_format(case_dir) + for _warning in _w: + cons.print(f"[yellow]Warning:[/yellow] {_warning.message}") except FileNotFoundError as exc: msg = str(exc) if os.path.isfile(os.path.join(case_dir, 'case.py')): @@ -292,10 +309,11 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc f"No timesteps found in '{case_dir}' ({fmt} format). " "Ensure post_process has been run and produced output files.") - requested_steps = _parse_steps(step_arg, steps) + requested_steps, n_requested = _parse_steps(step_arg, steps) if not requested_steps: + detail = (f" ({n_requested} requested, 0 matched)" if n_requested > 1 else "") raise MFCException( - f"No matching timesteps for --step {step_arg!r}. " + f"No matching timesteps for --step {step_arg!r}{detail}. " f"Available steps: {_steps_hint(steps)}") # Collect rendering options From b21a24cd01787d0b3417fd2b93bb4a35ad16fc47 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 10:46:29 -0500 Subject: [PATCH 085/102] viz: fix analyze.py variable names and Cantera 3.2 deprecation warnings --- examples/nD_perfect_reactor/analyze.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py index 98a4060f4d..a1b6692957 100644 --- a/examples/nD_perfect_reactor/analyze.py +++ b/examples/nD_perfect_reactor/analyze.py @@ -12,9 +12,8 @@ All dependencies (cantera, matplotlib, tqdm, h5py) are installed automatically by the MFC toolchain. -Variable names in MFC's Silo output follow the convention: prim.1 = rho, -prim.{5+i} = Y_i (species mass fraction). Run `./mfc.sh viz . --list-vars` -to verify the names present in your output files. +MFC writes species as Y_{name} (e.g. Y_OH) and density as alpha_rho1. +Run `./mfc.sh viz . --list-vars` to verify variable names in your output. """ from case import dt, Tend, SAVE_COUNT, sol from mfc.viz.silo_reader import assemble_silo @@ -39,11 +38,6 @@ skinner_induction_time = 0.052e-3 # Skinner & Ringrose (1965) -def _species_var(name): - """MFC Silo variable name for species mass fraction Y_{name}.""" - return f'prim.{5 + sol.species_index(name)}' - - # --------------------------------------------------------------------------- # Load MFC output # --------------------------------------------------------------------------- @@ -60,9 +54,10 @@ def _species_var(name): # Perfectly stirred reactor: spatially uniform — take the midpoint cell. mid = assembled.x_cc.size // 2 mfc_times.append(step * dt) - mfc_rhos.append(float(assembled.variables['prim.1'][mid])) + # alpha_rho1 = partial density of fluid 1; equals total density for single-fluid cases. + mfc_rhos.append(float(assembled.variables['alpha_rho1'][mid])) for y in Y_VARS: - mfc_Ys[y].append(float(assembled.variables[_species_var(y)][mid])) + mfc_Ys[y].append(float(assembled.variables[f'Y_{y}'][mid])) # --------------------------------------------------------------------------- # Cantera 0-D reference @@ -71,18 +66,19 @@ def _species_var(name): def generate_ct_saves(): - reactor = ct.IdealGasReactor(sol) + reactor = ct.IdealGasReactor(sol, clone=True) net = ct.ReactorNet([reactor]) + phase = reactor.phase ct_time = 0.0 ct_ts = [0.0] - ct_Ys = [reactor.thermo.Y.copy()] - ct_rhos = [reactor.thermo.density] + ct_Ys = [phase.Y.copy()] + ct_rhos = [phase.density] while ct_time < Tend: net.advance(ct_time + time_save) ct_time += time_save ct_ts.append(ct_time) - ct_Ys.append(reactor.thermo.Y.copy()) - ct_rhos.append(reactor.thermo.density) + ct_Ys.append(phase.Y.copy()) + ct_rhos.append(phase.density) return ct_ts, ct_Ys, ct_rhos From b0acc7c2247f562bb84929abb51082edeba6fb5f Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 10:57:34 -0500 Subject: [PATCH 086/102] viz: fix analyze.py variable names, Cantera deprecation warnings, curly quotes in README --- examples/nD_perfect_reactor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/nD_perfect_reactor/README.md b/examples/nD_perfect_reactor/README.md index 8fe5807b42..a8c8ed40dd 100644 --- a/examples/nD_perfect_reactor/README.md +++ b/examples/nD_perfect_reactor/README.md @@ -3,7 +3,7 @@ Reference: > G. B. Skinner and G. H. Ringrose, “Ignition Delays of a Hydrogen—Oxygen—Argon Mixture at Relatively Low Temperatures”, J. Chem. Phys., vol. 42, no. 6, pp. 2190–2192, Mar. 1965. Accessed: Oct. 13, 2024. - + ## Validation From dcd04ef992772c3d54c4e37cd45a40ba6f581f76 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 11:39:53 -0500 Subject: [PATCH 087/102] viz: fix ellipsis n_requested count; add 2-rank ghost-cell assembly fixture --- .../fixtures/1d_binary_2rank/binary/p0/0.dat | Bin 0 -> 420 bytes .../fixtures/1d_binary_2rank/binary/p1/0.dat | Bin 0 -> 348 bytes toolchain/mfc/viz/test_viz.py | 47 ++++++++++++++++++ toolchain/mfc/viz/viz.py | 11 +++- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p0/0.dat create mode 100644 toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p1/0.dat diff --git a/toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p0/0.dat b/toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p0/0.dat new file mode 100644 index 0000000000000000000000000000000000000000..90fa8cf6d4c62be393afb3359313b2deaabea7f4 GIT binary patch literal 420 zcmWe&U|`?^Vi;foG6aA)0VV*aH`qg%2cYx`D18A+UxCs$p!6Lm{Qycog3?c*^fP;q z9bG_NP?TD%KoWqsWC7Hq9Z>oRls*HcFG1;RQ2G{>z6YfrLg~j)`l&t0H6UD+kx!C) Q6civX`T#YFdNjy206L&HGynhq literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p1/0.dat b/toolchain/mfc/viz/fixtures/1d_binary_2rank/binary/p1/0.dat new file mode 100644 index 0000000000000000000000000000000000000000..bbaa98bd935a5b89f2d067369a1a5dc898660b9b GIT binary patch literal 348 zcmb8o$qj%Y5QSml#VaMy30E62@utCs-S>qO1oh+PrUKfzV%O4C-o{7nk}Ew^{pQfi7qq% literal 0 HcmV?d00001 diff --git a/toolchain/mfc/viz/test_viz.py b/toolchain/mfc/viz/test_viz.py index 67da4d79c7..7fe72096a4 100644 --- a/toolchain/mfc/viz/test_viz.py +++ b/toolchain/mfc/viz/test_viz.py @@ -21,6 +21,7 @@ FIX_2D_SILO = os.path.join(FIXTURES, '2d_silo') FIX_3D_BIN = os.path.join(FIXTURES, '3d_binary') FIX_3D_SILO = os.path.join(FIXTURES, '3d_silo') +FIX_1D_BIN_2RANK = os.path.join(FIXTURES, '1d_binary_2rank') # --------------------------------------------------------------------------- @@ -104,6 +105,14 @@ def test_ellipsis_must_be_second_to_last(self): with self.assertRaises(MFCException): self._parse('0,100,...,500,1000', [0, 100, 500, 1000]) + def test_ellipsis_n_requested_is_expanded_range(self): + """Ellipsis n_requested reflects the expanded range, not the matched count.""" + from .viz import _parse_steps + # Range 0,100,...,1000 expands to 11 steps; only 3 are available. + matched, n_req = _parse_steps('0,100,...,1000', [0, 200, 1000]) + self.assertEqual(n_req, 11) + self.assertEqual(matched, [0, 200, 1000]) + def test_invalid_value(self): """Non-numeric, non-keyword input raises MFCException.""" from mfc.common import MFCException @@ -230,6 +239,44 @@ def test_var_filter(self): self.assertNotIn('vel1', data.variables) +class TestAssembleBinary1DMultiRank(unittest.TestCase): + """Test multi-rank assembly with overlapping ghost cells (1D, 2 ranks).""" + + def test_ndim(self): + """2-rank 1D fixture assembles with ndim=1.""" + from .reader import assemble + data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + self.assertEqual(data.ndim, 1) + + def test_cell_count_after_dedup(self): + """Ghost cell overlap is deduplicated: 16 unique cells from two overlapping ranks.""" + from .reader import assemble + data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + self.assertEqual(len(data.x_cc), 16) + + def test_grid_is_sorted_and_unique(self): + """Assembled global grid is strictly increasing with no duplicates.""" + import numpy as np + from .reader import assemble + data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + diffs = np.diff(data.x_cc) + self.assertTrue(bool(np.all(diffs > 0)), "x_cc is not strictly increasing") + + def test_variable_values_match_position(self): + """pres values (== x_cc position) are placed at the correct global cells.""" + import numpy as np + from .reader import assemble + data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + np.testing.assert_allclose(data.variables['pres'], data.x_cc, atol=1e-10) + + def test_all_vars_present(self): + """Both variables written by both ranks appear in the assembled output.""" + from .reader import assemble + data = assemble(FIX_1D_BIN_2RANK, 0, 'binary') + self.assertIn('pres', data.variables) + self.assertIn('rho', data.variables) + + class TestAssembleBinary2D(unittest.TestCase): """Test binary reader with 2D fixture data.""" diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 745b7899a4..8fc498a899 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -168,8 +168,15 @@ def _parse_steps(step_arg, available_steps): # n_requested: count of explicit values (ellipsis form expands to a range) parts = [p.strip() for p in s.split(',')] if '...' in parts: - # approximate from the parsed result + unmatched - n_req = len(matched) # conservative; exact count needs re-parsing + # Compute the expanded range length independently of filtering. + try: + idx = parts.index('...') + prefix = [int(p) for p in parts[:idx]] + end_val = int(parts[-1]) + stride = prefix[-1] - prefix[-2] + n_req = len(range(prefix[0], end_val + 1, stride)) if stride > 0 else len(matched) + except (ValueError, IndexError): + n_req = len(matched) else: n_req = len(parts) return matched, n_req From 9ac8874fc1e282147ac0118422b653c71bfbcfa8 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 11:56:54 -0500 Subject: [PATCH 088/102] ci: move test_viz from pre-commit hook to CI-only --- .github/workflows/lint-toolchain.yml | 3 +++ .github/workflows/test-toolchain-compat.yml | 3 +++ toolchain/bootstrap/lint.sh | 3 --- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint-toolchain.yml b/.github/workflows/lint-toolchain.yml index 026a36f28e..aff0f2d518 100644 --- a/.github/workflows/lint-toolchain.yml +++ b/.github/workflows/lint-toolchain.yml @@ -25,6 +25,9 @@ jobs: - name: Lint and test the toolchain run: ./mfc.sh lint + - name: Test viz module + run: cd toolchain && python3 -m unittest mfc.viz.test_viz -v + - name: Generate toolchain files run: ./mfc.sh generate diff --git a/.github/workflows/test-toolchain-compat.yml b/.github/workflows/test-toolchain-compat.yml index 1c00ea2845..0f5fd5d20b 100644 --- a/.github/workflows/test-toolchain-compat.yml +++ b/.github/workflows/test-toolchain-compat.yml @@ -23,3 +23,6 @@ jobs: run: ./mfc.sh init - name: Lint and test toolchain run: ./mfc.sh lint + + - name: Test viz module + run: cd toolchain && python3 -m unittest mfc.viz.test_viz -v diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 9fdadc0c7c..65df0187af 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -50,9 +50,6 @@ if [ "$RUN_TESTS" = true ]; then cd "$(pwd)/toolchain" python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v - if [ "$VIZ_LINT" = true ]; then - python3 -m unittest mfc.viz.test_viz -v - fi cd - > /dev/null fi From ed8328ed1bc90d31b369feff29890627ea059040 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 12:25:19 -0500 Subject: [PATCH 089/102] ci: move test_viz from pre-commit hook to CI-only --- .github/workflows/lint-toolchain.yml | 3 --- .github/workflows/test-toolchain-compat.yml | 3 --- toolchain/bootstrap/lint.sh | 7 +++++++ toolchain/bootstrap/precheck.sh | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/lint-toolchain.yml b/.github/workflows/lint-toolchain.yml index aff0f2d518..026a36f28e 100644 --- a/.github/workflows/lint-toolchain.yml +++ b/.github/workflows/lint-toolchain.yml @@ -25,9 +25,6 @@ jobs: - name: Lint and test the toolchain run: ./mfc.sh lint - - name: Test viz module - run: cd toolchain && python3 -m unittest mfc.viz.test_viz -v - - name: Generate toolchain files run: ./mfc.sh generate diff --git a/.github/workflows/test-toolchain-compat.yml b/.github/workflows/test-toolchain-compat.yml index 0f5fd5d20b..1c00ea2845 100644 --- a/.github/workflows/test-toolchain-compat.yml +++ b/.github/workflows/test-toolchain-compat.yml @@ -23,6 +23,3 @@ jobs: run: ./mfc.sh init - name: Lint and test toolchain run: ./mfc.sh lint - - - name: Test viz module - run: cd toolchain && python3 -m unittest mfc.viz.test_viz -v diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 65df0187af..814a3ae626 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -4,11 +4,15 @@ set -o pipefail # Parse arguments RUN_TESTS=true +RUN_VIZ_TESTS=true for arg in "$@"; do case $arg in --no-test) RUN_TESTS=false ;; + --no-viz-test) + RUN_VIZ_TESTS=false + ;; esac done @@ -50,6 +54,9 @@ if [ "$RUN_TESTS" = true ]; then cd "$(pwd)/toolchain" python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v + if [ "$RUN_VIZ_TESTS" = true ] && [ "$VIZ_LINT" = true ]; then + python3 -m unittest mfc.viz.test_viz -v + fi cd - > /dev/null fi diff --git a/toolchain/bootstrap/precheck.sh b/toolchain/bootstrap/precheck.sh index 9274f3abae..7d751b4801 100755 --- a/toolchain/bootstrap/precheck.sh +++ b/toolchain/bootstrap/precheck.sh @@ -92,7 +92,7 @@ fi # 3. Lint toolchain (Python) log "[$CYAN 3/5$COLOR_RESET] Running$MAGENTA toolchain lint$COLOR_RESET..." -if ./mfc.sh lint > /dev/null 2>&1; then +if ./mfc.sh lint --no-viz-test > /dev/null 2>&1; then ok "Toolchain lint passed." else error "Toolchain lint failed. Run$MAGENTA ./mfc.sh lint$COLOR_RESET for details." From a44a898cf5a629806d56e5d62bb3f016e14f88cf Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 12:53:11 -0500 Subject: [PATCH 090/102] viz: fix vmin=0 bug, narrow exception handling, improve MP4 error msg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix interactive 1D y-axis ignoring vmin=0 (falsy check → None check) - Narrow bubble overlay catches from Exception to (OSError, ValueError) - Narrow data-loading catches from Exception to (OSError, ValueError, EOFError) - raise ValueError with descriptive message when MP4 has no rendered frames Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/interactive.py | 12 ++++++------ toolchain/mfc/viz/renderer.py | 14 ++++++++++---- toolchain/mfc/viz/tui.py | 6 +++--- toolchain/mfc/viz/viz.py | 11 +++++++---- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 9f92bdd790..fb4144acb4 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -504,7 +504,7 @@ def _update(var_sel, step, mode, # pylint: disable=too-many-arguments,too-many- selected_var = var_sel or varname try: ad = _load(step, read_func) - except Exception as exc: # pylint: disable=broad-except + except (OSError, ValueError, EOFError) as exc: return no_update, [html.Span(f' Error loading step {step}: {exc}', style={'color': _RED})] if selected_var not in ad.variables: @@ -575,8 +575,8 @@ def _tf(arr): return arr showlegend=False, hovertemplate='x=%{x:.3g}
y=%{y:.3g}
z=%{z:.3g}bubble', )) - except Exception: # pylint: disable=broad-except - pass + except (OSError, ValueError): + pass # bubble overlay is best-effort; skip on read errors # Compute aspect ratio from domain extents so slices (which # have a constant coordinate on one axis) don't collapse that axis. dx = float(ad.x_cc[-1] - ad.x_cc[0]) if len(ad.x_cc) > 1 else 1.0 @@ -624,8 +624,8 @@ def _tf(arr): return arr for b in bubbles ] fig.update_layout(shapes=shapes) - except Exception: # pylint: disable=broad-except - pass + except (OSError, ValueError): + pass # bubble overlay is best-effort; skip on read errors title = f'{selected_var} · step {step}' else: # 1D @@ -637,7 +637,7 @@ def _tf(arr): return arr fig.update_layout( xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER), yaxis=dict(title=cbar_title, color=_TEXT, gridcolor=_OVER, - range=[cmin, cmax] if (vmin_in or vmax_in) else None), + range=[cmin, cmax] if (vmin_in is not None or vmax_in is not None) else None), plot_bgcolor=_BG, ) title = f'{selected_var} · step {step}' diff --git a/toolchain/mfc/viz/renderer.py b/toolchain/mfc/viz/renderer.py index 0eb6faa703..a6f4d4edbd 100644 --- a/toolchain/mfc/viz/renderer.py +++ b/toolchain/mfc/viz/renderer.py @@ -455,8 +455,9 @@ def _cleanup(): if bubble_func is not None: try: frame_opts = dict(opts, bubbles=bubble_func(step)) - except Exception: # pylint: disable=broad-except - pass + except (OSError, ValueError) as exc: + import warnings # pylint: disable=import-outside-toplevel + warnings.warn(f"Skipping bubble overlay for step {step}: {exc}", stacklevel=2) if tiled and assembled.ndim == 1: render_1d_tiled(assembled.x_cc, assembled.variables, @@ -493,10 +494,15 @@ def _cleanup(): # Combine frames into MP4 using imageio + imageio-ffmpeg (bundled ffmpeg) frame_files = sorted(f for f in os.listdir(viz_dir) if f.endswith('.png')) + if not frame_files: + _cleanup() + raise ValueError( + f"No frames were rendered for '{varname}'. " + "The variable may not exist in the loaded timesteps." + ) + success = False try: - if not frame_files: - return False def _to_rgb(arr): """Normalise an image array to uint8 RGB (3-channel). diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index dcc3545693..b53c43a154 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -550,7 +550,7 @@ def _push_data(self) -> None: try: assembled = _load(step, self._read_func) - except Exception as exc: # pylint: disable=broad-except + except (OSError, ValueError, EOFError) as exc: self.call_from_thread( self.query_one("#status", Static).update, f" [red]Error loading step {step}: {exc}[/red]", @@ -566,8 +566,8 @@ def _push_data(self) -> None: if self._bubble_func is not None and self._ndim == 2: try: bubbles = self._bubble_func(step) - except Exception: # pylint: disable=broad-except - pass + except (OSError, ValueError): + pass # bubble overlay is best-effort; skip on read errors self.call_from_thread( self._apply_data, assembled, data, step, var, cmap, log, frozen, bubbles, diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 8fc498a899..f4122a6884 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -452,9 +452,12 @@ def read_step(step): label = 'all' if tiled else varname mp4_path = os.path.join(output_base, f'{label}.mp4') cons.print(f"[bold]Generating MP4:[/bold] {mp4_path} ({n_frames} frames @ {fps:.2g} fps = {n_frames/fps:.1f}s)") - success = render_mp4(varname, requested_steps, mp4_path, - fps=fps, read_func=read_step, - tiled=tiled, bubble_func=bubble_func, **render_opts) + try: + success = render_mp4(varname, requested_steps, mp4_path, + fps=fps, read_func=read_step, + tiled=tiled, bubble_func=bubble_func, **render_opts) + except ValueError as exc: + raise MFCException(str(exc)) from exc if success: cons.print(f"[bold green]Done:[/bold green] {mp4_path}") else: @@ -487,7 +490,7 @@ def read_step(step): if bubble_func is not None: try: step_opts = dict(render_opts, bubbles=bubble_func(step)) - except Exception as exc: # pylint: disable=broad-except + except (OSError, ValueError) as exc: cons.print(f"[yellow]Warning:[/yellow] Skipping bubble overlay for step {step}: {exc}") if tiled and assembled.ndim == 1: From 772641885a43827b370e66697dc673035b8639e3 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 14:27:43 -0500 Subject: [PATCH 091/102] viz: make viz deps non-optional; add half-precision msg; tighten step parsing - Move viz dependencies from [viz] optional extras to main dependencies in pyproject.toml so they are always available - Remove _ensure_viz_deps auto-installer and --no-viz-test flag - Re-export public API (assemble, discover_format, discover_timesteps, assemble_silo) from mfc.viz.__init__ and update analyze.py to use it - Add explicit half-precision (--mixed) detection with clear error message in binary reader instead of generic ValueError - Reject malformed range strings with >3 colon-separated parts Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 3 -- examples/nD_perfect_reactor/analyze.py | 3 +- toolchain/bootstrap/lint.sh | 28 ++------------- toolchain/bootstrap/precheck.sh | 2 +- toolchain/mfc/viz/__init__.py | 3 ++ toolchain/mfc/viz/reader.py | 5 +++ toolchain/mfc/viz/viz.py | 49 +++----------------------- toolchain/pyproject.toml | 17 ++------- 8 files changed, 19 insertions(+), 91 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 6a77f47eff..6339a00d41 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -172,9 +172,6 @@ It supports 1D and 2D data only (use `--interactive` for 3D). | `c` | Cycle colormap | | `q` | Quit | -> [!NOTE] -> The TUI requires the `textual` and `textual-plotext` Python packages, which are part of the optional `[viz]` extras and are auto-installed on the first `./mfc.sh viz --tui` run. - ### Plot styling Axis labels use LaTeX-style math notation — for example, `pres` is labeled as \f$p\f$, `vel1` as \f$u\f$, and `alpha1` as \f$\alpha_1\f$. diff --git a/examples/nD_perfect_reactor/analyze.py b/examples/nD_perfect_reactor/analyze.py index a1b6692957..a7f212fd3f 100644 --- a/examples/nD_perfect_reactor/analyze.py +++ b/examples/nD_perfect_reactor/analyze.py @@ -16,8 +16,7 @@ Run `./mfc.sh viz . --list-vars` to verify variable names in your output. """ from case import dt, Tend, SAVE_COUNT, sol -from mfc.viz.silo_reader import assemble_silo -from mfc.viz.reader import discover_timesteps +from mfc.viz import assemble_silo, discover_timesteps from tqdm import tqdm import cantera as ct import matplotlib.pyplot as plt diff --git a/toolchain/bootstrap/lint.sh b/toolchain/bootstrap/lint.sh index 814a3ae626..89649cd5aa 100644 --- a/toolchain/bootstrap/lint.sh +++ b/toolchain/bootstrap/lint.sh @@ -4,39 +4,17 @@ set -o pipefail # Parse arguments RUN_TESTS=true -RUN_VIZ_TESTS=true for arg in "$@"; do case $arg in --no-test) RUN_TESTS=false ;; - --no-viz-test) - RUN_VIZ_TESTS=false - ;; esac done -# Install viz optional deps only if not already present. On air-gapped systems or -# networks where PyPI is unreachable the install may fail; in that case we skip the -# viz-specific lint and tests rather than aborting the entire precheck. -VIZ_LINT=true -if ! python3 -c "import matplotlib, dash, textual, imageio, h5py, plotext, plotly" 2>/dev/null; then - log "(venv) Installing$MAGENTA viz$COLOR_RESET optional dependencies for linting..." - if ! { uv pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null \ - || python3 -m pip install -q "$(pwd)/toolchain[viz]" 2>/dev/null; }; then - log "${YELLOW}Warning:${COLOR_RESET} viz optional dependencies could not be installed (no network?). Skipping viz lint/tests." - VIZ_LINT=false - fi -fi - log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""toolchain$COLOR_RESET." -# Exclude the viz subpackage from pylint when its optional deps are unavailable, -# since pylint needs to import matplotlib/dash/etc. to analyse those modules. -PYLINT_VIZ_OPT="" -[ "$VIZ_LINT" = false ] && PYLINT_VIZ_OPT="--ignore-paths=.*/mfc/viz/.*" -# shellcheck disable=SC2086 -pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 $PYLINT_VIZ_OPT "$(pwd)/toolchain/" +pylint -d R1722,W0718,C0301,C0116,C0115,C0114,C0410,W0622,W0640,C0103,W1309,C0411,W1514,R0401,W0511,C0321,C3001,R0801,R0911,R0912 "$(pwd)/toolchain/" log "(venv) Running$MAGENTA pylint$COLOR_RESET on$MAGENTA MFC$COLOR_RESET's $MAGENTA""examples$COLOR_RESET." @@ -54,9 +32,7 @@ if [ "$RUN_TESTS" = true ]; then cd "$(pwd)/toolchain" python3 -m unittest mfc.params_tests.test_registry mfc.params_tests.test_definitions mfc.params_tests.test_validate mfc.params_tests.test_integration -v python3 -m unittest mfc.cli.test_cli -v - if [ "$RUN_VIZ_TESTS" = true ] && [ "$VIZ_LINT" = true ]; then - python3 -m unittest mfc.viz.test_viz -v - fi + python3 -m unittest mfc.viz.test_viz -v cd - > /dev/null fi diff --git a/toolchain/bootstrap/precheck.sh b/toolchain/bootstrap/precheck.sh index 7d751b4801..9274f3abae 100755 --- a/toolchain/bootstrap/precheck.sh +++ b/toolchain/bootstrap/precheck.sh @@ -92,7 +92,7 @@ fi # 3. Lint toolchain (Python) log "[$CYAN 3/5$COLOR_RESET] Running$MAGENTA toolchain lint$COLOR_RESET..." -if ./mfc.sh lint --no-viz-test > /dev/null 2>&1; then +if ./mfc.sh lint > /dev/null 2>&1; then ok "Toolchain lint passed." else error "Toolchain lint failed. Run$MAGENTA ./mfc.sh lint$COLOR_RESET for details." diff --git a/toolchain/mfc/viz/__init__.py b/toolchain/mfc/viz/__init__.py index e69de29bb2..9185f3d63c 100644 --- a/toolchain/mfc/viz/__init__.py +++ b/toolchain/mfc/viz/__init__.py @@ -0,0 +1,3 @@ +# Public API — importable as ``from mfc.viz import ...`` +from .reader import assemble, discover_format, discover_timesteps # noqa: F401 +from .silo_reader import assemble_silo # noqa: F401 diff --git a/toolchain/mfc/viz/reader.py b/toolchain/mfc/viz/reader.py index 8764e5f4ae..1e63ae8d49 100644 --- a/toolchain/mfc/viz/reader.py +++ b/toolchain/mfc/viz/reader.py @@ -209,6 +209,11 @@ def read_binary_file(path: str, var_filter: Optional[str] = None) -> ProcessorDa var_dtype = np.dtype(f'{endian}f8') elif data_bytes == data_size * 4: var_dtype = np.dtype(f'{endian}f4') + elif data_bytes == data_size * 2: + raise ValueError( + f"Variable '{varname}' appears to be half-precision (2 bytes/value). " + "This is typical of --mixed builds. Half-precision viz is not yet supported." + ) else: var_bpv = data_bytes / data_size if data_size else 0 raise ValueError( diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index f4122a6884..fa4d7554ee 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -5,55 +5,13 @@ """ import os -import importlib -import importlib.util -import shutil -import subprocess -import sys import warnings from mfc.state import ARG -from mfc.common import MFC_ROOT_DIR, MFCException +from mfc.common import MFCException from mfc.printer import cons -def _ensure_viz_deps() -> None: - """Install the [viz] optional extras on first use. - - Checks one sentinel per feature group so that a user who has matplotlib - pre-installed (e.g. via Anaconda) but lacks imageio, textual, or h5py - still triggers the install. - """ - _SENTINELS = ("matplotlib", "imageio", "h5py", "textual", "dash", "plotext", "plotly") - if all(importlib.util.find_spec(p) is not None for p in _SENTINELS): - return # all present - - toolchain_path = os.path.join(MFC_ROOT_DIR, "toolchain") - cons.print("[bold]Installing viz dependencies[/bold] " - "(first run — this may take a minute)...") - - env = {**os.environ, "UV_LINK_MODE": "copy"} - if shutil.which("uv"): - cmd = ["uv", "pip", "install", f"{toolchain_path}[viz]"] - else: - cmd = [sys.executable, "-m", "pip", "install", f"{toolchain_path}[viz]"] - - try: - result = subprocess.run(cmd, env=env, check=False, timeout=300) - except subprocess.TimeoutExpired as exc: - raise MFCException( - "Timed out installing viz dependencies (network may be restricted). " - f"Try manually: pip install '{toolchain_path}[viz]'" - ) from exc - if result.returncode != 0: - raise MFCException( - "Failed to install viz dependencies. " - f"Try manually: pip install '{toolchain_path}[viz]'" - ) - - importlib.invalidate_caches() - cons.print("[bold green]Viz dependencies installed.[/bold green]\n") - _CMAP_POPULAR = ( 'viridis, plasma, inferno, magma, turbo, ' @@ -184,6 +142,10 @@ def _parse_steps(step_arg, available_steps): try: if ':' in s: parts = s.split(':') + if len(parts) > 3: + raise MFCException( + f"Invalid range '{step_arg}': expected start:end or start:end:stride." + ) start = int(parts[0]) end = int(parts[1]) stride = int(parts[2]) if len(parts) > 2 else 1 @@ -206,7 +168,6 @@ def _parse_steps(step_arg, available_steps): def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branches """Main viz command dispatcher.""" - _ensure_viz_deps() from .reader import discover_format, discover_timesteps, assemble, has_lag_bubble_evol, read_lag_bubbles_at_step # pylint: disable=import-outside-toplevel from .renderer import render_1d, render_1d_tiled, render_2d, render_2d_tiled, render_3d_slice, render_mp4 # pylint: disable=import-outside-toplevel diff --git a/toolchain/pyproject.toml b/toolchain/pyproject.toml index 4788c1d56e..ceeff38a86 100644 --- a/toolchain/pyproject.toml +++ b/toolchain/pyproject.toml @@ -40,31 +40,18 @@ dependencies = [ # Frontier Profiling "astunparse==1.6.2", "pymongo", - "tabulate" -] + "tabulate", -[project.optional-dependencies] -viz = [ - # 2D/3D plotting (renderer, TUI) + # Visualization (./mfc.sh viz) "matplotlib", - - # Silo-HDF5 reader "h5py", - - # MP4 export "imageio>=2.33", "imageio-ffmpeg>=0.5.0", - - # Terminal UI (--tui) "plotext>=5.2.0", "textual>=0.47.0", "textual-plotext>=0.2.0", - - # Interactive web UI (--interactive) "dash>=2.0", "plotly", - - # Progress bar (PNG/MP4 batch rendering) "tqdm", ] From b95c24579e02e20d2d2974be121be6855e5f5236 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 16:00:13 -0500 Subject: [PATCH 092/102] viz: dark-theme interactive UI, scientific notation colorbars, SSH tunnel help - Inject dark-theme CSS for Dash 4.0 dropdowns, inputs, and sliders - Use .2e tick format on colorbars and 1D y-axis for consistent width - Use exponentformat=e on spatial axes to avoid SI prefix mu symbol - Add right margin for colorbar tick labels - Hide native number input spinner buttons - Expand SSH tunnel instructions for HPC users with 2FA - Add CLAUDE.md rule: no heredocs in commit messages Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + toolchain/mfc/viz/interactive.py | 87 +++++++++++++++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ab424ea54..47a188d100 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ IMPORTANT: Follow this loop for ALL code changes. Do not skip steps. YOU MUST run `./mfc.sh precheck` before any commit. This is enforced by pre-commit hooks. YOU MUST run tests relevant to your changes before claiming work is done. NEVER commit code that does not compile or fails tests. +NEVER use heredocs for git commit messages. Use simple `git commit -m "message"` instead. ## Architecture diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index fb4144acb4..6eab773f58 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -103,7 +103,7 @@ def _num(sid, placeholder='auto'): 'width': '100%', 'backgroundColor': _OVER, 'color': _TEXT, 'border': f'1px solid {_BORDER}', 'borderRadius': '4px', 'padding': '4px 6px', 'fontSize': '12px', 'fontFamily': 'monospace', - 'boxSizing': 'border-box', + 'boxSizing': 'border-box', 'colorScheme': 'dark', }, ) @@ -121,6 +121,7 @@ def _build_3d(ad, raw, varname, step, mode, cmap, # pylint: disable=too-many-ar cbar = dict( title=dict(text=cbar_title, font=dict(color=_TEXT)), tickfont=dict(color=_TEXT), + tickformat='.2e', ) rng = cmax - cmin if cmax > cmin else 1.0 @@ -213,6 +214,62 @@ def run_interactive( # pylint: disable=too-many-locals,too-many-statements,too- suppress_callback_exceptions=True, ) + # Override Dash's internal component styles for dark theme. + # Dash components (Dropdown, Input, Slider) render internal DOM that + # ignores inline style props. We inject CSS via app.index_string. + # Build CSS using %-formatting to avoid f-string brace conflicts. + _V = {'bg': _OVER, 'tx': _TEXT, 'bd': _BORDER, 'ac': _ACCENT} + _dark_css = """ +* { color-scheme: dark; } +/* Dropdowns — target by known IDs + universal child selectors */ +#var-sel *, #step-sel *, #cmap-sel *, +#var-sel > div, #step-sel > div, #cmap-sel > div { + background-color: %(bg)s !important; + color: %(tx)s !important; + border-color: %(bd)s !important; +} +#var-sel input, #step-sel input, #cmap-sel input { + background-color: %(bg)s !important; + color: %(tx)s !important; +} +/* Inputs */ +input, input[type=number], input[type=text] { + background-color: %(bg)s !important; + color: %(tx)s !important; + border: 1px solid %(bd)s !important; + border-radius: 4px; +} +/* Number input spinner buttons (browser chrome) */ +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type=number] { -moz-appearance: textfield; } +/* Slider tooltip bubble */ +.rc-slider-tooltip-inner { + background-color: %(tx)s !important; + color: #11111b !important; + border: none !important; +} +.rc-slider-mark-text { color: %(tx)s !important; } +.rc-slider-rail { background-color: %(bd)s !important; } +.rc-slider-track { background-color: %(ac)s !important; } +.rc-slider-handle { + border-color: %(ac)s !important; + background-color: %(ac)s !important; +} +.rc-slider-dot { background-color: %(bd)s !important; border-color: %(bd)s !important; } +""" % _V + app.index_string = ( + '\n\n\n' + '{%metas%}\n{%title%}\n{%favicon%}\n{%css%}\n' + '\n' + '\n\n' + '{%app_entry%}\n
\n{%config%}\n{%scripts%}\n{%renderer%}\n' + '
\n\n' + ) + # Load first step to know dimensionality and available variables init = _load(steps[0], read_func) ndim = init.ndim @@ -597,14 +654,14 @@ def _tf(arr): return arr elif ad.ndim == 2: cbar = dict(title=dict(text=cbar_title, font=dict(color=_TEXT)), - tickfont=dict(color=_TEXT)) + tickfont=dict(color=_TEXT), tickformat='.2e') fig.add_trace(go.Heatmap( x=ad.x_cc, y=ad.y_cc, z=_tf(raw).T, zmin=cmin, zmax=cmax, colorscale=cmap, colorbar=cbar, )) fig.update_layout( - xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, scaleanchor='y'), - yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER), + xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, scaleanchor='y', exponentformat='e'), + yaxis=dict(title='y', color=_TEXT, gridcolor=_OVER, exponentformat='e'), plot_bgcolor=_BG, ) # Bubble overlay for 2D @@ -635,8 +692,9 @@ def _tf(arr): return arr line=dict(color=_ACCENT, width=2), name=selected_var, )) fig.update_layout( - xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER), + xaxis=dict(title='x', color=_TEXT, gridcolor=_OVER, exponentformat='e'), yaxis=dict(title=cbar_title, color=_TEXT, gridcolor=_OVER, + tickformat='.2e', range=[cmin, cmax] if (vmin_in is not None or vmax_in is not None) else None), plot_bgcolor=_BG, ) @@ -646,7 +704,7 @@ def _tf(arr): return arr title=dict(text=title, font=dict(color=_TEXT, size=13, family='monospace')), paper_bgcolor=_BG, font=dict(color=_TEXT, family='monospace'), - margin=dict(l=0, r=0, t=36, b=0), + margin=dict(l=0, r=80, t=36, b=0), uirevision=mode, # preserve camera angle within a mode ) @@ -666,6 +724,19 @@ def _tf(arr): return arr cons.print(f'\n[bold green]Interactive viz server:[/bold green] ' f'[bold]http://{host}:{port}[/bold]') if host in ('127.0.0.1', 'localhost'): - cons.print(f'[dim]SSH tunnel: ssh -L {port}:localhost:{port} [/dim]') - cons.print('[dim]Ctrl+C to stop.[/dim]\n') + cons.print( + f'\n[dim]To view from your laptop/desktop:[/dim]\n' + f'[dim] 1. Open a [bold]new terminal[/bold] on your [bold]local[/bold] machine (not the cluster)[/dim]\n' + f'[dim] 2. Run:[/dim] [bold]ssh -L {port}:localhost:{port} [/bold]\n' + f'[dim] (replace with the host you SSH into, e.g. login-phoenix.pace.gatech.edu)[/dim]\n' + f'[dim] 3. Open [bold]http://localhost:{port}[/bold] in your local browser[/dim]\n' + f'[dim] If your cluster requires 2FA, add to your ~/.ssh/config:[/dim]\n' + f'[dim] Host [/dim]\n' + f'[dim] ControlMaster auto[/dim]\n' + f'[dim] ControlPath ~/.ssh/sockets/%r@%h-%p[/dim]\n' + f'[dim] ControlPersist 600[/dim]\n' + f'[dim] Then your first SSH session handles 2FA; the tunnel reuses it without re-authenticating.[/dim]\n' + f'[dim] (Run [bold]mkdir -p ~/.ssh/sockets[/bold] once if the directory does not exist.)[/dim]' + ) + cons.print('[dim]\nCtrl+C to stop.[/dim]\n') app.run(debug=False, port=port, host=host) From 6b772eeec309c1c599d6f6bc55f3311c5d376b0a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 18:38:22 -0500 Subject: [PATCH 093/102] viz: improve SSH tunnel help text, add chmod 700 for sockets dir Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/interactive.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 6eab773f58..8dc54c0bdf 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -725,18 +725,20 @@ def _tf(arr): return arr f'[bold]http://{host}:{port}[/bold]') if host in ('127.0.0.1', 'localhost'): cons.print( - f'\n[dim]To view from your laptop/desktop:[/dim]\n' - f'[dim] 1. Open a [bold]new terminal[/bold] on your [bold]local[/bold] machine (not the cluster)[/dim]\n' - f'[dim] 2. Run:[/dim] [bold]ssh -L {port}:localhost:{port} [/bold]\n' - f'[dim] (replace with the host you SSH into, e.g. login-phoenix.pace.gatech.edu)[/dim]\n' - f'[dim] 3. Open [bold]http://localhost:{port}[/bold] in your local browser[/dim]\n' - f'[dim] If your cluster requires 2FA, add to your ~/.ssh/config:[/dim]\n' - f'[dim] Host [/dim]\n' + f'\n[dim]To view from your laptop/desktop, open a [bold]new terminal on your local machine[/bold] and run:[/dim]\n' + f'\n [bold]ssh -L {port}:localhost:{port} [/bold]\n' + f'\n[dim] Replace [bold][/bold] with whatever you normally pass to [bold]ssh[/bold] to reach this cluster.[/dim]\n' + f'[dim] This can be a full hostname (e.g. [bold]login-phoenix.pace.gatech.edu[/bold])[/dim]\n' + f'[dim] or an alias from your [bold]~/.ssh/config[/bold] (e.g. [bold]ssh -L {port}:localhost:{port} delta[/bold]).[/dim]\n' + f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]\n' + f'\n[dim] [bold]2FA clusters:[/bold] if your cluster requires two-factor auth and you don\'t[/dim]\n' + f'[dim] want to authenticate twice, add [bold]ControlMaster[/bold] to your [bold]~/.ssh/config[/bold]:[/dim]\n' + f'[dim] Host [/dim]\n' f'[dim] ControlMaster auto[/dim]\n' f'[dim] ControlPath ~/.ssh/sockets/%r@%h-%p[/dim]\n' f'[dim] ControlPersist 600[/dim]\n' - f'[dim] Then your first SSH session handles 2FA; the tunnel reuses it without re-authenticating.[/dim]\n' - f'[dim] (Run [bold]mkdir -p ~/.ssh/sockets[/bold] once if the directory does not exist.)[/dim]' + f'[dim] Then your first SSH session handles 2FA; the tunnel reuses it.[/dim]\n' + f'[dim] (Run [bold]mkdir -p ~/.ssh/sockets && chmod 700 ~/.ssh/sockets[/bold] once if the dir does not exist.)[/dim]' ) cons.print('[dim]\nCtrl+C to stop.[/dim]\n') app.run(debug=False, port=port, host=host) From f56520f4c56a59e5e0066882c6f13ddb4f94d71a Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 18:51:17 -0500 Subject: [PATCH 094/102] viz: simplify SSH tunnel help, show user@host format Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/interactive.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 8dc54c0bdf..14a7f08e03 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -726,19 +726,11 @@ def _tf(arr): return arr if host in ('127.0.0.1', 'localhost'): cons.print( f'\n[dim]To view from your laptop/desktop, open a [bold]new terminal on your local machine[/bold] and run:[/dim]\n' - f'\n [bold]ssh -L {port}:localhost:{port} [/bold]\n' - f'\n[dim] Replace [bold][/bold] with whatever you normally pass to [bold]ssh[/bold] to reach this cluster.[/dim]\n' - f'[dim] This can be a full hostname (e.g. [bold]login-phoenix.pace.gatech.edu[/bold])[/dim]\n' - f'[dim] or an alias from your [bold]~/.ssh/config[/bold] (e.g. [bold]ssh -L {port}:localhost:{port} delta[/bold]).[/dim]\n' - f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]\n' - f'\n[dim] [bold]2FA clusters:[/bold] if your cluster requires two-factor auth and you don\'t[/dim]\n' - f'[dim] want to authenticate twice, add [bold]ControlMaster[/bold] to your [bold]~/.ssh/config[/bold]:[/dim]\n' - f'[dim] Host [/dim]\n' - f'[dim] ControlMaster auto[/dim]\n' - f'[dim] ControlPath ~/.ssh/sockets/%r@%h-%p[/dim]\n' - f'[dim] ControlPersist 600[/dim]\n' - f'[dim] Then your first SSH session handles 2FA; the tunnel reuses it.[/dim]\n' - f'[dim] (Run [bold]mkdir -p ~/.ssh/sockets && chmod 700 ~/.ssh/sockets[/bold] once if the dir does not exist.)[/dim]' + f'\n [bold]ssh -L {port}:localhost:{port} user@cluster-hostname[/bold]\n' + f'\n[dim] Replace [bold]user@cluster-hostname[/bold] with whatever you normally pass to [bold]ssh[/bold][/dim]\n' + f'[dim] to reach this cluster (e.g. [bold]jdoe@login.delta.ncsa.illinois.edu[/bold] or an[/dim]\n' + f'[dim] alias from your [bold]~/.ssh/config[/bold]).[/dim]\n' + f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]' ) cons.print('[dim]\nCtrl+C to stop.[/dim]\n') app.run(debug=False, port=port, host=host) From adfcdc709060fcebcd6818b6469e6421b3f328bd Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 18:59:38 -0500 Subject: [PATCH 095/102] viz: add port-in-use troubleshooting hint to SSH tunnel help Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/interactive.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/toolchain/mfc/viz/interactive.py b/toolchain/mfc/viz/interactive.py index 14a7f08e03..a4e7e89266 100644 --- a/toolchain/mfc/viz/interactive.py +++ b/toolchain/mfc/viz/interactive.py @@ -730,7 +730,9 @@ def _tf(arr): return arr f'\n[dim] Replace [bold]user@cluster-hostname[/bold] with whatever you normally pass to [bold]ssh[/bold][/dim]\n' f'[dim] to reach this cluster (e.g. [bold]jdoe@login.delta.ncsa.illinois.edu[/bold] or an[/dim]\n' f'[dim] alias from your [bold]~/.ssh/config[/bold]).[/dim]\n' - f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]' + f'[dim] Then open [bold]http://localhost:{port}[/bold] in your local browser.[/dim]\n' + f'[dim] If you see [bold]Address already in use[/bold], free the port with:[/dim]\n' + f' [bold]lsof -ti :{port} | xargs kill[/bold]' ) cons.print('[dim]\nCtrl+C to stop.[/dim]\n') app.run(debug=False, port=port, host=host) From 5b8d16ef194833142bd6c577c75ea0c86a6de010 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 19:28:16 -0500 Subject: [PATCH 096/102] viz: make TUI the default mode, add --png flag for image output Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 34 ++++++++++---------- toolchain/mfc/cli/commands.py | 48 ++++++++++++++--------------- toolchain/mfc/viz/tui.py | 4 +-- toolchain/mfc/viz/viz.py | 15 +++++---- 4 files changed, 51 insertions(+), 50 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 6339a00d41..58cf0fefc9 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -14,16 +14,16 @@ MFC includes a built-in visualization command that renders images and videos dir ### Basic usage ```bash -# Plot pressure at the last available timestep (--step defaults to 'last') -./mfc.sh viz case_dir/ --var pres +# Launch the terminal UI (default mode) +./mfc.sh viz case_dir/ -# Plot density at all available timesteps -./mfc.sh viz case_dir/ --var rho --step all +# Launch with a specific variable pre-selected +./mfc.sh viz case_dir/ --var pres ``` The command auto-detects the output format (binary or Silo-HDF5) and dimensionality (1D, 2D, or 3D). -Output images are saved to `case_dir/viz/` by default. -The default colormap is `viridis`, default DPI is 150, and `--step` defaults to `last`. +By default it launches an interactive terminal UI that works over SSH. +Use `--interactive` for a browser-based UI (supports 3D), `--png` to save images, or `--mp4` for video. ### Exploring available data @@ -54,13 +54,13 @@ Customize the appearance of plots: ```bash # Custom colormap and color range -./mfc.sh viz case_dir/ --var rho --step 1000 --cmap RdBu --vmin 0.5 --vmax 2.0 +./mfc.sh viz case_dir/ --var rho --step 1000 --png --cmap RdBu --vmin 0.5 --vmax 2.0 # Higher resolution -./mfc.sh viz case_dir/ --var pres --step 500 --dpi 300 +./mfc.sh viz case_dir/ --var pres --step 500 --png --dpi 300 # Logarithmic color scale -./mfc.sh viz case_dir/ --var schlieren --step 1000 --log-scale +./mfc.sh viz case_dir/ --var schlieren --step 1000 --png --log-scale ``` | Option | Description | Default | @@ -84,13 +84,13 @@ By default, it slices at the midplane along the z-axis. ```bash # Default z-midplane slice -./mfc.sh viz case_dir/ --var pres --step 500 +./mfc.sh viz case_dir/ --var pres --step 500 --png # Slice along the x-axis at x=0.25 -./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x --slice-value 0.25 +./mfc.sh viz case_dir/ --var pres --step 500 --png --slice-axis x --slice-value 0.25 # Slice by array index -./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis y --slice-index 50 +./mfc.sh viz case_dir/ --var pres --step 500 --png --slice-axis y --slice-index 50 ``` ### Video generation @@ -117,10 +117,10 @@ For 1D cases, omitting `--var` (or passing `--var all`) renders all variables in ```bash # Tiled plot of all variables at the last timestep -./mfc.sh viz case_dir/ --step last +./mfc.sh viz case_dir/ --step last --png # Equivalent explicit form -./mfc.sh viz case_dir/ --var all --step last +./mfc.sh viz case_dir/ --var all --step last --png ``` Each variable gets its own subplot with automatic LaTeX-style axis labels. @@ -147,13 +147,13 @@ The interactive viewer provides a Dash web UI with: ### Terminal UI (TUI) -For environments without a browser — such as SSH sessions or HPC login nodes — use `--tui` to launch a live terminal UI: +The default mode launches a live terminal UI that works over SSH with no browser or port-forwarding needed: ```bash -./mfc.sh viz case_dir/ --tui +./mfc.sh viz case_dir/ # Start with a specific variable pre-selected -./mfc.sh viz case_dir/ --var pres --tui +./mfc.sh viz case_dir/ --var pres ``` The TUI loads all timesteps and renders plots directly in the terminal using Unicode block characters. diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 2a41e45aa3..c861fe5a36 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -867,19 +867,19 @@ description=( "Render post-processed MFC output as PNG images or MP4 video, or explore " "interactively. Supports 1D line plots, 2D colormaps, 3D midplane slices, " - "and tiled all-variable views. PNG files are saved to case_dir/viz/ by default.\n\n" + "and tiled all-variable views.\n\n" "Output modes:\n" - " (default) Save PNG image(s) to case_dir/viz/\n" - " --mp4 Encode frames into an MP4 video\n" + " (default) Launch a terminal UI (works over SSH, no browser needed)\n" " --interactive Launch a Dash web UI in your browser\n" - " --tui Launch a terminal UI (works over SSH, no browser needed)\n\n" + " --png Save PNG image(s) to case_dir/viz/\n" + " --mp4 Encode frames into an MP4 video\n\n" "Variable selection:\n" " --var NAME Plot a single variable\n" " (omit --var) 1D/2D: tiled layout of all variables; 3D: first variable\n\n" "Quick-start workflow:\n" " 1. ./mfc.sh viz case_dir/ --list-steps\n" " 2. ./mfc.sh viz case_dir/ --list-vars --step 0\n" - " 3. ./mfc.sh viz case_dir/ --var pres --step 1000" + " 3. ./mfc.sh viz case_dir/" ), positionals=[ Positional( @@ -927,7 +927,7 @@ Argument( name="output", short="o", - help="Directory for saved PNG images or MP4 video (default: case_dir/viz/).", + help="Directory for saved PNG images or MP4 video.", type=str, default=None, metavar="DIR", @@ -935,7 +935,7 @@ ), Argument( name="cmap", - help="Matplotlib colormap name (default: viridis).", + help="Matplotlib colormap name.", type=str, default='viridis', metavar="CMAP", @@ -981,14 +981,14 @@ ), Argument( name="dpi", - help="Image resolution in DPI (default: 150).", + help="Image resolution in DPI.", type=int, default=150, metavar="DPI", ), Argument( name="slice-axis", - help="Axis for 3D slice: x, y, or z (default: z).", + help="Axis for 3D slice.", type=str, default='z', choices=["x", "y", "z"], @@ -1019,7 +1019,7 @@ ), Argument( name="fps", - help="Frames per second for MP4 output (default: 10).", + help="Frames per second for MP4 output.", type=int, default=10, metavar="FPS", @@ -1058,38 +1058,36 @@ ), Argument( name="port", - help="Port for the interactive web server (default: 8050).", + help="Port for the interactive web server.", type=int, default=8050, metavar="PORT", ), Argument( name="host", - help="Host/bind address for the interactive web server (default: 127.0.0.1).", + help="Host/bind address for the interactive web server.", default="127.0.0.1", metavar="HOST", ), Argument( - name="tui", + name="png", help=( - "Launch an interactive terminal UI (1D/2D only). " - "Works over SSH with no browser required. " - "Use arrow keys to step through timesteps." + "Save PNG image(s) to the output directory instead of " + "launching the terminal UI." ), action=ArgAction.STORE_TRUE, default=False, ), ], examples=[ + Example("./mfc.sh viz case_dir/", "Launch terminal UI (default mode)"), Example("./mfc.sh viz case_dir/ --list-steps", "Discover available timesteps"), Example("./mfc.sh viz case_dir/ --list-vars --step 0", "Discover available variables at step 0"), - Example("./mfc.sh viz case_dir/ --var pres --step 1000", "Save pressure PNG at step 1000 → case_dir/viz/"), - Example("./mfc.sh viz case_dir/ --step 1000", "Save tiled PNG of all variables (1D/2D) at step 1000"), - Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Encode schlieren MP4 from range"), - Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000", "Render all steps 0–1000 (stride inferred)"), - Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x", "3D: x-plane slice of pressure"), Example("./mfc.sh viz case_dir/ --var pres --interactive", "Browser UI — scrub timesteps and switch vars"), - Example("./mfc.sh viz case_dir/ --var pres --tui", "Terminal UI over SSH (1D/2D, no browser)"), + Example("./mfc.sh viz case_dir/ --var pres --step 1000 --png", "Save pressure PNG at step 1000"), + Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Encode schlieren MP4 from range"), + Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000 --png", "Render all steps 0–1000 as images"), + Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x --png", "3D: x-plane slice of pressure"), ], key_options=[ ("-- Discovery --", ""), @@ -1099,10 +1097,10 @@ ("--var NAME", "Variable to plot (omit for tiled all-vars layout)"), ("--step STEP", "last (default), int, start:stop:stride, list, or 'all'"), ("-- Output modes --", ""), - ("(default)", "Save PNG to case_dir/viz/; use -o DIR to change"), - ("--mp4", "Encode frames into an MP4 video"), + ("(default)", "Terminal UI — works over SSH, no browser needed (1D/2D)"), ("--interactive / -i", "Dash web UI in browser (supports 1D/2D/3D)"), - ("--tui", "Terminal UI over SSH — no browser needed (1D/2D)"), + ("--png", "Save PNG image(s) to case_dir/viz/; use -o DIR to change"), + ("--mp4", "Encode frames into an MP4 video"), ("-- Appearance --", ""), ("--cmap NAME", "Matplotlib colormap (default: viridis)"), ("--vmin / --vmax", "Fix color-scale limits"), diff --git a/toolchain/mfc/viz/tui.py b/toolchain/mfc/viz/tui.py index b53c43a154..3efd34b053 100644 --- a/toolchain/mfc/viz/tui.py +++ b/toolchain/mfc/viz/tui.py @@ -1,7 +1,7 @@ """ Terminal UI (TUI) for MFC visualization using Textual + plotext. -Launched via ``./mfc.sh viz --tui [--var VAR] [--step STEP]``. +Launched via ``./mfc.sh viz [--var VAR] [--step STEP]``. Opens a full-terminal interactive viewer that works over SSH with no browser or port-forwarding required. @@ -688,7 +688,7 @@ def run_tui( """Launch the Textual TUI for MFC visualization (1D/2D only).""" if ndim not in (1, 2): raise MFCException( - f"--tui only supports 1D and 2D data (got ndim={ndim}). " + f"Terminal UI only supports 1D and 2D data (got ndim={ndim}). " "Use --interactive for 3D data." ) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index fa4d7554ee..0c3276bafa 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -265,7 +265,10 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc step_arg = ARG('step') tiled = varname is None or varname == 'all' - if ARG('interactive') or ARG('tui') or ARG('mp4'): + # TUI is the default mode; --interactive, --png, and --mp4 are explicit. + use_tui = not ARG('interactive') and not ARG('png') and not ARG('mp4') + + if ARG('interactive') or use_tui or ARG('mp4'): # Load all steps by default; honour an explicit --step so users can # reduce the set for large 3D cases before hitting the step limit. if step_arg == 'last': @@ -313,7 +316,7 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc # Load all variables when tiled, interactive, or TUI; filter otherwise. # TUI needs all vars loaded so the sidebar can switch between them. - load_all = tiled or interactive or ARG('tui') + load_all = tiled or interactive or use_tui def read_step(step): if fmt == 'silo': @@ -334,7 +337,7 @@ def read_step(step): f"(limit is {_3d_limit}). Use --step with a range or stride to reduce.") # Tiled mode works for 1D and 2D. For 3D, auto-select the first variable. - if tiled and not interactive and not ARG('tui'): + if tiled and not interactive and not use_tui: if test_assembled.ndim == 3: varname = avail[0] if avail else None if varname is None: @@ -343,7 +346,7 @@ def read_step(step): cons.print(f"[dim]Auto-selected variable: [bold]{varname}[/bold]" " (use --var to specify)[/dim]") - if not tiled and not interactive and not ARG('tui') and varname not in test_assembled.variables: + if not tiled and not interactive and not use_tui and varname not in test_assembled.variables: # test_assembled was loaded with var_filter=varname so its variables dict # may be empty. Re-read without filter (errors only, so extra I/O is fine) # to build a useful "available variables" list for the error message. @@ -367,10 +370,10 @@ def read_step(step): _validate_cmap(cmap_name) # TUI mode — launch Textual terminal UI (1D/2D only) - if ARG('tui'): + if use_tui: if test_assembled.ndim == 3: raise MFCException( - "--tui only supports 1D and 2D data. " + "Terminal UI only supports 1D and 2D data. " "Use --interactive for 3D data." ) from .tui import run_tui # pylint: disable=import-outside-toplevel From 94915199d04b89bbd965c26ca1712ffdbea7bb00 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 19:32:29 -0500 Subject: [PATCH 097/102] viz: replace schlieren with always-available vars in examples Co-Authored-By: Claude Opus 4.6 --- docs/documentation/visualization.md | 4 ++-- toolchain/mfc/cli/commands.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/documentation/visualization.md b/docs/documentation/visualization.md index 58cf0fefc9..45486fa7a9 100644 --- a/docs/documentation/visualization.md +++ b/docs/documentation/visualization.md @@ -60,7 +60,7 @@ Customize the appearance of plots: ./mfc.sh viz case_dir/ --var pres --step 500 --png --dpi 300 # Logarithmic color scale -./mfc.sh viz case_dir/ --var schlieren --step 1000 --png --log-scale +./mfc.sh viz case_dir/ --var pres --step 1000 --png --log-scale ``` | Option | Description | Default | @@ -102,7 +102,7 @@ Generate MP4 videos from a range of timesteps: ./mfc.sh viz case_dir/ --var pres --step 0:10000:100 --mp4 # Custom frame rate -./mfc.sh viz case_dir/ --var schlieren --step all --mp4 --fps 24 +./mfc.sh viz case_dir/ --var rho --step all --mp4 --fps 24 # Video with fixed color range ./mfc.sh viz case_dir/ --var rho --step 0:5000:50 --mp4 --vmin 0.1 --vmax 1.0 diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index c861fe5a36..03842714c8 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -892,7 +892,7 @@ Argument( name="var", help=( - "Variable to visualize (e.g. pres, rho, vel1, schlieren). " + "Variable to visualize (e.g. pres, rho, vel1). " "Omit (or pass 'all') for a tiled layout of all variables " "(1D and 2D data) or the first variable (3D data). " "Use --list-vars to see available names." @@ -1085,7 +1085,7 @@ Example("./mfc.sh viz case_dir/ --list-vars --step 0", "Discover available variables at step 0"), Example("./mfc.sh viz case_dir/ --var pres --interactive", "Browser UI — scrub timesteps and switch vars"), Example("./mfc.sh viz case_dir/ --var pres --step 1000 --png", "Save pressure PNG at step 1000"), - Example("./mfc.sh viz case_dir/ --var schlieren --step 0:10000:500 --mp4", "Encode schlieren MP4 from range"), + Example("./mfc.sh viz case_dir/ --var pres --step 0:10000:500 --mp4", "Encode pressure MP4 from range"), Example("./mfc.sh viz case_dir/ --step 0,100,200,...,1000 --png", "Render all steps 0–1000 as images"), Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x --png", "3D: x-plane slice of pressure"), ], From a49ceba04e0ac27ac1d00cfbca3e1cff578502c1 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 19:54:43 -0500 Subject: [PATCH 098/102] viz: clarify which CLI options apply to which output modes Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/cli/commands.py | 48 ++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 03842714c8..1b07748b5e 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -927,7 +927,7 @@ Argument( name="output", short="o", - help="Directory for saved PNG images or MP4 video.", + help="Output directory for --png and --mp4.", type=str, default=None, metavar="DIR", @@ -935,7 +935,7 @@ ), Argument( name="cmap", - help="Matplotlib colormap name.", + help="Matplotlib colormap name (--interactive, --png, --mp4).", type=str, default='viridis', metavar="CMAP", @@ -967,28 +967,28 @@ ), Argument( name="vmin", - help="Minimum value for color scale.", + help="Minimum value for color scale (--interactive, --png, --mp4).", type=float, default=None, metavar="VMIN", ), Argument( name="vmax", - help="Maximum value for color scale.", + help="Maximum value for color scale (--interactive, --png, --mp4).", type=float, default=None, metavar="VMAX", ), Argument( name="dpi", - help="Image resolution in DPI.", + help="Image resolution in DPI (--png, --mp4).", type=int, default=150, metavar="DPI", ), Argument( name="slice-axis", - help="Axis for 3D slice.", + help="Axis for 3D slice (--interactive, --png, --mp4).", type=str, default='z', choices=["x", "y", "z"], @@ -997,7 +997,7 @@ ), Argument( name="slice-value", - help="Coordinate value at which to take the 3D slice.", + help="Coordinate value at which to take the 3D slice (--interactive, --png, --mp4).", type=float, default=None, dest="slice_value", @@ -1005,7 +1005,7 @@ ), Argument( name="slice-index", - help="Array index at which to take the 3D slice.", + help="Array index at which to take the 3D slice (--interactive, --png, --mp4).", type=int, default=None, dest="slice_index", @@ -1019,7 +1019,7 @@ ), Argument( name="fps", - help="Frames per second for MP4 output.", + help="Frames per second for --mp4 output.", type=int, default=10, metavar="FPS", @@ -1040,7 +1040,7 @@ ), Argument( name="log-scale", - help="Use a logarithmic color/y scale (skips non-positive values).", + help="Logarithmic color/y scale (--interactive, --png, --mp4).", action=ArgAction.STORE_TRUE, default=False, dest="log_scale", @@ -1058,14 +1058,14 @@ ), Argument( name="port", - help="Port for the interactive web server.", + help="Port for --interactive web server.", type=int, default=8050, metavar="PORT", ), Argument( name="host", - help="Host/bind address for the interactive web server.", + help="Bind address for --interactive web server.", default="127.0.0.1", metavar="HOST", ), @@ -1090,26 +1090,32 @@ Example("./mfc.sh viz case_dir/ --var pres --step 500 --slice-axis x --png", "3D: x-plane slice of pressure"), ], key_options=[ - ("-- Discovery --", ""), + ("-- Discovery (all modes) --", ""), ("--list-steps", "Print available timesteps and exit"), ("--list-vars", "Print available variable names and exit"), - ("-- Variable / step selection --", ""), + ("-- Data selection (all modes) --", ""), ("--var NAME", "Variable to plot (omit for tiled all-vars layout)"), ("--step STEP", "last (default), int, start:stop:stride, list, or 'all'"), - ("-- Output modes --", ""), - ("(default)", "Terminal UI — works over SSH, no browser needed (1D/2D)"), - ("--interactive / -i", "Dash web UI in browser (supports 1D/2D/3D)"), - ("--png", "Save PNG image(s) to case_dir/viz/; use -o DIR to change"), + ("-f, --format", "Force binary or silo (auto-detected if omitted)"), + ("-- Output mode --", ""), + ("(default)", "Terminal UI (1D/2D, works over SSH, no browser needed)"), + ("--interactive / -i", "Dash web UI (1D/2D/3D, needs browser or SSH tunnel)"), + ("--png", "Save PNG image(s) to case_dir/viz/"), ("--mp4", "Encode frames into an MP4 video"), - ("-- Appearance --", ""), + ("-- Appearance (--interactive, --png, --mp4) --", ""), ("--cmap NAME", "Matplotlib colormap (default: viridis)"), ("--vmin / --vmax", "Fix color-scale limits"), ("--log-scale", "Logarithmic color/y axis"), - ("--dpi N", "Image resolution (default: 150)"), - ("-- 3D options --", ""), + ("--dpi N", "Image resolution for --png/--mp4 (default: 150)"), + ("-- 3D slicing (--interactive, --png, --mp4) --", ""), ("--slice-axis x|y|z", "Plane to slice (default: z midplane)"), ("--slice-value VAL", "Slice at coordinate value"), ("--slice-index IDX", "Slice at array index"), + ("-- Mode-specific --", ""), + ("-o, --output DIR", "Output directory for --png/--mp4 (default: case_dir/viz/)"), + ("--fps N", "Frames per second for --mp4 (default: 10)"), + ("--port PORT", "Web server port for --interactive (default: 8050)"), + ("--host HOST", "Bind address for --interactive (default: 127.0.0.1)"), ], ) From 632199416fa8b3c7f1528afd626efe77b10eec7b Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 19:57:42 -0500 Subject: [PATCH 099/102] viz: add viz to primary commands list in main help Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/user_guide.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolchain/mfc/user_guide.py b/toolchain/mfc/user_guide.py index f30201b892..567ffe6887 100644 --- a/toolchain/mfc/user_guide.py +++ b/toolchain/mfc/user_guide.py @@ -399,7 +399,7 @@ def print_help(): cons.print("[bold]Commands:[/bold]") # Primary commands (shown prominently with aliases) - primary = ["build", "run", "test", "validate", "new", "clean"] + primary = ["build", "run", "test", "viz", "validate", "new", "clean"] for cmd in primary: if cmd not in COMMANDS: continue From d1ccb3d01062880d4599e2fec66b7677e01044c7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 20:05:06 -0500 Subject: [PATCH 100/102] viz: color section headers yellow in Key Options help Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/user_guide.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/toolchain/mfc/user_guide.py b/toolchain/mfc/user_guide.py index 567ffe6887..1beb7cc4d2 100644 --- a/toolchain/mfc/user_guide.py +++ b/toolchain/mfc/user_guide.py @@ -459,7 +459,10 @@ def print_command_help(command: str, show_argparse: bool = True): if cmd.get("key_options"): cons.print("[bold]Key Options:[/bold]") for opt, desc in cmd["key_options"]: - cons.print(f" [cyan]{opt:24}[/cyan] {desc}") + if opt.startswith("-- ") and opt.endswith(" --"): + cons.print(f" [bold yellow]{opt}[/bold yellow]") + else: + cons.print(f" [cyan]{opt:24}[/cyan] {desc}") cons.print() if show_argparse: cons.print("[dim]Run with --help for full option list[/dim]") From 64257b40198543e18890c9366c9ac77ad6a03a61 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 20:15:22 -0500 Subject: [PATCH 101/102] viz: limit rendering/slice CLI opts to --png/--mp4 only 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 --- toolchain/mfc/cli/commands.py | 31 ++++++++++++----------- toolchain/mfc/viz/viz.py | 47 +++++++++++++++++------------------ 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index 1b07748b5e..5384425c7d 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -935,7 +935,7 @@ ), Argument( name="cmap", - help="Matplotlib colormap name (--interactive, --png, --mp4).", + help="Matplotlib colormap name (--png, --mp4 only).", type=str, default='viridis', metavar="CMAP", @@ -967,14 +967,14 @@ ), Argument( name="vmin", - help="Minimum value for color scale (--interactive, --png, --mp4).", + help="Minimum value for color scale (--png, --mp4 only).", type=float, default=None, metavar="VMIN", ), Argument( name="vmax", - help="Maximum value for color scale (--interactive, --png, --mp4).", + help="Maximum value for color scale (--png, --mp4 only).", type=float, default=None, metavar="VMAX", @@ -988,7 +988,7 @@ ), Argument( name="slice-axis", - help="Axis for 3D slice (--interactive, --png, --mp4).", + help="Axis for 3D slice (--png, --mp4 only).", type=str, default='z', choices=["x", "y", "z"], @@ -997,7 +997,7 @@ ), Argument( name="slice-value", - help="Coordinate value at which to take the 3D slice (--interactive, --png, --mp4).", + help="Coordinate value at which to take the 3D slice (--png, --mp4 only).", type=float, default=None, dest="slice_value", @@ -1005,7 +1005,7 @@ ), Argument( name="slice-index", - help="Array index at which to take the 3D slice (--interactive, --png, --mp4).", + help="Array index at which to take the 3D slice (--png, --mp4 only).", type=int, default=None, dest="slice_index", @@ -1040,7 +1040,7 @@ ), Argument( name="log-scale", - help="Logarithmic color/y scale (--interactive, --png, --mp4).", + help="Logarithmic color/y scale (--png, --mp4 only).", action=ArgAction.STORE_TRUE, default=False, dest="log_scale", @@ -1102,20 +1102,21 @@ ("--interactive / -i", "Dash web UI (1D/2D/3D, needs browser or SSH tunnel)"), ("--png", "Save PNG image(s) to case_dir/viz/"), ("--mp4", "Encode frames into an MP4 video"), - ("-- Appearance (--interactive, --png, --mp4) --", ""), + ("-- Rendering (--png, --mp4 only) --", ""), ("--cmap NAME", "Matplotlib colormap (default: viridis)"), ("--vmin / --vmax", "Fix color-scale limits"), ("--log-scale", "Logarithmic color/y axis"), - ("--dpi N", "Image resolution for --png/--mp4 (default: 150)"), - ("-- 3D slicing (--interactive, --png, --mp4) --", ""), + ("--dpi N", "Image resolution (default: 150)"), + ("-o, --output DIR", "Output directory (default: case_dir/viz/)"), + ("-- 3D slicing (--png, --mp4 only) --", ""), ("--slice-axis x|y|z", "Plane to slice (default: z midplane)"), ("--slice-value VAL", "Slice at coordinate value"), ("--slice-index IDX", "Slice at array index"), - ("-- Mode-specific --", ""), - ("-o, --output DIR", "Output directory for --png/--mp4 (default: case_dir/viz/)"), - ("--fps N", "Frames per second for --mp4 (default: 10)"), - ("--port PORT", "Web server port for --interactive (default: 8050)"), - ("--host HOST", "Bind address for --interactive (default: 127.0.0.1)"), + ("-- --mp4 only --", ""), + ("--fps N", "Frames per second (default: 10)"), + ("-- --interactive only --", ""), + ("--port PORT", "Web server port (default: 8050)"), + ("--host HOST", "Bind address (default: 127.0.0.1)"), ], ) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index 0c3276bafa..b080a6a1e6 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -287,25 +287,6 @@ def viz(): # pylint: disable=too-many-locals,too-many-statements,too-many-branc f"No matching timesteps for --step {step_arg!r}{detail}. " f"Available steps: {_steps_hint(steps)}") - # Collect rendering options - render_opts = { - 'cmap': ARG('cmap'), - 'dpi': ARG('dpi'), - 'slice_axis': ARG('slice_axis'), - } - if ARG('vmin') is not None: - render_opts['vmin'] = float(ARG('vmin')) - if ARG('vmax') is not None: - render_opts['vmax'] = float(ARG('vmax')) - if ARG('log_scale'): - render_opts['log_scale'] = True - if ARG('slice_index') is not None and ARG('slice_value') is not None: - raise MFCException("--slice-index and --slice-value are mutually exclusive.") - if ARG('slice_index') is not None: - render_opts['slice_index'] = int(ARG('slice_index')) - if ARG('slice_value') is not None: - render_opts['slice_value'] = float(ARG('slice_value')) - interactive = ARG('interactive') # Lagrange bubble overlay: auto-detect D/lag_bubble_evol_*.dat files @@ -364,11 +345,6 @@ def read_step(step): f"Use --list-vars to see variables at a given step." ) - # Validate colormap early so all modes get a clean error for bad --cmap - cmap_name = ARG('cmap') - if cmap_name: - _validate_cmap(cmap_name) - # TUI mode — launch Textual terminal UI (1D/2D only) if use_tui: if test_assembled.ndim == 3: @@ -394,6 +370,29 @@ def read_step(step): bubble_func=bubble_func) return + # --- PNG / MP4 rendering options (not used by TUI or interactive) --- + render_opts = { + 'cmap': ARG('cmap'), + 'dpi': ARG('dpi'), + 'slice_axis': ARG('slice_axis'), + } + if ARG('vmin') is not None: + render_opts['vmin'] = float(ARG('vmin')) + if ARG('vmax') is not None: + render_opts['vmax'] = float(ARG('vmax')) + if ARG('log_scale'): + render_opts['log_scale'] = True + if ARG('slice_index') is not None and ARG('slice_value') is not None: + raise MFCException("--slice-index and --slice-value are mutually exclusive.") + if ARG('slice_index') is not None: + render_opts['slice_index'] = int(ARG('slice_index')) + if ARG('slice_value') is not None: + render_opts['slice_value'] = float(ARG('slice_value')) + + cmap_name = ARG('cmap') + if cmap_name: + _validate_cmap(cmap_name) + # Create output directory output_base = ARG('output') if output_base is None: From ced26e4e29fc82553d7b1cd519119c1ad18050e7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 20:41:12 -0500 Subject: [PATCH 102/102] viz: warn when --slice-* flags are ignored in --interactive mode Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/viz/viz.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/toolchain/mfc/viz/viz.py b/toolchain/mfc/viz/viz.py index b080a6a1e6..73520eec1b 100644 --- a/toolchain/mfc/viz/viz.py +++ b/toolchain/mfc/viz/viz.py @@ -360,6 +360,13 @@ def read_step(step): # Interactive mode — launch Dash web server if interactive: + ignored = [f for f in ('slice_index', 'slice_value') + if ARG(f) is not None] + if ARG('slice_axis') != 'z': + ignored.insert(0, 'slice_axis') + if ignored: + cons.print(f"[yellow]Warning:[/yellow] {', '.join('--' + f.replace('_', '-') for f in ignored)} " + "ignored in --interactive mode (use the UI controls instead).") from .interactive import run_interactive # pylint: disable=import-outside-toplevel port = ARG('port') host = ARG('host')