From 562919ff62a758fa30f043fc70702d4e46a24b26 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 21 Feb 2026 09:57:12 -0500 Subject: [PATCH 01/69] 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 02/69] 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 03/69] 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 04/69] 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 05/69] 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 06/69] 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 07/69] 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 08/69] 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 09/69] 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 10/69] 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 11/69] 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 12/69] 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 13/69] 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 14/69] 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 15/69] 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 16/69] 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 17/69] 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 18/69] 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 19/69] 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 20/69] 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 21/69] 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 22/69] 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 23/69] 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 24/69] 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 25/69] 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 26/69] 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 27/69] 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 28/69] 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 29/69] 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 30/69] 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 31/69] 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 32/69] 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 33/69] 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 34/69] 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 35/69] 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 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 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 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 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 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 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 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 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 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 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 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 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 36/69] 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 37/69] 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 38/69] 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 39/69] 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 40/69] 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 41/69] 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 42/69] 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 43/69] 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 44/69] 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 45/69] 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 46/69] 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 47/69] 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 48/69] 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 49/69] 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 50/69] 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 51/69] 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 52/69] 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 53/69] 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 54/69] 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 55/69] 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 56/69] 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 57/69] 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 58/69] 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 59/69] 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 60/69] 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 61/69] 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 62/69] 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 63/69] 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 64/69] 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 65/69] 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 66/69] 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 67/69] 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 68/69] 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 69/69] 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}"