From 2ebdf36b88fab0dd6fdd4631e507a119cee97b3e Mon Sep 17 00:00:00 2001 From: shaia Date: Sat, 7 Mar 2026 18:16:09 +0200 Subject: [PATCH 1/9] feat: Add unified cfd-viz CLI with entry point and batch processing (Phase 4) Register cfd-viz as a package entry point in pyproject.toml and create a unified CLI with argparse subcommands (animate, dashboard, vorticity, profiles, monitor, info, batch) dispatching to existing scripts. Add batch processing module for TOML-configured multi-file operations with stderr progress indicators. Includes 23 tests covering parser structure, command dispatch, batch execution, and entry point verification. --- ROADMAP.md | 28 ++--- cfd_viz/_batch.py | 161 ++++++++++++++++++++++++++ cfd_viz/cli.py | 253 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 + scripts/__init__.py | 0 tests/test_cli.py | 274 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 701 insertions(+), 18 deletions(-) create mode 100644 cfd_viz/_batch.py create mode 100644 cfd_viz/cli.py create mode 100644 scripts/__init__.py create mode 100644 tests/test_cli.py diff --git a/ROADMAP.md b/ROADMAP.md index 2c838b2..80d1aaf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ This document outlines planned enhancements for cfd-visualization, organized by priority and release target. -**Last Updated:** 2026-03-05 +**Last Updated:** 2026-03-07 **Current Version:** 0.1.0 --- @@ -75,26 +75,18 @@ The VTK reader had zero dedicated tests and VTKData accepted any data without va --- -## Phase 4: CLI Tooling & Entry Points +## Phase 4: CLI Tooling & Entry Points - COMPLETE -**Priority:** P1 - Registers existing orphaned code -**Target:** v0.3.0 - -Five scripts exist in `scripts/` but are not registered as package entry points. Users must run `python scripts/create_animation.py` instead of a proper CLI command. - -### Tasks - -- [ ] **4.1 Register entry points** in `pyproject.toml` - `[project.scripts]` section -- [ ] **4.2 Create unified `cfd-viz` CLI** - single entry point with argparse subcommands dispatching to existing scripts -- [ ] **4.3 Add batch processing** - `cfd-viz batch --config batch.toml` for processing multiple VTK files in one run -- [ ] **4.4 Add `cfd-viz info`** - wraps existing `print_system_info()`, shows backends and optional deps -- [ ] **4.5 Add progress indicators** - simple stderr output for batch operations and animation rendering (no extra dependency) +**Status:** Completed (2026-03-07) -### Success Criteria +Five scripts existed in `scripts/` but were not registered as package entry points. This phase added a unified `cfd-viz` CLI with argparse subcommands dispatching to existing scripts, batch processing, and progress indicators. -- `pip install -e .` makes `cfd-viz` command available -- `cfd-viz info` prints system capabilities -- `cfd-viz batch` processes multiple files from config +- [x] **4.1 Register entry points** in `pyproject.toml` - `[project.scripts]` section, `cfd-viz = "cfd_viz.cli:main"` +- [x] **4.2 Create unified `cfd-viz` CLI** (`cfd_viz/cli.py`) - single entry point with subcommands: `animate`, `dashboard`, `vorticity`, `profiles`, `monitor`, `info`, `batch` +- [x] **4.3 Add batch processing** (`cfd_viz/_batch.py`) - `cfd-viz batch --config batch.toml` for processing multiple VTK files with TOML config +- [x] **4.4 Add `cfd-viz info`** - wraps existing `print_system_info()`, shows backends and optional deps +- [x] **4.5 Add progress indicators** - simple stderr progress bar for batch operations (no extra dependency) +- [x] **4.6 Add CLI tests** (`tests/test_cli.py`) - 23 tests covering parser structure, dispatch, batch processing, progress, and entry point registration --- diff --git a/cfd_viz/_batch.py b/cfd_viz/_batch.py new file mode 100644 index 0000000..18f029e --- /dev/null +++ b/cfd_viz/_batch.py @@ -0,0 +1,161 @@ +"""Batch processing for multiple VTK files from a TOML config. + +Config format (``batch.toml``):: + + [batch] + output_dir = "output/batch" + + [[batch.jobs]] + vtk = "data/vtk_files/flow_0100.vtk" + analyses = ["vorticity", "profiles"] + + [[batch.jobs]] + vtk = "data/vtk_files/flow_0200.vtk" + analyses = ["vorticity"] + output_dir = "output/batch/step200" # per-job override + + [[batch.jobs]] + vtk_pattern = "data/vtk_files/flow_*.vtk" + analyses = ["animate"] + animate_type = "velocity" + fps = 10 + +Supported analyses: ``vorticity``, ``profiles``, ``animate``. +""" + +import os +import sys + + +def _load_toml(path): + """Load a TOML file, using tomllib (3.11+) or tomli.""" + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + with open(path, "rb") as f: + return tomllib.load(f) + + +def _progress(current, total, label=""): + """Write a simple progress line to stderr.""" + width = 30 + filled = int(width * current / total) if total > 0 else width + bar = "#" * filled + "-" * (width - filled) + pct = (current / total * 100) if total > 0 else 100 + sys.stderr.write(f"\r [{bar}] {pct:5.1f}% {label}") + if current >= total: + sys.stderr.write("\n") + sys.stderr.flush() + + +def _run_vorticity(vtk_file, output_dir): + from cfd_viz.common import ensure_dirs, read_vtk_file + + ensure_dirs() + data = read_vtk_file(vtk_file) + if data is None: + print(f" Warning: could not read {vtk_file}") + return + + if data.u is None or data.v is None: + print(f" Warning: no velocity data in {vtk_file}") + return + + from scripts.create_vorticity_analysis import ( + create_vorticity_visualization, + ) + + data_dict = data.to_dict() + create_vorticity_visualization(data_dict, output_dir) + + +def _run_profiles(vtk_file, output_dir): + from cfd_viz.common import ensure_dirs, read_vtk_file as _read + + ensure_dirs() + data = _read(vtk_file) + if data is None: + print(f" Warning: could not read {vtk_file}") + return + + from scripts.create_line_profiles import ( + create_cross_section_analysis, + read_vtk_file, + ) + + data_dict = read_vtk_file(vtk_file) + if data_dict is None: + return + create_cross_section_analysis(data_dict, output_dir) + + +def _run_animate(vtk_files, output_dir, animate_type="velocity", fps=5): + from scripts.create_animation import ( + create_and_save_animation, + load_vtk_files_to_frames, + ) + + frames = load_vtk_files_to_frames(vtk_files) + output_path = os.path.join(output_dir, f"cfd_{animate_type}.gif") + create_and_save_animation(frames, animate_type, output_path, fps=fps) + + +def run_batch(config_path): + """Execute a batch config file.""" + import glob as globmod + + cfg = _load_toml(config_path) + batch = cfg.get("batch", {}) + global_output = batch.get("output_dir", "output/batch") + jobs = batch.get("jobs", []) + + if not jobs: + print("No jobs defined in config.") + return + + total = len(jobs) + print(f"Batch: {total} job(s) from {config_path}") + + for idx, job in enumerate(jobs, 1): + analyses = job.get("analyses", []) + job_output = job.get("output_dir", global_output) + os.makedirs(job_output, exist_ok=True) + + # Resolve VTK files + vtk_files = [] + if "vtk" in job: + vtk_files = [job["vtk"]] + elif "vtk_pattern" in job: + vtk_files = sorted(globmod.glob(job["vtk_pattern"])) + + if not vtk_files: + print(f" Job {idx}/{total}: no VTK files found, skipping") + _progress(idx, total, "skipped") + continue + + label = ( + os.path.basename(vtk_files[0]) + if len(vtk_files) == 1 + else f"{len(vtk_files)} files" + ) + print(f" Job {idx}/{total}: {label} analyses={analyses}") + + for analysis in analyses: + if analysis == "vorticity": + for vtk_file in vtk_files: + _run_vorticity(vtk_file, job_output) + elif analysis == "profiles": + for vtk_file in vtk_files: + _run_profiles(vtk_file, job_output) + elif analysis == "animate": + animate_type = job.get("animate_type", "velocity") + fps = job.get("fps", 5) + _run_animate(vtk_files, job_output, animate_type, fps) + else: + print(f" Warning: unknown analysis '{analysis}', skipping") + + _progress(idx, total, label) + + print("Batch processing complete.") diff --git a/cfd_viz/cli.py b/cfd_viz/cli.py new file mode 100644 index 0000000..aa65499 --- /dev/null +++ b/cfd_viz/cli.py @@ -0,0 +1,253 @@ +""" +Unified CLI for cfd-visualization. + +Provides ``cfd-viz`` command with subcommands that dispatch to existing +scripts and library functions. + +Usage:: + + cfd-viz info + cfd-viz animate output/*.vtk --type velocity + cfd-viz dashboard --vtk-pattern "output/*.vtk" + cfd-viz vorticity data/flow.vtk + cfd-viz profiles data/flow.vtk + cfd-viz monitor --watch-dir data/vtk_files + cfd-viz batch --config batch.toml +""" + +import argparse +import importlib +import sys + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="cfd-viz", + description="CFD visualization toolkit", + ) + sub = parser.add_subparsers(dest="command") + + # ── info ────────────────────────────────────────────────────────── + sub.add_parser( + "info", + help="Print system capabilities, backends, and optional dependencies", + ) + + # ── animate ─────────────────────────────────────────────────────── + p_anim = sub.add_parser( + "animate", + help="Create animations from VTK time-series files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Animation types:\n" + " velocity, field, streamlines, vectors,\n" + " dashboard, vorticity, 3d, particles" + ), + ) + p_anim.add_argument( + "vtk_files", nargs="+", help="VTK files (glob patterns supported)" + ) + p_anim.add_argument( + "--type", + "-t", + choices=[ + "velocity", + "field", + "streamlines", + "vectors", + "dashboard", + "vorticity", + "3d", + "particles", + ], + default="velocity", + help="Animation type (default: velocity)", + ) + p_anim.add_argument( + "--field", "-f", default="velocity_mag", help="Field for field animations" + ) + p_anim.add_argument("--output", "-o", default=None, help="Output path") + p_anim.add_argument("--fps", type=int, default=5, help="Frames per second") + p_anim.add_argument( + "--export-frames", action="store_true", help="Export individual frames" + ) + p_anim.add_argument("--all", action="store_true", help="Create all animation types") + + # ── dashboard ───────────────────────────────────────────────────── + p_dash = sub.add_parser( + "dashboard", help="Create interactive Plotly dashboards from VTK files" + ) + p_dash.add_argument( + "--vtk-pattern", + default="output/output_optimized_*.vtk", + help="Glob pattern for VTK files", + ) + p_dash.add_argument( + "--output-dir", default="visualization_output", help="Output directory" + ) + p_dash.add_argument( + "--auto-open", action="store_true", help="Auto-open HTML in browser" + ) + + # ── vorticity ───────────────────────────────────────────────────── + p_vort = sub.add_parser("vorticity", help="Vorticity and circulation analysis") + p_vort.add_argument("input_file", nargs="?", help="VTK file to analyze") + p_vort.add_argument("--output", "-o", default=None, help="Output directory") + p_vort.add_argument( + "--latest", "-l", action="store_true", help="Use latest VTK file" + ) + + # ── profiles ────────────────────────────────────────────────────── + p_prof = sub.add_parser("profiles", help="Cross-sectional line profile analysis") + p_prof.add_argument("input_file", nargs="?", help="VTK file to analyze") + p_prof.add_argument("--output", "-o", default=None, help="Output directory") + p_prof.add_argument( + "--interactive", "-i", action="store_true", help="Interactive line analysis" + ) + p_prof.add_argument( + "--latest", "-l", action="store_true", help="Use latest VTK file" + ) + + # ── monitor ─────────────────────────────────────────────────────── + p_mon = sub.add_parser("monitor", help="Real-time simulation monitoring dashboard") + p_mon.add_argument( + "--watch-dir", "-w", default=None, help="Directory to watch for VTK files" + ) + p_mon.add_argument("--output", "-o", default=None, help="Output directory") + p_mon.add_argument( + "--interval", type=float, default=2.0, help="Polling interval (seconds)" + ) + p_mon.add_argument( + "--manual", "-m", action="store_true", help="Manual polling mode" + ) + + # ── batch ───────────────────────────────────────────────────────── + p_batch = sub.add_parser( + "batch", + help="Batch-process multiple VTK files from a TOML config", + ) + p_batch.add_argument( + "--config", + "-c", + required=True, + help="Path to batch config file (TOML)", + ) + + return parser + + +# ── helpers ────────────────────────────────────────────────────────── + + +def _run_script_main(module_name, argv): + """Import a script module and call its ``main()`` after overriding sys.argv.""" + old_argv = sys.argv + sys.argv = ["cfd-viz", *argv] + try: + mod = importlib.import_module(module_name) + mod.main() + finally: + sys.argv = old_argv + + +def _build_animate_argv(args): + argv = list(args.vtk_files) + argv += ["--type", args.type] + argv += ["--field", args.field] + argv += ["--fps", str(args.fps)] + if args.output: + argv += ["--output", args.output] + if args.export_frames: + argv.append("--export-frames") + if getattr(args, "all", False): + argv.append("--all") + return argv + + +# ── subcommand handlers ────────────────────────────────────────────── + + +def _cmd_info(_args): + from cfd_viz.info import print_system_info + + print_system_info() + + +def _cmd_animate(args): + _run_script_main("scripts.create_animation", _build_animate_argv(args)) + + +def _cmd_dashboard(args): + argv = ["--vtk-pattern", args.vtk_pattern, "--output-dir", args.output_dir] + if args.auto_open: + argv.append("--auto-open") + _run_script_main("scripts.create_dashboard", argv) + + +def _cmd_vorticity(args): + argv = [] + if args.input_file: + argv.append(args.input_file) + if args.output: + argv += ["--output", args.output] + if args.latest: + argv.append("--latest") + _run_script_main("scripts.create_vorticity_analysis", argv) + + +def _cmd_profiles(args): + argv = [] + if args.input_file: + argv.append(args.input_file) + if args.output: + argv += ["--output", args.output] + if args.interactive: + argv.append("--interactive") + if args.latest: + argv.append("--latest") + _run_script_main("scripts.create_line_profiles", argv) + + +def _cmd_monitor(args): + argv = [] + if args.watch_dir: + argv += ["--watch_dir", args.watch_dir] + if args.output: + argv += ["--output", args.output] + argv += ["--interval", str(args.interval)] + if args.manual: + argv.append("--manual") + _run_script_main("scripts.create_monitor", argv) + + +def _cmd_batch(args): + from cfd_viz._batch import run_batch + + run_batch(args.config) + + +_DISPATCH = { + "info": _cmd_info, + "animate": _cmd_animate, + "dashboard": _cmd_dashboard, + "vorticity": _cmd_vorticity, + "profiles": _cmd_profiles, + "monitor": _cmd_monitor, + "batch": _cmd_batch, +} + + +def main(argv=None): + parser = _build_parser() + args = parser.parse_args(argv) + + if args.command is None: + parser.print_help() + raise SystemExit(0) + + handler = _DISPATCH.get(args.command) + if handler is None: + parser.print_help() + raise SystemExit(1) + + handler(args) diff --git a/pyproject.toml b/pyproject.toml index 48427bf..b1daff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ interactive = ["plotly", "dash"] simulation = ["cfd-python>=0.1.6"] full = ["plotly", "dash", "cfd-python>=0.1.6"] +[project.scripts] +cfd-viz = "cfd_viz.cli:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..571a69a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,274 @@ +"""Tests for the unified cfd-viz CLI.""" + +import os +import textwrap +from unittest import mock + +import pytest + +from cfd_viz.cli import _build_parser, main + + +class TestParserStructure: + """Verify parser accepts expected arguments without executing anything.""" + + def test_no_args_exits_zero(self): + with pytest.raises(SystemExit) as exc: + main([]) + assert exc.value.code == 0 + + def test_help_exits(self): + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + + def test_info_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args(["info"]) + assert args.command == "info" + + def test_animate_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args(["animate", "a.vtk", "--type", "velocity"]) + assert args.command == "animate" + assert args.vtk_files == ["a.vtk"] + assert args.type == "velocity" + + def test_animate_defaults(self): + parser = _build_parser() + args = parser.parse_args(["animate", "a.vtk"]) + assert args.type == "velocity" + assert args.field == "velocity_mag" + assert args.fps == 5 + assert args.output is None + assert args.export_frames is False + + def test_animate_all_options(self): + parser = _build_parser() + args = parser.parse_args( + [ + "animate", + "a.vtk", + "b.vtk", + "--type", + "streamlines", + "--field", + "pressure", + "--output", + "out.gif", + "--fps", + "10", + "--export-frames", + "--all", + ] + ) + assert args.vtk_files == ["a.vtk", "b.vtk"] + assert args.type == "streamlines" + assert args.field == "pressure" + assert args.output == "out.gif" + assert args.fps == 10 + assert args.export_frames is True + + def test_dashboard_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args( + [ + "dashboard", + "--vtk-pattern", + "data/*.vtk", + "--auto-open", + ] + ) + assert args.command == "dashboard" + assert args.vtk_pattern == "data/*.vtk" + assert args.auto_open is True + + def test_dashboard_defaults(self): + parser = _build_parser() + args = parser.parse_args(["dashboard"]) + assert args.vtk_pattern == "output/output_optimized_*.vtk" + assert args.output_dir == "visualization_output" + assert args.auto_open is False + + def test_vorticity_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args(["vorticity", "flow.vtk", "-o", "out"]) + assert args.command == "vorticity" + assert args.input_file == "flow.vtk" + assert args.output == "out" + + def test_vorticity_latest_flag(self): + parser = _build_parser() + args = parser.parse_args(["vorticity", "--latest"]) + assert args.latest is True + assert args.input_file is None + + def test_profiles_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args(["profiles", "f.vtk", "--interactive"]) + assert args.command == "profiles" + assert args.input_file == "f.vtk" + assert args.interactive is True + + def test_monitor_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args( + [ + "monitor", + "--watch-dir", + "/tmp/data", + "--interval", + "5", + ] + ) + assert args.command == "monitor" + assert args.watch_dir == "/tmp/data" + assert args.interval == 5.0 + + def test_monitor_defaults(self): + parser = _build_parser() + args = parser.parse_args(["monitor"]) + assert args.watch_dir is None + assert args.output is None + assert args.interval == 2.0 + assert args.manual is False + + def test_batch_requires_config(self): + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["batch"]) + + def test_batch_subcommand_parsed(self): + parser = _build_parser() + args = parser.parse_args(["batch", "--config", "batch.toml"]) + assert args.command == "batch" + assert args.config == "batch.toml" + + +class TestInfoCommand: + """Test that ``cfd-viz info`` calls print_system_info.""" + + def test_info_dispatches(self): + with mock.patch("cfd_viz.info.print_system_info") as mock_print: + main(["info"]) + mock_print.assert_called_once() + + +class TestBatchProcessing: + """Test batch config loading and execution.""" + + def test_empty_batch(self, tmp_path): + config = tmp_path / "batch.toml" + config.write_text("[batch]\njobs = []\n") + + from cfd_viz._batch import run_batch + + # Should print "No jobs" and return without error + run_batch(str(config)) + + def test_batch_missing_vtk(self, tmp_path): + config = tmp_path / "batch.toml" + config.write_text( + textwrap.dedent("""\ + [batch] + output_dir = "{out}" + + [[batch.jobs]] + vtk = "nonexistent.vtk" + analyses = ["vorticity"] + """).format(out=str(tmp_path / "out").replace("\\", "/")) + ) + + from cfd_viz._batch import run_batch + + # Should handle missing file gracefully (warning, not crash) + run_batch(str(config)) + + def test_batch_unknown_analysis(self, tmp_path, capsys): + config = tmp_path / "batch.toml" + vtk_file = tmp_path / "dummy.vtk" + vtk_file.write_text("") # empty file + + config.write_text( + textwrap.dedent("""\ + [batch] + output_dir = "{out}" + + [[batch.jobs]] + vtk = "{vtk}" + analyses = ["unknown_analysis"] + """).format( + out=str(tmp_path / "out").replace("\\", "/"), + vtk=str(vtk_file).replace("\\", "/"), + ) + ) + + from cfd_viz._batch import run_batch + + run_batch(str(config)) + captured = capsys.readouterr() + assert ( + "unknown analysis" in captured.out.lower() + or "unknown_analysis" in captured.out + ) + + def test_batch_vtk_pattern(self, tmp_path): + config = tmp_path / "batch.toml" + config.write_text( + textwrap.dedent("""\ + [batch] + output_dir = "{out}" + + [[batch.jobs]] + vtk_pattern = "{pattern}" + analyses = ["vorticity"] + """).format( + out=str(tmp_path / "out").replace("\\", "/"), + pattern=str(tmp_path / "*.vtk").replace("\\", "/"), + ) + ) + + from cfd_viz._batch import run_batch + + # No matching files — should skip gracefully + run_batch(str(config)) + + +class TestProgressIndicator: + """Test the progress bar helper.""" + + def test_progress_writes_to_stderr(self, capsys): + from cfd_viz._batch import _progress + + _progress(1, 2, "test") + captured = capsys.readouterr() + assert "test" in captured.err + assert "%" in captured.err + + def test_progress_complete(self, capsys): + from cfd_viz._batch import _progress + + _progress(5, 5, "done") + captured = capsys.readouterr() + assert "100" in captured.err + + +class TestEntryPoint: + """Verify the entry point registration in pyproject.toml.""" + + def test_entry_point_in_pyproject(self): + # Read pyproject.toml to verify entry point is registered + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib + + pyproject_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "pyproject.toml" + ) + with open(pyproject_path, "rb") as f: + config = tomllib.load(f) + + scripts = config.get("project", {}).get("scripts", {}) + assert "cfd-viz" in scripts + assert scripts["cfd-viz"] == "cfd_viz.cli:main" From ab174eae1461522bd5b4998c0a6ff384f9796965 Mon Sep 17 00:00:00 2001 From: shaia Date: Sun, 8 Mar 2026 22:35:41 +0200 Subject: [PATCH 2/9] fix: Remove scripts/ dependency from CLI for wheel compatibility Move CLI helper functions from scripts/ into cfd_viz/_cli_impl.py so the installed wheel works without unpackaged script modules. Also: - Wrap animate in try/except for batch error resilience - Handle missing/invalid config file with clear error messages - Eliminate double VTK read in batch profiles analysis --- cfd_viz/_batch.py | 48 +-- cfd_viz/_cli_impl.py | 808 +++++++++++++++++++++++++++++++++++++++++++ cfd_viz/cli.py | 182 ++++++---- pyproject.toml | 2 + 4 files changed, 956 insertions(+), 84 deletions(-) create mode 100644 cfd_viz/_cli_impl.py diff --git a/cfd_viz/_batch.py b/cfd_viz/_batch.py index 18f029e..4b015cb 100644 --- a/cfd_viz/_batch.py +++ b/cfd_viz/_batch.py @@ -51,6 +51,7 @@ def _progress(current, total, label=""): def _run_vorticity(vtk_file, output_dir): + from cfd_viz._cli_impl import create_vorticity_visualization from cfd_viz.common import ensure_dirs, read_vtk_file ensure_dirs() @@ -63,39 +64,32 @@ def _run_vorticity(vtk_file, output_dir): print(f" Warning: no velocity data in {vtk_file}") return - from scripts.create_vorticity_analysis import ( - create_vorticity_visualization, - ) - - data_dict = data.to_dict() - create_vorticity_visualization(data_dict, output_dir) + create_vorticity_visualization(data.to_dict(), output_dir) def _run_profiles(vtk_file, output_dir): - from cfd_viz.common import ensure_dirs, read_vtk_file as _read + from cfd_viz._cli_impl import ( + _vtk_to_profiles_dict, + create_cross_section_analysis, + ) + from cfd_viz.common import ensure_dirs, read_vtk_file ensure_dirs() - data = _read(vtk_file) + data = read_vtk_file(vtk_file) if data is None: print(f" Warning: could not read {vtk_file}") return - from scripts.create_line_profiles import ( - create_cross_section_analysis, - read_vtk_file, - ) - - data_dict = read_vtk_file(vtk_file) + data_dict = _vtk_to_profiles_dict(data) if data_dict is None: + print(f" Warning: no velocity data in {vtk_file}") return + create_cross_section_analysis(data_dict, output_dir) def _run_animate(vtk_files, output_dir, animate_type="velocity", fps=5): - from scripts.create_animation import ( - create_and_save_animation, - load_vtk_files_to_frames, - ) + from cfd_viz._cli_impl import create_and_save_animation, load_vtk_files_to_frames frames = load_vtk_files_to_frames(vtk_files) output_path = os.path.join(output_dir, f"cfd_{animate_type}.gif") @@ -106,7 +100,15 @@ def run_batch(config_path): """Execute a batch config file.""" import glob as globmod - cfg = _load_toml(config_path) + try: + cfg = _load_toml(config_path) + except FileNotFoundError: + print(f"Error: config file not found: {config_path}", file=sys.stderr) + raise SystemExit(2) from None + except Exception as exc: + print(f"Error: failed to parse config: {exc}", file=sys.stderr) + raise SystemExit(2) from None + batch = cfg.get("batch", {}) global_output = batch.get("output_dir", "output/batch") jobs = batch.get("jobs", []) @@ -152,7 +154,13 @@ def run_batch(config_path): elif analysis == "animate": animate_type = job.get("animate_type", "velocity") fps = job.get("fps", 5) - _run_animate(vtk_files, job_output, animate_type, fps) + try: + _run_animate(vtk_files, job_output, animate_type, fps) + except Exception as exc: + print( + f" Warning: animate analysis failed for job" + f" {idx}/{total}: {exc}" + ) else: print(f" Warning: unknown analysis '{analysis}', skipping") diff --git a/cfd_viz/_cli_impl.py b/cfd_viz/_cli_impl.py new file mode 100644 index 0000000..eb216e4 --- /dev/null +++ b/cfd_viz/_cli_impl.py @@ -0,0 +1,808 @@ +"""Internal helpers for CLI and batch commands. + +Contains functions moved from ``scripts/`` so the installed wheel can run +all ``cfd-viz`` subcommands without depending on unpackaged scripts. +""" + +import os +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np + +# ── VTK input file resolution ──────────────────────────────────────── + + +def resolve_vtk_input(input_file=None, latest=False): + """Resolve a VTK input file from CLI arguments. + + Returns the path string, or None if nothing was found. + """ + from cfd_viz.common import DATA_DIR, find_vtk_files + + if latest: + vtk_files = find_vtk_files() + if not vtk_files: + print(f"No VTK files found in {DATA_DIR}") + return None + path = str(max(vtk_files, key=lambda f: f.stat().st_ctime)) + print(f"Using latest file: {path}") + return path + + if input_file: + return input_file + + vtk_files = find_vtk_files() + if vtk_files: + path = str(vtk_files[0]) + print(f"Using file: {path}") + return path + + print("No VTK file specified. Use --help for usage information.") + return None + + +# ── Animation helpers (from scripts/create_animation.py) ───────────── + + +def extract_iteration_from_filename(filename, fallback): + """Extract iteration number from VTK filename.""" + try: + return int(os.path.basename(filename).split("_")[-1].split(".")[0]) + except ValueError: + return fallback + + +def load_vtk_files_to_frames(vtk_files): + """Load VTK files and create AnimationFrames.""" + from cfd_viz.animation import create_animation_frames + from cfd_viz.common import read_vtk_file + + frames_data = [] + time_indices = [] + + for filename in sorted(vtk_files): + try: + data = read_vtk_file(filename) + if data is None: + raise ValueError(f"Failed to read VTK file: {filename}") + X, Y, fields = data.X, data.Y, data.fields + u, v, p = fields.get("u"), fields.get("v"), fields.get("p") + if u is not None and v is not None: + frames_data.append((X, Y, u, v, p)) + time_indices.append( + extract_iteration_from_filename(filename, len(frames_data)) + ) + except Exception as e: + print(f"Warning: Error reading {filename}: {e}") + continue + + if not frames_data: + raise ValueError("No valid VTK files found with velocity data") + + _progress_stderr(len(vtk_files), len(vtk_files), "frames loaded") + return create_animation_frames(frames_data, time_indices=time_indices) + + +def create_and_save_animation( + animation_frames, animation_type, output_path, fps=5, field="velocity_mag" +): + """Create and save an animation.""" + from cfd_viz.animation import ( + advect_particles_through_frames, + create_3d_surface_animation, + create_field_animation, + create_multi_panel_animation, + create_particle_trace_animation, + create_streamline_animation, + create_vector_animation, + create_vorticity_analysis_animation, + save_animation, + ) + + print(f"Creating {animation_type} animation...") + + if animation_type in {"velocity", "field"}: + fig, anim = create_field_animation(animation_frames, field) + elif animation_type == "streamlines": + fig, anim = create_streamline_animation(animation_frames) + elif animation_type == "vectors": + fig, anim = create_vector_animation(animation_frames) + elif animation_type == "dashboard": + fig, anim = create_multi_panel_animation(animation_frames) + elif animation_type == "vorticity": + fig, anim = create_vorticity_analysis_animation(animation_frames) + elif animation_type == "3d": + fig, anim = create_3d_surface_animation(animation_frames, field) + elif animation_type == "particles": + traces = advect_particles_through_frames( + animation_frames, n_particles=50, dt=0.01, steps_per_frame=10 + ) + fig, anim = create_particle_trace_animation(animation_frames, traces) + else: + raise ValueError(f"Unknown animation type: {animation_type}") + + print(f"Saving animation to {output_path}...") + save_animation(anim, output_path, fps=fps) + plt.close(fig) + print("Animation saved!") + + +# ── Vorticity visualization (from scripts/create_vorticity_analysis.py) + + +def create_vorticity_visualization(data, output_dir): + """Create comprehensive 6-panel vorticity visualization.""" + from cfd_viz.fields.vorticity import ( + circulation, + detect_vortex_cores, + q_criterion, + vorticity, + ) + + os.makedirs(output_dir, exist_ok=True) + + x, y = data["x"], data["y"] + u, v = data["u"], data["v"] + dx, dy = data["dx"], data["dy"] + + omega = vorticity(u, v, dx, dy) + Q = q_criterion(u, v, dx, dy) + vortex_cores = detect_vortex_cores(omega, Q) + X, Y = np.meshgrid(x, y) + + plt.figure(figsize=(16, 12)) + + # 1. Vorticity contours + ax1 = plt.subplot(2, 3, 1) + vort_levels = np.linspace(-np.max(np.abs(omega)), np.max(np.abs(omega)), 20) + cs1 = ax1.contourf(X, Y, omega, levels=vort_levels, cmap="RdBu_r", extend="both") + ax1.contour( + X, Y, omega, levels=vort_levels[::4], colors="black", linewidths=0.5, alpha=0.3 + ) + plt.colorbar(cs1, ax=ax1, label="Vorticity (1/s)") + ax1.set_title("Vorticity Field") + ax1.set_xlabel("x") + ax1.set_ylabel("y") + ax1.set_aspect("equal") + + # 2. Q-criterion + ax2 = plt.subplot(2, 3, 2) + Q_levels = np.linspace(0, np.max(Q), 15) + cs2 = ax2.contourf(X, Y, Q, levels=Q_levels, cmap="viridis") + plt.colorbar(cs2, ax=ax2, label="Q-criterion") + ax2.set_title("Q-Criterion (Vortex Identification)") + ax2.set_xlabel("x") + ax2.set_ylabel("y") + ax2.set_aspect("equal") + + # 3. Vortex cores overlay + ax3 = plt.subplot(2, 3, 3) + velocity_magnitude = np.sqrt(u**2 + v**2) + cs3 = ax3.contourf(X, Y, velocity_magnitude, levels=20, cmap="plasma", alpha=0.7) + ax3.contour( + X, Y, vortex_cores.astype(int), levels=[0.5], colors="red", linewidths=2 + ) + plt.colorbar(cs3, ax=ax3, label="Velocity Magnitude (m/s)") + ax3.set_title("Detected Vortex Cores (Red Lines)") + ax3.set_xlabel("x") + ax3.set_ylabel("y") + ax3.set_aspect("equal") + + # 4. Vorticity with streamlines + ax4 = plt.subplot(2, 3, 4) + cs4 = ax4.contourf(X, Y, omega, levels=vort_levels, cmap="RdBu_r", alpha=0.8) + ax4.streamplot(X, Y, u, v, density=1.5, color="black", linewidth=0.8, arrowsize=1.2) + plt.colorbar(cs4, ax=ax4, label="Vorticity (1/s)") + ax4.set_title("Vorticity with Streamlines") + ax4.set_xlabel("x") + ax4.set_ylabel("y") + ax4.set_aspect("equal") + + # 5. Circulation analysis + ax5 = plt.subplot(2, 3, 5) + cs5 = ax5.contourf(X, Y, omega, levels=vort_levels, cmap="RdBu_r", alpha=0.6) + center_x, center_y = x[len(x) // 2], y[len(y) // 2] + radii = np.linspace(0.1, min(x.max() - x.min(), y.max() - y.min()) / 3, 5) + circulations = [] + for radius in radii: + circ = circulation(u, v, x, y, (center_x, center_y), radius) + circulations.append(circ) + circle = plt.Circle( + (center_x, center_y), + radius, + fill=False, + color="white", + linewidth=2, + linestyle="--", + ) + ax5.add_patch(circle) + ax5.text( + center_x + radius * 0.7, + center_y + radius * 0.7, + f"\u0393={circ:.3f}", + fontsize=8, + color="white", + bbox=dict(boxstyle="round,pad=0.2", facecolor="black", alpha=0.7), + ) + plt.colorbar(cs5, ax=ax5, label="Vorticity (1/s)") + ax5.set_title("Circulation Analysis") + ax5.set_xlabel("x") + ax5.set_ylabel("y") + ax5.set_aspect("equal") + + # 6. Statistics + ax6 = plt.subplot(2, 3, 6) + ax6.axis("off") + stats_text = ( + f"\n Vorticity Statistics:\n" + f" Max |\u03c9|: {np.max(np.abs(omega)):.4f} 1/s\n" + f" Mean \u03c9: {np.mean(omega):.4f} 1/s\n" + f" Std \u03c9: {np.std(omega):.4f} 1/s\n\n" + f" Q-Criterion:\n" + f" Max Q: {np.max(Q):.4f}\n\n" + f" Vortex Detection:\n" + f" Core area: {np.sum(vortex_cores) * dx * dy:.4f} m\u00b2\n\n" + f" Circulation Values:\n" + ) + for r, circ in zip(radii, circulations): + stats_text += f" r={r:.3f}: \u0393={circ:.4f}\n" + ax6.text( + 0.05, + 0.95, + stats_text, + transform=ax6.transAxes, + fontsize=10, + verticalalignment="top", + fontfamily="monospace", + bbox=dict(boxstyle="round,pad=0.5", facecolor="lightgray", alpha=0.8), + ) + + plt.tight_layout() + output_file = os.path.join(output_dir, "vorticity_analysis.png") + plt.savefig(output_file, dpi=300, bbox_inches="tight") + print(f"Vorticity analysis saved to: {output_file}") + plt.close() + + return omega, Q, vortex_cores + + +# ── Line profiles (from scripts/create_line_profiles.py) ───────────── + + +def _vtk_to_profiles_dict(data): + """Convert VTKData to the dict format create_cross_section_analysis expects.""" + field_mapping = { + "u": "u_velocity", + "v": "v_velocity", + "p": "pressure", + "rho": "density", + "T": "temperature", + } + data_fields = {} + for old, new in field_mapping.items(): + if old in data.fields: + data_fields[new] = data.fields[old] + for name, field in data.fields.items(): + if name not in field_mapping: + data_fields[name] = field + if "u_velocity" not in data_fields or "v_velocity" not in data_fields: + return None + return { + "x": data.x, + "y": data.y, + "data_fields": data_fields, + "nx": data.nx, + "ny": data.ny, + "dx": data.dx, + "dy": data.dy, + } + + +def _extract_line_data(x, y, field, start_point, end_point, num_points=100): + """Extract data along a line between two points.""" + from scipy.interpolate import RegularGridInterpolator + + line_x = np.linspace(start_point[0], end_point[0], num_points) + line_y = np.linspace(start_point[1], end_point[1], num_points) + interp = RegularGridInterpolator( + (y, x), field, bounds_error=False, fill_value=np.nan + ) + line_values = interp(np.column_stack([line_y, line_x])) + distance = np.sqrt((line_x - start_point[0]) ** 2 + (line_y - start_point[1]) ** 2) + return distance, line_values, line_x, line_y + + +def _analyze_boundary_layer( + x, y, u_field, v_field, wall_y, start_x, end_x, num_profiles=5 +): + """Analyze boundary layer profiles at multiple locations.""" + x_locations = np.linspace(start_x, end_x, num_profiles) + profiles = [] + for x_loc in x_locations: + x_idx = np.argmin(np.abs(x - x_loc)) + wall_idx = np.argmin(np.abs(y - wall_y)) + y_profile = y[wall_idx:] + u_profile = u_field[wall_idx:, x_idx] + wall_distance = y_profile - wall_y + u_freestream = u_profile[-1] + bl_thickness_idx = np.where(u_profile >= 0.99 * u_freestream)[0] + bl_thickness = ( + wall_distance[bl_thickness_idx[0]] if len(bl_thickness_idx) > 0 else np.nan + ) + profiles.append( + { + "x_location": x_loc, + "wall_distance": wall_distance, + "u_velocity": u_profile, + "v_velocity": v_field[wall_idx:, x_idx], + "bl_thickness": bl_thickness, + } + ) + return profiles + + +def create_cross_section_analysis(data, output_dir): + """Create comprehensive 12-panel cross-sectional analysis.""" + from cfd_viz.analysis.flow_features import ( + compute_cross_sectional_averages, + compute_spatial_fluctuations, + detect_wake_regions, + ) + + os.makedirs(output_dir, exist_ok=True) + + x, y = data["x"], data["y"] + fields = data["data_fields"] + u, v = fields["u_velocity"], fields["v_velocity"] + pressure = fields.get("pressure", np.zeros_like(u)) + X, Y = np.meshgrid(x, y) + + plt.figure(figsize=(20, 16)) + + # 1. Velocity magnitude with analysis lines + ax1 = plt.subplot(3, 4, 1) + velocity_mag = np.sqrt(u**2 + v**2) + cs1 = ax1.contourf(X, Y, velocity_mag, levels=20, cmap="viridis") + plt.colorbar(cs1, ax=ax1, label="Velocity Magnitude (m/s)") + + lines = [ + { + "start": (x.min(), y.mean()), + "end": (x.max(), y.mean()), + "name": "Horizontal Centerline", + "color": "red", + }, + { + "start": (x.mean(), y.min()), + "end": (x.mean(), y.max()), + "name": "Vertical Centerline", + "color": "white", + }, + { + "start": (x[len(x) // 4], y.min()), + "end": (x[len(x) // 4], y.max()), + "name": "Quarter Section", + "color": "yellow", + }, + { + "start": (x[3 * len(x) // 4], y.min()), + "end": (x[3 * len(x) // 4], y.max()), + "name": "Three-Quarter Section", + "color": "cyan", + }, + ] + for line in lines: + ax1.plot( + [line["start"][0], line["end"][0]], + [line["start"][1], line["end"][1]], + color=line["color"], + linewidth=2, + linestyle="--", + label=line["name"], + ) + ax1.set_title("Velocity Field with Analysis Lines") + ax1.set_xlabel("x (m)") + ax1.set_ylabel("y (m)") + ax1.legend(fontsize=8) + ax1.set_aspect("equal") + + # 2-5. Line plots for each analysis line + for i, line in enumerate(lines): + ax = plt.subplot(3, 4, i + 2) + distance, u_line, _, _ = _extract_line_data(x, y, u, line["start"], line["end"]) + _, v_line, _, _ = _extract_line_data(x, y, v, line["start"], line["end"]) + _, p_line, _, _ = _extract_line_data(x, y, pressure, line["start"], line["end"]) + ax.plot(distance, u_line, "b-", label="u-velocity", linewidth=1.5) + ax.plot(distance, v_line, "r-", label="v-velocity", linewidth=1.5) + ax.plot( + distance, np.sqrt(u_line**2 + v_line**2), "k-", label="|v|", linewidth=2 + ) + ax2 = ax.twinx() + ax2.plot(distance, p_line, "g--", label="pressure", alpha=0.7) + ax2.set_ylabel("Pressure", color="g") + ax2.tick_params(axis="y", labelcolor="g") + ax.set_title(f"{line['name']}") + ax.set_xlabel("Distance along line (m)") + ax.set_ylabel("Velocity (m/s)") + ax.legend(loc="upper left", fontsize=8) + ax.grid(True, alpha=0.3) + + # 6. Boundary layer analysis + ax6 = plt.subplot(3, 4, 6) + wall_y = y.min() + bl_profiles = _analyze_boundary_layer( + x, y, u, v, wall_y, x[len(x) // 4], x[3 * len(x) // 4] + ) + colors = plt.cm.plasma(np.linspace(0, 1, len(bl_profiles))) + for profile, color in zip(bl_profiles, colors): + if not np.isnan(profile["bl_thickness"]) and profile["bl_thickness"] > 0: + y_norm = profile["wall_distance"] / profile["bl_thickness"] + u_norm = profile["u_velocity"] / np.max(profile["u_velocity"]) + ax6.plot( + u_norm, + y_norm, + color=color, + linewidth=2, + label=f"x={profile['x_location']:.2f}", + ) + ax6.set_xlabel("u/u_max") + ax6.set_ylabel("y/\u03b4 (normalized wall distance)") + ax6.set_title("Boundary Layer Profiles") + ax6.legend(fontsize=8) + ax6.grid(True, alpha=0.3) + ax6.set_ylim(0, 2) + + # 7. Velocity profiles at different x-locations + ax7 = plt.subplot(3, 4, 7) + x_stations = [ + x[len(x) // 6], + x[len(x) // 3], + x[len(x) // 2], + x[2 * len(x) // 3], + x[5 * len(x) // 6], + ] + colors7 = plt.cm.viridis(np.linspace(0, 1, len(x_stations))) + for x_station, color in zip(x_stations, colors7): + _, u_profile, _, y_profile = _extract_line_data( + x, y, u, (x_station, y.min()), (x_station, y.max()) + ) + ax7.plot( + u_profile, y_profile, color=color, linewidth=2, label=f"x={x_station:.2f}" + ) + ax7.set_xlabel("u-velocity (m/s)") + ax7.set_ylabel("y (m)") + ax7.set_title("Velocity Profiles at Different Stations") + ax7.legend(fontsize=8) + ax7.grid(True, alpha=0.3) + + # 8. Pressure distribution along centerline + ax8 = plt.subplot(3, 4, 8) + _, p_horizontal, x_line, _ = _extract_line_data( + x, y, pressure, (x.min(), y.mean()), (x.max(), y.mean()) + ) + ax8.plot(x_line, p_horizontal, "b-", linewidth=2, label="Centerline Pressure") + dp_dx = np.gradient(p_horizontal, x_line) + ax8_twin = ax8.twinx() + ax8_twin.plot(x_line, dp_dx, "r--", alpha=0.7, label="Pressure Gradient") + ax8_twin.set_ylabel("dp/dx", color="r") + ax8_twin.tick_params(axis="y", labelcolor="r") + ax8.set_xlabel("x (m)") + ax8.set_ylabel("Pressure") + ax8.set_title("Pressure Distribution") + ax8.legend(loc="upper left") + ax8.grid(True, alpha=0.3) + + # 9. Wake analysis + ax9 = plt.subplot(3, 4, 9) + wake_result = detect_wake_regions(u, v, threshold_fraction=0.1) + ax9.contourf(X, Y, velocity_mag, levels=20, cmap="viridis", alpha=0.7) + ax9.contour( + X, Y, wake_result.mask.astype(int), levels=[0.5], colors="red", linewidths=2 + ) + ax9.set_title(f"Wake Regions ({wake_result.area_fraction:.1%} of domain)") + ax9.set_xlabel("x (m)") + ax9.set_ylabel("y (m)") + ax9.set_aspect("equal") + + # 10. Velocity fluctuations + ax10 = plt.subplot(3, 4, 10) + fluct_result = compute_spatial_fluctuations(u, v, averaging_axis=1) + cs10 = ax10.contourf(X, Y, fluct_result.fluct_magnitude, levels=15, cmap="hot") + plt.colorbar(cs10, ax=ax10, label="Velocity Fluctuation Magnitude") + ax10.set_title( + f"Velocity Fluctuations (TI={fluct_result.turbulence_intensity:.1%})" + ) + ax10.set_xlabel("x (m)") + ax10.set_ylabel("y (m)") + ax10.set_aspect("equal") + + # 11. Statistics summary + ax11 = plt.subplot(3, 4, 11) + ax11.axis("off") + stats_text = ( + f"\n Flow Statistics:\n\n" + f" Domain:\n" + f" x: [{x.min():.3f}, {x.max():.3f}] m\n" + f" y: [{y.min():.3f}, {y.max():.3f}] m\n" + f" Grid: {len(x)} x {len(y)}\n\n" + f" Velocity:\n" + f" Max |v|: {np.max(velocity_mag):.3f} m/s\n" + f" Mean |v|: {np.mean(velocity_mag):.3f} m/s\n" + f" Min |v|: {np.min(velocity_mag):.3f} m/s\n\n" + f" Pressure:\n" + f" Max p: {np.max(pressure):.3f}\n" + f" Mean p: {np.mean(pressure):.3f}\n" + f" Min p: {np.min(pressure):.3f}\n\n" + f" Boundary Layer:\n" + ) + for profile in bl_profiles: + if not np.isnan(profile["bl_thickness"]): + stats_text += f" \u03b4 at x={profile['x_location']:.2f}: {profile['bl_thickness']:.4f} m\n" + ax11.text( + 0.05, + 0.95, + stats_text, + transform=ax11.transAxes, + fontsize=9, + verticalalignment="top", + fontfamily="monospace", + bbox=dict(boxstyle="round,pad=0.5", facecolor="lightblue", alpha=0.8), + ) + + # 12. Cross-sectional averages + ax12 = plt.subplot(3, 4, 12) + avg_result = compute_cross_sectional_averages(u, v, x, y, averaging_axis="x") + ax12.plot( + avg_result.u_avg, avg_result.coordinate, "b-", linewidth=2, label=" vs y" + ) + ax12.plot( + avg_result.v_avg, avg_result.coordinate, "r-", linewidth=2, label=" vs y" + ) + ax12.set_xlabel("Average Velocity (m/s)") + ax12.set_ylabel("y (m)") + ax12.set_title(f"Cross-sectional Averages (bulk={avg_result.bulk_velocity:.3f})") + ax12.legend() + ax12.grid(True, alpha=0.3) + + plt.tight_layout() + output_file = os.path.join(output_dir, "cross_section_analysis.png") + plt.savefig(output_file, dpi=300, bbox_inches="tight") + print(f"Cross-section analysis saved to: {output_file}") + plt.close() + + +# ── Dashboard helpers (from scripts/create_dashboard.py) ────────────── + + +def load_dashboard_vtk_files(vtk_files): + """Load VTK files for dashboard creation. Returns (frames_list, time_indices).""" + from cfd_viz.common import read_vtk_file + + frames_list = [] + time_indices = [] + for filename in sorted(vtk_files): + try: + data = read_vtk_file(filename) + if data is None: + continue + u, v, p = data.fields.get("u"), data.fields.get("v"), data.fields.get("p") + if u is not None and v is not None: + frames_list.append((data.x, data.y, u, v, p)) + iteration = int(os.path.basename(filename).split("_")[-1].split(".")[0]) + time_indices.append(iteration) + except Exception as e: + print(f"Error reading {filename}: {e}") + return frames_list, time_indices + + +def create_dashboards(vtk_files, output_dir, auto_open=False): + """Create interactive dashboards from VTK files.""" + from plotly.offline import plot + + from cfd_viz.interactive import ( + create_animated_dashboard, + create_convergence_figure, + create_interactive_frame_collection, + ) + + frames_list, time_indices = load_dashboard_vtk_files(vtk_files) + if not frames_list: + print("No valid data found!") + return + + print(f"Loaded {len(frames_list)} frames") + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + collection = create_interactive_frame_collection( + frames_list, time_indices=time_indices + ) + + print("Creating animated dashboard...") + dashboard_fig = create_animated_dashboard(collection) + dashboard_path = output_dir / "cfd_interactive_dashboard.html" + plot(dashboard_fig, filename=str(dashboard_path), auto_open=auto_open) + print(f"Animated dashboard saved to: {dashboard_path}") + + print("Creating convergence plot...") + convergence_fig = create_convergence_figure(collection) + convergence_path = output_dir / "cfd_convergence.html" + plot(convergence_fig, filename=str(convergence_path), auto_open=auto_open) + print(f"Convergence plot saved to: {convergence_path}") + + +# ── Monitor helpers (from scripts/create_monitor.py) ────────────────── + + +def run_monitor(watch_dir, output_dir, interval=2.0, manual=False): + """Start the real-time CFD monitoring dashboard.""" + import glob as _glob + import time + + import pandas as pd + from watchdog.events import FileSystemEventHandler + from watchdog.observers import Observer + + from cfd_viz.analysis.time_series import ( + compute_flow_metrics, + create_flow_metrics_time_series, + ) + from cfd_viz.common import read_vtk_file + + def _read_vtk_for_monitor(filename): + try: + data = read_vtk_file(filename) + except (FileNotFoundError, PermissionError): + return None + if data is None: + return None + field_mapping = { + "u": "u_velocity", + "v": "v_velocity", + "p": "pressure", + "rho": "density", + "T": "temperature", + } + data_fields = {} + for old, new in field_mapping.items(): + if old in data.fields: + data_fields[new] = data.fields[old] + for name, field in data.fields.items(): + if name not in field_mapping: + data_fields[name] = field + if "u_velocity" not in data_fields or "v_velocity" not in data_fields: + return None + return { + "x": data.x, + "y": data.y, + "data_fields": data_fields, + "nx": data.nx, + "ny": data.ny, + "dx": data.dx, + "dy": data.dy, + "filename": filename, + "timestamp": time.time(), + } + + class _CFDMonitor: + def __init__(self, wd, od): + self.watch_dir, self.output_dir = wd, od + self.monitoring = False + self.monitoring_history = create_flow_metrics_time_series(max_length=100) + self.fig, axes = plt.subplots(2, 3, figsize=(18, 10)) + self.fig.suptitle("CFD Real-time Monitoring Dashboard", fontsize=16) + self.axes = axes.flatten() + self.last_processed_file = None + os.makedirs(od, exist_ok=True) + for ax in self.axes: + ax.set_visible(True) + plt.tight_layout() + + def process_new_file(self, filepath): + print(f"Processing new file: {os.path.basename(filepath)}") + data = _read_vtk_for_monitor(filepath) + if data is None: + return + fields = data["data_fields"] + u, v = fields["u_velocity"], fields["v_velocity"] + p = fields.get("pressure", np.zeros_like(u)) + metrics = compute_flow_metrics( + u=u, v=v, p=p, dx=data["dx"], dy=data["dy"], timestamp=data["timestamp"] + ) + self.monitoring_history.add(metrics) + self.last_processed_file = filepath + self._save_metrics() + + def _save_metrics(self): + if not self.monitoring_history.snapshots: + return + keys = [ + "timestamp", + "max_velocity", + "mean_velocity", + "max_pressure", + "mean_pressure", + "total_kinetic_energy", + "max_vorticity", + ] + df = pd.DataFrame( + {k: self.monitoring_history.get_metric_array(k) for k in keys} + ) + df.to_csv( + os.path.join(self.output_dir, "monitoring_metrics.csv"), index=False + ) + + class _VTKHandler(FileSystemEventHandler): + def __init__(self, mon): + self.monitor = mon + + def on_created(self, event): + if not event.is_directory and event.src_path.endswith(".vtk"): + time.sleep(0.5) + self.monitor.process_new_file(event.src_path) + + if not os.path.exists(watch_dir): + print(f"Watch directory does not exist: {watch_dir}") + return + + monitor = _CFDMonitor(watch_dir, output_dir) + plt.ion() + plt.show() + + if manual: + print( + f"Manual monitoring mode. Checking {watch_dir} every {interval} seconds..." + ) + print("Press Ctrl+C to stop.") + last_file = None + try: + while True: + vtk_files = _glob.glob(os.path.join(watch_dir, "*.vtk")) + if vtk_files: + latest = max(vtk_files, key=os.path.getctime) + if latest != last_file: + monitor.process_new_file(latest) + last_file = latest + time.sleep(interval) + except KeyboardInterrupt: + print("\nMonitoring stopped.") + else: + print(f"Monitoring {watch_dir}. Close plot window to stop.") + existing = sorted(_glob.glob(os.path.join(watch_dir, "*.vtk"))) + if existing: + monitor.process_new_file(existing[-1]) + observer = Observer() + observer.schedule(_VTKHandler(monitor), watch_dir, recursive=False) + observer.start() + monitor.monitoring = True + try: + while monitor.monitoring: + plt.pause(1.0) + except KeyboardInterrupt: + pass + finally: + observer.stop() + observer.join() + + print("Monitoring complete!") + + +# ── Progress helper ─────────────────────────────────────────────────── + + +def _progress_stderr(current, total, label=""): + """Write a simple progress line to stderr.""" + width = 30 + filled = int(width * current / total) if total > 0 else width + bar = "#" * filled + "-" * (width - filled) + pct = (current / total * 100) if total > 0 else 100 + sys.stderr.write(f"\r [{bar}] {pct:5.1f}% {label}") + if current >= total: + sys.stderr.write("\n") + sys.stderr.flush() diff --git a/cfd_viz/cli.py b/cfd_viz/cli.py index aa65499..8a894f9 100644 --- a/cfd_viz/cli.py +++ b/cfd_viz/cli.py @@ -1,8 +1,8 @@ """ Unified CLI for cfd-visualization. -Provides ``cfd-viz`` command with subcommands that dispatch to existing -scripts and library functions. +Provides ``cfd-viz`` command with subcommands that dispatch to library +functions in ``cfd_viz``. Usage:: @@ -16,8 +16,7 @@ """ import argparse -import importlib -import sys +import glob def _build_parser() -> argparse.ArgumentParser: @@ -136,34 +135,6 @@ def _build_parser() -> argparse.ArgumentParser: return parser -# ── helpers ────────────────────────────────────────────────────────── - - -def _run_script_main(module_name, argv): - """Import a script module and call its ``main()`` after overriding sys.argv.""" - old_argv = sys.argv - sys.argv = ["cfd-viz", *argv] - try: - mod = importlib.import_module(module_name) - mod.main() - finally: - sys.argv = old_argv - - -def _build_animate_argv(args): - argv = list(args.vtk_files) - argv += ["--type", args.type] - argv += ["--field", args.field] - argv += ["--fps", str(args.fps)] - if args.output: - argv += ["--output", args.output] - if args.export_frames: - argv.append("--export-frames") - if getattr(args, "all", False): - argv.append("--all") - return argv - - # ── subcommand handlers ────────────────────────────────────────────── @@ -174,50 +145,133 @@ def _cmd_info(_args): def _cmd_animate(args): - _run_script_main("scripts.create_animation", _build_animate_argv(args)) + from cfd_viz._cli_impl import create_and_save_animation, load_vtk_files_to_frames + from cfd_viz.animation import export_animation_frames + from cfd_viz.common import ANIMATIONS_DIR, PLOTS_DIR, ensure_dirs + + ensure_dirs() + + # Expand glob patterns + vtk_files = [] + for pattern in args.vtk_files: + matches = glob.glob(pattern) + if matches: + vtk_files.extend(matches) + else: + print(f"Warning: No files matching pattern: {pattern}") + + if not vtk_files: + print("Error: No VTK files found") + raise SystemExit(1) + + vtk_files = sorted(set(vtk_files)) + print(f"Found {len(vtk_files)} VTK files") + + animation_frames = load_vtk_files_to_frames(vtk_files) + + if args.export_frames: + output_dir = args.output or str(PLOTS_DIR / "frames") + print(f"Exporting frames to {output_dir}...") + exported = export_animation_frames(animation_frames, output_dir) + print(f"Exported {len(exported)} frames") + elif getattr(args, "all", False): + for anim_type in [ + "velocity", + "streamlines", + "vectors", + "dashboard", + "vorticity", + "3d", + "particles", + ]: + output_path = str(ANIMATIONS_DIR / f"cfd_{anim_type}.gif") + try: + create_and_save_animation( + animation_frames, anim_type, output_path, args.fps, args.field + ) + except Exception as e: + print(f"Warning: Failed to create {anim_type} animation: {e}") + else: + output_path = args.output or str(ANIMATIONS_DIR / f"cfd_{args.type}.gif") + create_and_save_animation( + animation_frames, args.type, output_path, args.fps, args.field + ) + + print("Done!") def _cmd_dashboard(args): - argv = ["--vtk-pattern", args.vtk_pattern, "--output-dir", args.output_dir] - if args.auto_open: - argv.append("--auto-open") - _run_script_main("scripts.create_dashboard", argv) + from cfd_viz._cli_impl import create_dashboards + + vtk_files = glob.glob(args.vtk_pattern) + if not vtk_files: + print(f"No VTK files found matching pattern: {args.vtk_pattern}") + return + + print(f"Found {len(vtk_files)} VTK files") + create_dashboards(vtk_files, args.output_dir, args.auto_open) + print("\nInteractive visualizations complete!") def _cmd_vorticity(args): - argv = [] - if args.input_file: - argv.append(args.input_file) - if args.output: - argv += ["--output", args.output] - if args.latest: - argv.append("--latest") - _run_script_main("scripts.create_vorticity_analysis", argv) + from cfd_viz._cli_impl import ( + create_vorticity_visualization, + resolve_vtk_input, + ) + from cfd_viz.common import PLOTS_DIR, ensure_dirs, read_vtk_file + + ensure_dirs() + input_file = resolve_vtk_input(args.input_file, args.latest) + if input_file is None: + return + + data = read_vtk_file(input_file) + if data is None: + return + if data.u is None or data.v is None: + print("Error: Could not find velocity data in VTK file") + return + + output_dir = args.output or str(PLOTS_DIR) + create_vorticity_visualization(data.to_dict(), output_dir) + print("Vorticity analysis complete!") def _cmd_profiles(args): - argv = [] - if args.input_file: - argv.append(args.input_file) - if args.output: - argv += ["--output", args.output] - if args.interactive: - argv.append("--interactive") - if args.latest: - argv.append("--latest") - _run_script_main("scripts.create_line_profiles", argv) + from cfd_viz._cli_impl import ( + _vtk_to_profiles_dict, + create_cross_section_analysis, + resolve_vtk_input, + ) + from cfd_viz.common import PLOTS_DIR, ensure_dirs, read_vtk_file + + ensure_dirs() + input_file = resolve_vtk_input(args.input_file, args.latest) + if input_file is None: + return + + data = read_vtk_file(input_file) + if data is None: + return + + data_dict = _vtk_to_profiles_dict(data) + if data_dict is None: + print("Error: Could not find velocity data in VTK file") + return + + output_dir = args.output or str(PLOTS_DIR) + create_cross_section_analysis(data_dict, output_dir) + print("Cross-section analysis complete!") def _cmd_monitor(args): - argv = [] - if args.watch_dir: - argv += ["--watch_dir", args.watch_dir] - if args.output: - argv += ["--output", args.output] - argv += ["--interval", str(args.interval)] - if args.manual: - argv.append("--manual") - _run_script_main("scripts.create_monitor", argv) + from cfd_viz._cli_impl import run_monitor + from cfd_viz.common import DATA_DIR, PLOTS_DIR, ensure_dirs + + ensure_dirs() + watch_dir = args.watch_dir or str(DATA_DIR) + output_dir = args.output or str(PLOTS_DIR) + run_monitor(watch_dir, output_dir, args.interval, args.manual) def _cmd_batch(args): diff --git a/pyproject.toml b/pyproject.toml index b1daff2..437c831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,8 @@ unfixable = [] "examples/*.py" = ["PLR0915", "PLW0603", "RUF059", "F841"] # Tests can use assert and have longer functions, unused variables in fixtures "tests/*.py" = ["S101", "PLR0915", "RUF059", "F841", "RUF043"] +# _cli_impl sets matplotlib backend before importing pyplot +"cfd_viz/_cli_impl.py" = ["E402"] [tool.ruff.lint.isort] # Group imports: standard library, third-party, local From 12d814cd81fc5a1e72601ceea9ce875f042fa71b Mon Sep 17 00:00:00 2001 From: shaia Date: Wed, 18 Mar 2026 20:51:50 +0200 Subject: [PATCH 3/9] fix: Only force Agg backend on headless systems Avoid forcing matplotlib Agg backend on Windows or systems with DISPLAY set, so interactive commands like monitor can use GUI backends. --- cfd_viz/_cli_impl.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cfd_viz/_cli_impl.py b/cfd_viz/_cli_impl.py index eb216e4..570eeee 100644 --- a/cfd_viz/_cli_impl.py +++ b/cfd_viz/_cli_impl.py @@ -10,7 +10,10 @@ import matplotlib -matplotlib.use("Agg") +# Only force non-interactive backend when no display is available. +# This preserves GUI capability for interactive commands (e.g. monitor). +if os.name != "nt" and not os.environ.get("DISPLAY"): + matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np From 9e50e692188ad8be5c46d909c584383e6d58c60d Mon Sep 17 00:00:00 2001 From: shaia Date: Wed, 18 Mar 2026 21:19:19 +0200 Subject: [PATCH 4/9] fix: Catch ValueError for invalid VTK files in monitor loop read_vtk_file() can raise ValueError for malformed VTK content. Treat it as a non-fatal read failure so the monitor keeps running. --- cfd_viz/_cli_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cfd_viz/_cli_impl.py b/cfd_viz/_cli_impl.py index 570eeee..5d587e3 100644 --- a/cfd_viz/_cli_impl.py +++ b/cfd_viz/_cli_impl.py @@ -661,7 +661,7 @@ def run_monitor(watch_dir, output_dir, interval=2.0, manual=False): def _read_vtk_for_monitor(filename): try: data = read_vtk_file(filename) - except (FileNotFoundError, PermissionError): + except (FileNotFoundError, PermissionError, ValueError): return None if data is None: return None From dd4205eabd871d45a8f838a9214e6bb0104d18fa Mon Sep 17 00:00:00 2001 From: shaia Date: Thu, 19 Mar 2026 07:53:37 +0200 Subject: [PATCH 5/9] fix: Split animate --output (file) and --output-dir (frames directory) The --output flag was ambiguously used as both a file path and directory. Add --output-dir for --export-frames to make the distinction clear. --- cfd_viz/cli.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cfd_viz/cli.py b/cfd_viz/cli.py index 8a894f9..05e74d5 100644 --- a/cfd_viz/cli.py +++ b/cfd_viz/cli.py @@ -65,11 +65,16 @@ def _build_parser() -> argparse.ArgumentParser: p_anim.add_argument( "--field", "-f", default="velocity_mag", help="Field for field animations" ) - p_anim.add_argument("--output", "-o", default=None, help="Output path") + p_anim.add_argument( + "--output", "-o", default=None, help="Output file path for animation" + ) p_anim.add_argument("--fps", type=int, default=5, help="Frames per second") p_anim.add_argument( "--export-frames", action="store_true", help="Export individual frames" ) + p_anim.add_argument( + "--output-dir", default=None, help="Output directory for --export-frames" + ) p_anim.add_argument("--all", action="store_true", help="Create all animation types") # ── dashboard ───────────────────────────────────────────────────── @@ -170,7 +175,7 @@ def _cmd_animate(args): animation_frames = load_vtk_files_to_frames(vtk_files) if args.export_frames: - output_dir = args.output or str(PLOTS_DIR / "frames") + output_dir = args.output_dir or str(PLOTS_DIR / "frames") print(f"Exporting frames to {output_dir}...") exported = export_animation_frames(animation_frames, output_dir) print(f"Exported {len(exported)} frames") From f390ec32c532fa92f004ea229114087d2dfd8563 Mon Sep 17 00:00:00 2001 From: shaia Date: Thu, 19 Mar 2026 07:54:06 +0200 Subject: [PATCH 6/9] fix: Include field type in --all animation set The field animation type was listed in --type choices but omitted from the --all loop. --- cfd_viz/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cfd_viz/cli.py b/cfd_viz/cli.py index 05e74d5..11506e2 100644 --- a/cfd_viz/cli.py +++ b/cfd_viz/cli.py @@ -188,6 +188,7 @@ def _cmd_animate(args): "vorticity", "3d", "particles", + "field", ]: output_path = str(ANIMATIONS_DIR / f"cfd_{anim_type}.gif") try: From 0d0ca72dc99a00a3df2bcac52aed59dde9f263a1 Mon Sep 17 00:00:00 2001 From: shaia Date: Thu, 19 Mar 2026 08:09:00 +0200 Subject: [PATCH 7/9] fix: Handle ValueError from read_vtk_file in CLI commands Catch ValueError from malformed VTK files in animate, vorticity, and profiles commands to show clean error messages instead of tracebacks. --- cfd_viz/cli.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cfd_viz/cli.py b/cfd_viz/cli.py index 11506e2..79282a5 100644 --- a/cfd_viz/cli.py +++ b/cfd_viz/cli.py @@ -172,7 +172,11 @@ def _cmd_animate(args): vtk_files = sorted(set(vtk_files)) print(f"Found {len(vtk_files)} VTK files") - animation_frames = load_vtk_files_to_frames(vtk_files) + try: + animation_frames = load_vtk_files_to_frames(vtk_files) + except ValueError as e: + print(f"Error: {e}") + raise SystemExit(1) from None if args.export_frames: output_dir = args.output_dir or str(PLOTS_DIR / "frames") @@ -231,7 +235,11 @@ def _cmd_vorticity(args): if input_file is None: return - data = read_vtk_file(input_file) + try: + data = read_vtk_file(input_file) + except ValueError as exc: + print(f"Error: Failed to read VTK file '{input_file}': {exc}") + return if data is None: return if data.u is None or data.v is None: @@ -256,7 +264,11 @@ def _cmd_profiles(args): if input_file is None: return - data = read_vtk_file(input_file) + try: + data = read_vtk_file(input_file) + except ValueError as exc: + print(f"Error: Failed to read VTK file '{input_file}': {exc}") + return if data is None: return From 22c6e10215a15385e0021a11fc91c96ce464f486 Mon Sep 17 00:00:00 2001 From: shaia Date: Thu, 19 Mar 2026 08:12:30 +0200 Subject: [PATCH 8/9] fix: Catch ValueError for malformed VTK files in batch processing Treat invalid VTK content as a non-fatal warning so other batch jobs can continue instead of aborting the entire run. --- cfd_viz/_batch.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cfd_viz/_batch.py b/cfd_viz/_batch.py index 4b015cb..c434b4c 100644 --- a/cfd_viz/_batch.py +++ b/cfd_viz/_batch.py @@ -55,7 +55,11 @@ def _run_vorticity(vtk_file, output_dir): from cfd_viz.common import ensure_dirs, read_vtk_file ensure_dirs() - data = read_vtk_file(vtk_file) + try: + data = read_vtk_file(vtk_file) + except ValueError: + print(f" Warning: invalid VTK file {vtk_file}") + return if data is None: print(f" Warning: could not read {vtk_file}") return @@ -75,7 +79,11 @@ def _run_profiles(vtk_file, output_dir): from cfd_viz.common import ensure_dirs, read_vtk_file ensure_dirs() - data = read_vtk_file(vtk_file) + try: + data = read_vtk_file(vtk_file) + except ValueError: + print(f" Warning: invalid VTK file {vtk_file}") + return if data is None: print(f" Warning: could not read {vtk_file}") return From ca0bf5490761d9024a9f96e96728de259b921e36 Mon Sep 17 00:00:00 2001 From: shaia Date: Thu, 19 Mar 2026 11:09:16 +0200 Subject: [PATCH 9/9] fix: Improve monitor and headless detection robustness - Stop monitor loop when plot window is closed (close_event + fignum_exists) - Fix headless detection to only force Agg on Linux without DISPLAY/WAYLAND - Use st_mtime instead of st_ctime for latest file detection (Unix compat) --- cfd_viz/_cli_impl.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cfd_viz/_cli_impl.py b/cfd_viz/_cli_impl.py index 5d587e3..a5b2fcc 100644 --- a/cfd_viz/_cli_impl.py +++ b/cfd_viz/_cli_impl.py @@ -12,7 +12,12 @@ # Only force non-interactive backend when no display is available. # This preserves GUI capability for interactive commands (e.g. monitor). -if os.name != "nt" and not os.environ.get("DISPLAY"): +# Windows and macOS always have a display; on Linux check DISPLAY/WAYLAND_DISPLAY. +if ( + sys.platform == "linux" + and not os.environ.get("DISPLAY") + and not os.environ.get("WAYLAND_DISPLAY") +): matplotlib.use("Agg") import matplotlib.pyplot as plt @@ -33,7 +38,7 @@ def resolve_vtk_input(input_file=None, latest=False): if not vtk_files: print(f"No VTK files found in {DATA_DIR}") return None - path = str(max(vtk_files, key=lambda f: f.stat().st_ctime)) + path = str(max(vtk_files, key=lambda f: f.stat().st_mtime)) print(f"Using latest file: {path}") return path @@ -768,7 +773,7 @@ def on_created(self, event): while True: vtk_files = _glob.glob(os.path.join(watch_dir, "*.vtk")) if vtk_files: - latest = max(vtk_files, key=os.path.getctime) + latest = max(vtk_files, key=os.path.getmtime) if latest != last_file: monitor.process_new_file(latest) last_file = latest @@ -784,8 +789,11 @@ def on_created(self, event): observer.schedule(_VTKHandler(monitor), watch_dir, recursive=False) observer.start() monitor.monitoring = True + monitor.fig.canvas.mpl_connect( + "close_event", lambda _: setattr(monitor, "monitoring", False) + ) try: - while monitor.monitoring: + while monitor.monitoring and plt.fignum_exists(monitor.fig.number): plt.pause(1.0) except KeyboardInterrupt: pass