diff --git a/ROADMAP.md b/ROADMAP.md index 817f59c..4b2a403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,27 +48,18 @@ The cfd-python integration had inline `if/else` branching scattered across `stat --- -## Phase 2: Foundation & Code Quality +## Phase 2: Foundation & Code Quality - COMPLETE -**Priority:** P0 - Must-have before adding features -**Target:** v0.2.0 - -The VTK reader (`cfd_viz/common/vtk_reader.py`) is 240 lines with zero dedicated tests. VTKData accepts any data without validation. The version string is duplicated between `pyproject.toml` and `__init__.py`. These are trust issues for a library used in research. - -### Tasks - -- [ ] **2.1 Add pytest configuration** to `pyproject.toml` - testpaths, markers, filterwarnings -- [ ] **2.2 Write VTK reader tests** - STRUCTURED_POINTS/RECTILINEAR_GRID parsing, malformed files, edge cases (empty fields, single-row grids) -- [ ] **2.3 Add VTKData validation** - verify field shapes match `(ny, nx)` on construction, warn on NaN/inf, add `__repr__` for debugging -- [ ] **2.4 Normalize field naming** - add `FIELD_ALIASES` mapping so `data["pressure"]` and `data["p"]` both work; standardize across all modules -- [ ] **2.5 Single-source version** - use hatchling dynamic version or `importlib.metadata`; remove the duplication -- [ ] **2.6 Add integration test** - end-to-end: create VTK data in memory, read it, compute vorticity, plot to figure, assert no exceptions +**Status:** Completed (2026-03-06) -### Success Criteria +The VTK reader had zero dedicated tests and VTKData accepted any data without validation. This phase added validation, field aliases, single-source versioning, and comprehensive test coverage. -- VTK reader has dedicated test coverage -- VTKData raises `ValueError` for mismatched field shapes -- Version appears in exactly one source file +- [x] **2.1 Add pytest configuration** to `pyproject.toml` - testpaths, markers (`slow`, `integration`), filterwarnings +- [x] **2.2 Write VTK reader tests** - 35 tests covering STRUCTURED_POINTS/RECTILINEAR_GRID parsing, malformed files, edge cases, field aliases +- [x] **2.3 Add VTKData validation** - field shape validation (`ValueError`), NaN/inf warnings, `__repr__` for debugging +- [x] **2.4 Normalize field naming** - `FIELD_ALIASES` and `CANONICAL_NAMES` mappings; `data["pressure"]` and `data["p"]` both work; `__contains__`, `has_field()` methods +- [x] **2.5 Single-source version** - `importlib.metadata` in `__init__.py`; version defined only in `pyproject.toml` +- [x] **2.6 Add integration test** - 5 end-to-end tests: VTK read → compute → plot, cfd-python conversion, field aliases, version, repr --- diff --git a/cfd_viz/__init__.py b/cfd_viz/__init__.py index d08070d..53a9ebd 100644 --- a/cfd_viz/__init__.py +++ b/cfd_viz/__init__.py @@ -36,7 +36,12 @@ For more examples, see the examples/ directory. """ -__version__ = "0.1.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("cfd-visualization") +except PackageNotFoundError: + __version__ = "0+unknown" # Fallback for uninstalled development usage # Re-export commonly used items for convenience # Import fields module for easy access diff --git a/cfd_viz/common/__init__.py b/cfd_viz/common/__init__.py index de8d5d1..993c8f8 100644 --- a/cfd_viz/common/__init__.py +++ b/cfd_viz/common/__init__.py @@ -1,10 +1,11 @@ # Common utilities package from .config import ANIMATIONS_DIR, DATA_DIR, PLOTS_DIR, ensure_dirs, find_vtk_files -from .vtk_reader import VTKData, read_vtk_file +from .vtk_reader import FIELD_ALIASES, VTKData, read_vtk_file __all__ = [ "ANIMATIONS_DIR", "DATA_DIR", + "FIELD_ALIASES", "PLOTS_DIR", "VTKData", "ensure_dirs", diff --git a/cfd_viz/common/vtk_reader.py b/cfd_viz/common/vtk_reader.py index 6e91ee1..27302cc 100644 --- a/cfd_viz/common/vtk_reader.py +++ b/cfd_viz/common/vtk_reader.py @@ -10,11 +10,44 @@ across all visualization scripts in the project. """ +import warnings from typing import Any, Dict, Optional, Tuple import numpy as np from numpy.typing import NDArray +# VTK section headers that signal the end of a data block. +_VTK_SECTION_HEADERS = frozenset( + { + "SCALARS", + "VECTORS", + "NORMALS", + "TENSORS", + "TEXTURE_COORDINATES", + "FIELD", + "LOOKUP_TABLE", + "POINT_DATA", + "CELL_DATA", + "DATASET", + "DIMENSIONS", + "ORIGIN", + "SPACING", + "X_COORDINATES", + "Y_COORDINATES", + "Z_COORDINATES", + } +) + +# Maps alternative user-facing names to canonical names for lookup. +# Also used at read time to normalize VTK field names to canonical short names. +FIELD_ALIASES: Dict[str, str] = { + "pressure": "p", + "velocity_x": "u", + "velocity_y": "v", + "x_velocity": "u", + "y_velocity": "v", +} + class VTKData: """Container for VTK file data with convenient access patterns.""" @@ -30,6 +63,7 @@ def __init__( ny: int, dx: float, dy: float, + validate: bool = True, ): self.x = x self.y = y @@ -41,6 +75,41 @@ def __init__( self.dx = dx self.dy = dy + # Validate field shapes + expected_shape = (ny, nx) + for name, field in self.fields.items(): + if field.shape != expected_shape: + raise ValueError( + f"Field '{name}' has shape {field.shape}, " + f"expected {expected_shape} (ny, nx)" + ) + + # Warn on NaN/inf values (only for floating-point fields) + if validate: + for name, field in self.fields.items(): + if not np.issubdtype(field.dtype, np.floating): + continue + if np.any(np.isnan(field)): + warnings.warn( + f"Field '{name}' contains NaN values", + UserWarning, + stacklevel=2, + ) + if np.any(np.isinf(field)): + warnings.warn( + f"Field '{name}' contains infinite values", + UserWarning, + stacklevel=2, + ) + + def __repr__(self) -> str: + field_names = ", ".join(sorted(self.fields.keys())) + return ( + f"VTKData(nx={self.nx}, ny={self.ny}, " + f"dx={self.dx:.6g}, dy={self.dy:.6g}, " + f"fields=[{field_names}])" + ) + @property def u(self) -> Optional[NDArray]: """X-velocity component.""" @@ -52,12 +121,25 @@ def v(self) -> Optional[NDArray]: return self.fields.get("v") def __getitem__(self, key: str) -> NDArray: - """Access fields by name.""" - return self.fields[key] + """Access fields by name, with alias support.""" + canonical = FIELD_ALIASES.get(key, key) + if canonical in self.fields: + return self.fields[canonical] + raise KeyError(key) def get(self, key: str, default: Any = None) -> Any: - """Get field with default value.""" - return self.fields.get(key, default) + """Get field with default value, with alias support.""" + canonical = FIELD_ALIASES.get(key, key) + return self.fields.get(canonical, default) + + def __contains__(self, key: str) -> bool: + """Support 'key in data' syntax, with alias support.""" + canonical = FIELD_ALIASES.get(key, key) + return canonical in self.fields + + def has_field(self, key: str) -> bool: + """Check if a field exists (supports aliases).""" + return key in self def keys(self): """Return field names.""" @@ -182,7 +264,7 @@ def read_vtk_file(filename: str) -> Optional[VTKData]: if not line_content: i += 1 continue - if line_content.split()[0].isalpha(): + if line_content.split()[0] in _VTK_SECTION_HEADERS: break try: values.extend([float(x) for x in line_content.split()]) @@ -191,7 +273,8 @@ def read_vtk_file(filename: str) -> Optional[VTKData]: i += 1 if len(values) == nx * ny: - fields[field_name] = np.array(values).reshape((ny, nx)) + canonical = FIELD_ALIASES.get(field_name, field_name) + fields[canonical] = np.array(values).reshape((ny, nx)) continue i += 1 diff --git a/pyproject.toml b/pyproject.toml index b24d5a8..35a2f2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,6 +122,20 @@ indent-style = "space" # Unix-style line endings for cross-platform consistency line-ending = "lf" +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks end-to-end integration tests", +] +filterwarnings = [ + "error", + "ignore::DeprecationWarning:matplotlib.*", + "ignore::DeprecationWarning:numpy.*", + "ignore::matplotlib._api.deprecation.MatplotlibDeprecationWarning", + "ignore::pytest.PytestUnraisableExceptionWarning", +] + [dependency-groups] dev = [ "cfd-python>=0.1.6", diff --git a/tests/common/__init__.py b/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common/test_vtk_reader.py b/tests/common/test_vtk_reader.py new file mode 100644 index 0000000..d3a7d87 --- /dev/null +++ b/tests/common/test_vtk_reader.py @@ -0,0 +1,458 @@ +"""Tests for cfd_viz.common.vtk_reader module.""" + +import numpy as np +import pytest + +from cfd_viz.common.vtk_reader import ( + FIELD_ALIASES, + VTKData, + read_vtk_file, + read_vtk_velocity, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_vtk_data(nx=4, ny=3, fields=None): + """Create a minimal VTKData for testing.""" + x = np.linspace(0, 1, nx) + y = np.linspace(0, 1, ny) + X, Y = np.meshgrid(x, y) + dx = x[1] - x[0] if nx > 1 else 1.0 + dy = y[1] - y[0] if ny > 1 else 1.0 + if fields is None: + fields = { + "u": np.ones((ny, nx)), + "v": np.zeros((ny, nx)), + } + return VTKData(x=x, y=y, X=X, Y=Y, fields=fields, nx=nx, ny=ny, dx=dx, dy=dy) + + +def _write_structured_points( + path, nx, ny, vectors=None, scalars=None, origin=(0, 0, 0), spacing=(1, 1, 1) +): + """Write a minimal STRUCTURED_POINTS VTK file.""" + lines = [ + "# vtk DataFile Version 3.0", + "Test", + "ASCII", + "DATASET STRUCTURED_POINTS", + f"DIMENSIONS {nx} {ny} 1", + f"ORIGIN {origin[0]} {origin[1]} {origin[2]}", + f"SPACING {spacing[0]} {spacing[1]} {spacing[2]}", + "", + f"POINT_DATA {nx * ny}", + ] + if vectors is not None: + lines.append("VECTORS velocity float") + for u, v in zip(vectors[0].ravel(), vectors[1].ravel()): + lines.append(f"{u} {v} 0.0") + if scalars is not None: + for name, data in scalars.items(): + lines.append(f"SCALARS {name} float 1") + lines.append("LOOKUP_TABLE default") + for val in data.ravel(): + lines.append(str(val)) + path.write_text("\n".join(lines) + "\n") + + +# --------------------------------------------------------------------------- +# VTKData construction +# --------------------------------------------------------------------------- + + +class TestVTKDataConstruction: + def test_basic_construction(self): + data = _make_vtk_data() + assert data.nx == 4 + assert data.ny == 3 + assert data.X.shape == (3, 4) + + def test_u_v_properties(self): + data = _make_vtk_data() + assert data.u is not None + np.testing.assert_array_equal(data.u, np.ones((3, 4))) + np.testing.assert_array_equal(data.v, np.zeros((3, 4))) + + def test_u_v_properties_missing(self): + data = _make_vtk_data(fields={"p": np.zeros((3, 4))}) + assert data.u is None + assert data.v is None + + def test_field_access_by_name(self): + data = _make_vtk_data() + np.testing.assert_array_equal(data["u"], np.ones((3, 4))) + + def test_field_access_by_alias(self): + data = _make_vtk_data(fields={"p": np.ones((3, 4))}) + np.testing.assert_array_equal(data["pressure"], np.ones((3, 4))) + + def test_get_with_default(self): + data = _make_vtk_data() + assert data.get("nonexistent") is None + assert data.get("nonexistent", 42) == 42 + + def test_get_alias(self): + data = _make_vtk_data(fields={"p": np.ones((3, 4))}) + result = data.get("pressure") + assert result is not None + np.testing.assert_array_equal(result, np.ones((3, 4))) + + def test_has_field(self): + data = _make_vtk_data() + assert data.has_field("u") + assert not data.has_field("nonexistent") + + def test_has_field_alias(self): + data = _make_vtk_data(fields={"p": np.zeros((3, 4))}) + assert data.has_field("pressure") + + def test_keys_returns_canonical_names(self): + data = _make_vtk_data(fields={"u": np.zeros((3, 4)), "p": np.ones((3, 4))}) + assert set(data.keys()) == {"u", "p"} + + def test_to_dict(self): + data = _make_vtk_data() + d = data.to_dict() + assert "u" in d + assert "X" in d + assert d["nx"] == 4 + + def test_repr(self): + data = _make_vtk_data() + r = repr(data) + assert "VTKData" in r + assert "nx=4" in r + assert "ny=3" in r + assert "u" in r + + def test_invalid_field_shape_raises(self): + with pytest.raises(ValueError, match="Field 'u' has shape"): + _make_vtk_data(fields={"u": np.ones((5, 5))}) + + def test_nan_warning(self): + fields = {"u": np.array([[1, float("nan")], [3, 4]])} + with pytest.warns(UserWarning, match="NaN"): + _make_vtk_data(nx=2, ny=2, fields=fields) + + def test_inf_warning(self): + fields = {"u": np.array([[1, float("inf")], [3, 4]])} + with pytest.warns(UserWarning, match="infinite"): + _make_vtk_data(nx=2, ny=2, fields=fields) + + def test_getitem_missing_raises_keyerror(self): + data = _make_vtk_data() + with pytest.raises(KeyError): + data["nonexistent"] + + +# --------------------------------------------------------------------------- +# Reading STRUCTURED_POINTS +# --------------------------------------------------------------------------- + + +class TestReadVTKFileStructuredPoints: + def test_read_real_sample_file(self, tmp_path): + nx, ny = 50, 50 + u = np.ones((ny, nx)) + v = np.zeros((ny, nx)) + vtk_file = tmp_path / "flow_field_50x50_Re100.vtk" + _write_structured_points(vtk_file, nx=nx, ny=ny, vectors=(u, v)) + data = read_vtk_file(str(vtk_file)) + assert data is not None + assert data.nx == nx + assert data.ny == ny + assert data.u is not None + assert data.v is not None + assert data.u.shape == (ny, nx) + + def test_real_file_with_scalars(self, tmp_path): + nx, ny = 10, 8 + u = np.ones((ny, nx)) + v = np.zeros((ny, nx)) + p = np.arange(nx * ny, dtype=float).reshape(ny, nx) + vtk_file = tmp_path / "animated_flow_0050.vtk" + _write_structured_points( + vtk_file, + nx=nx, + ny=ny, + vectors=(u, v), + scalars={"pressure": p}, + ) + data = read_vtk_file(str(vtk_file)) + assert data is not None + # "pressure" should be normalized to "p" + assert "p" in data + assert data["p"].shape == (data.ny, data.nx) + + def test_origin_and_spacing(self, tmp_path): + nx, ny = 50, 50 + dx_expected = 0.020408 + u = np.ones((ny, nx)) + v = np.zeros((ny, nx)) + vtk_file = tmp_path / "flow_field_50x50_Re100.vtk" + _write_structured_points( + vtk_file, + nx=nx, + ny=ny, + vectors=(u, v), + origin=(0.0, 0.0, 0.0), + spacing=(dx_expected, dx_expected, 1.0), + ) + data = read_vtk_file(str(vtk_file)) + assert data.dx == pytest.approx(dx_expected, rel=1e-3) + assert data.x[0] == pytest.approx(0.0) + + def test_synthetic_minimal(self, tmp_path): + u = np.array([[1.0, 2.0], [3.0, 4.0]]) + v = np.array([[0.1, 0.2], [0.3, 0.4]]) + vtk_file = tmp_path / "test.vtk" + _write_structured_points(vtk_file, nx=2, ny=2, vectors=(u, v)) + data = read_vtk_file(str(vtk_file)) + assert data is not None + assert data.nx == 2 + assert data.ny == 2 + np.testing.assert_allclose(data.u, u) + np.testing.assert_allclose(data.v, v) + + def test_scalars_with_lookup_table(self, tmp_path): + u = np.ones((3, 3)) + v = np.zeros((3, 3)) + p = np.arange(9, dtype=float).reshape(3, 3) + vtk_file = tmp_path / "test.vtk" + _write_structured_points( + vtk_file, + nx=3, + ny=3, + vectors=(u, v), + scalars={"pressure": p}, + ) + data = read_vtk_file(str(vtk_file)) + assert data is not None + # "pressure" normalized to "p" + assert "p" in data + np.testing.assert_allclose(data["p"], p) + + def test_custom_origin_and_spacing(self, tmp_path): + u = np.ones((2, 3)) + v = np.zeros((2, 3)) + vtk_file = tmp_path / "test.vtk" + _write_structured_points( + vtk_file, + nx=3, + ny=2, + vectors=(u, v), + origin=(1.0, 2.0, 0.0), + spacing=(0.5, 0.25, 1.0), + ) + data = read_vtk_file(str(vtk_file)) + assert data.x[0] == pytest.approx(1.0) + assert data.x[-1] == pytest.approx(2.0) + assert data.y[0] == pytest.approx(2.0) + assert data.dx == pytest.approx(0.5) + assert data.dy == pytest.approx(0.25) + + +# --------------------------------------------------------------------------- +# Reading RECTILINEAR_GRID +# --------------------------------------------------------------------------- + + +class TestReadVTKFileRectilinearGrid: + def test_synthetic_rectilinear(self, tmp_path): + nx, ny = 3, 3 + content = ( + "# vtk DataFile Version 3.0\n" + "Rectilinear Test\n" + "ASCII\n" + "DATASET RECTILINEAR_GRID\n" + f"DIMENSIONS {nx} {ny} 1\n" + "X_COORDINATES 3 float\n" + "0.0 0.5 1.0\n" + "Y_COORDINATES 3 float\n" + "0.0 0.5 1.0\n" + "Z_COORDINATES 1 float\n" + "0.0\n" + "\n" + f"POINT_DATA {nx * ny}\n" + "VECTORS velocity float\n" + ) + u_vals = np.arange(1, 10, dtype=float) + v_vals = np.zeros(9) + vec_lines = "\n".join(f"{u} {v} 0.0" for u, v in zip(u_vals, v_vals)) + vtk_file = tmp_path / "rect.vtk" + vtk_file.write_text(content + vec_lines + "\n") + + data = read_vtk_file(str(vtk_file)) + assert data is not None + assert data.nx == 3 + assert data.ny == 3 + assert data.x[0] == pytest.approx(0.0) + assert data.x[-1] == pytest.approx(1.0) + np.testing.assert_allclose(data.u.ravel(), u_vals) + + def test_non_uniform_spacing(self, tmp_path): + nx, ny = 3, 2 + content = ( + "# vtk DataFile Version 3.0\n" + "Non-uniform\n" + "ASCII\n" + "DATASET RECTILINEAR_GRID\n" + f"DIMENSIONS {nx} {ny} 1\n" + "X_COORDINATES 3 float\n" + "0.0 0.3 1.0\n" + "Y_COORDINATES 2 float\n" + "0.0 1.0\n" + "Z_COORDINATES 1 float\n" + "0.0\n" + "\n" + f"POINT_DATA {nx * ny}\n" + "VECTORS velocity float\n" + ) + vec_lines = "\n".join("1.0 0.0 0.0" for _ in range(nx * ny)) + vtk_file = tmp_path / "nonuniform.vtk" + vtk_file.write_text(content + vec_lines + "\n") + + data = read_vtk_file(str(vtk_file)) + assert data is not None + # Non-uniform: dx is computed from first two coordinates + assert data.x[1] == pytest.approx(0.3) + + +# --------------------------------------------------------------------------- +# Malformed files +# --------------------------------------------------------------------------- + + +class TestReadVTKFileMalformed: + def test_missing_dimensions(self, tmp_path): + content = ( + "# vtk DataFile Version 3.0\n" + "No dims\n" + "ASCII\n" + "DATASET STRUCTURED_POINTS\n" + "POINT_DATA 4\n" + "VECTORS velocity float\n" + "1.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + ) + vtk_file = tmp_path / "nodims.vtk" + vtk_file.write_text(content) + with pytest.raises(ValueError, match="dimensions not found"): + read_vtk_file(str(vtk_file)) + + def test_file_not_found(self): + result = read_vtk_file("/nonexistent/path/to/file.vtk") + assert result is None + + def test_empty_file(self, tmp_path): + vtk_file = tmp_path / "empty.vtk" + vtk_file.write_text("") + with pytest.raises(ValueError, match="dimensions not found"): + read_vtk_file(str(vtk_file)) + + def test_truncated_vector_data(self, tmp_path): + content = ( + "# vtk DataFile Version 3.0\n" + "Truncated\n" + "ASCII\n" + "DATASET STRUCTURED_POINTS\n" + "DIMENSIONS 3 3 1\n" + "ORIGIN 0 0 0\n" + "SPACING 1 1 1\n" + "\n" + "POINT_DATA 9\n" + "VECTORS velocity float\n" + "1.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + ) + vtk_file = tmp_path / "truncated.vtk" + vtk_file.write_text(content) + # Truncated data causes reshape error + with pytest.raises(ValueError, match="cannot reshape"): + read_vtk_file(str(vtk_file)) + + +# --------------------------------------------------------------------------- +# read_vtk_velocity convenience function +# --------------------------------------------------------------------------- + + +class TestReadVTKVelocity: + def test_returns_tuple(self, tmp_path): + nx, ny = 50, 50 + u_in = np.ones((ny, nx)) + v_in = np.zeros((ny, nx)) + vtk_file = tmp_path / "flow_field.vtk" + _write_structured_points(vtk_file, nx=nx, ny=ny, vectors=(u_in, v_in)) + result = read_vtk_velocity(str(vtk_file)) + X, Y, u, v = result + assert X is not None + assert X.shape == (ny, nx) + assert u.shape == (ny, nx) + + def test_file_not_found_returns_nones(self): + result = read_vtk_velocity("/nonexistent/file.vtk") + assert all(r is None for r in result) + + +# --------------------------------------------------------------------------- +# Field aliases +# --------------------------------------------------------------------------- + + +class TestFieldAliases: + def test_pressure_alias_maps_to_p(self): + assert FIELD_ALIASES["pressure"] == "p" + + def test_velocity_aliases(self): + assert FIELD_ALIASES["velocity_x"] == "u" + assert FIELD_ALIASES["velocity_y"] == "v" + + def test_canonical_names_not_aliased_to_themselves(self): + # Canonical names should not appear as keys in FIELD_ALIASES + assert "u" not in FIELD_ALIASES + assert "v" not in FIELD_ALIASES + assert "p" not in FIELD_ALIASES + + def test_canonical_name_normalization_at_read_time(self, tmp_path): + """SCALARS 'pressure' should be stored as 'p' in fields.""" + u = np.ones((2, 2)) + v = np.zeros((2, 2)) + p = np.array([[1.0, 2.0], [3.0, 4.0]]) + vtk_file = tmp_path / "test.vtk" + _write_structured_points( + vtk_file, + nx=2, + ny=2, + vectors=(u, v), + scalars={"pressure": p}, + ) + data = read_vtk_file(str(vtk_file)) + # Stored under canonical name "p", not "pressure" + assert "p" in data.fields + assert "pressure" not in data.fields + # But accessible via alias through __contains__ and __getitem__ + assert "pressure" in data + np.testing.assert_allclose(data["pressure"], p) + + def test_unknown_scalar_name_preserved(self, tmp_path): + """SCALARS with unknown names are stored as-is.""" + u = np.ones((2, 2)) + v = np.zeros((2, 2)) + temp = np.array([[300.0, 301.0], [302.0, 303.0]]) + vtk_file = tmp_path / "test.vtk" + _write_structured_points( + vtk_file, + nx=2, + ny=2, + vectors=(u, v), + scalars={"temperature": temp}, + ) + data = read_vtk_file(str(vtk_file)) + assert "temperature" in data + np.testing.assert_allclose(data["temperature"], temp) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..8ab10ac --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,125 @@ +"""End-to-end integration tests for cfd-visualization pipeline.""" + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from cfd_viz.common import read_vtk_file +from cfd_viz.convert import from_cfd_python +from cfd_viz.fields import magnitude, vorticity +from cfd_viz.plotting import plot_contour_field + + +def _write_structured_points( + path, nx, ny, vectors=None, scalars=None, origin=(0, 0, 0), spacing=(1, 1, 1) +): + """Write a minimal STRUCTURED_POINTS VTK file.""" + lines = [ + "# vtk DataFile Version 3.0", + "Test", + "ASCII", + "DATASET STRUCTURED_POINTS", + f"DIMENSIONS {nx} {ny} 1", + f"ORIGIN {origin[0]} {origin[1]} {origin[2]}", + f"SPACING {spacing[0]} {spacing[1]} {spacing[2]}", + "", + f"POINT_DATA {nx * ny}", + ] + if vectors is not None: + lines.append("VECTORS velocity float") + for u, v in zip(vectors[0].ravel(), vectors[1].ravel()): + lines.append(f"{u} {v} 0.0") + if scalars is not None: + for name, data in scalars.items(): + lines.append(f"SCALARS {name} float 1") + lines.append("LOOKUP_TABLE default") + for val in data.ravel(): + lines.append(str(val)) + path.write_text("\n".join(lines) + "\n") + + +@pytest.mark.integration +class TestEndToEnd: + def test_vtk_read_compute_plot(self, tmp_path): + """Read VTK file -> compute derived fields -> plot -> no exceptions.""" + nx, ny = 50, 50 + rng = np.random.default_rng(0) + u = rng.standard_normal((ny, nx)) + v = rng.standard_normal((ny, nx)) + vtk_file = tmp_path / "flow_field.vtk" + _write_structured_points(vtk_file, nx=nx, ny=ny, vectors=(u, v)) + + data = read_vtk_file(str(vtk_file)) + assert data is not None + + speed = magnitude(data.u, data.v) + omega = vorticity(data.u, data.v, data.dx, data.dy) + + assert speed.shape == (data.ny, data.nx) + assert omega.shape == (data.ny, data.nx) + + fig, axes = plt.subplots(1, 2, figsize=(12, 5)) + plot_contour_field(data.X, data.Y, speed, ax=axes[0], title="Speed") + plot_contour_field(data.X, data.Y, omega, ax=axes[1], title="Vorticity") + plt.close(fig) + + def test_cfd_python_convert_plot(self): + """from_cfd_python -> compute -> plot -> no exceptions.""" + nx, ny = 20, 20 + rng = np.random.default_rng(42) + u_flat = rng.standard_normal(nx * ny).tolist() + v_flat = rng.standard_normal(nx * ny).tolist() + p_flat = rng.standard_normal(nx * ny).tolist() + + data = from_cfd_python(u_flat, v_flat, nx=nx, ny=ny, p=p_flat) + + speed = magnitude(data.u, data.v) + fig, ax = plt.subplots() + plot_contour_field(data.X, data.Y, speed, ax=ax) + plt.close(fig) + + def test_field_alias_in_pipeline(self, tmp_path): + """Verify aliases work through the full pipeline.""" + nx, ny = 10, 8 + u = np.ones((ny, nx)) + v = np.zeros((ny, nx)) + p = np.arange(nx * ny, dtype=float).reshape(ny, nx) + vtk_file = tmp_path / "animated_flow.vtk" + _write_structured_points( + vtk_file, nx=nx, ny=ny, vectors=(u, v), scalars={"pressure": p} + ) + + data = read_vtk_file(str(vtk_file)) + assert data is not None + + # "pressure" was normalized to "p" at read time + p_field = data.get("pressure") + assert p_field is not None + + fig, ax = plt.subplots() + plot_contour_field(data.X, data.Y, p_field, ax=ax) + plt.close(fig) + + def test_version_accessible(self): + """__version__ should be a non-empty string.""" + from cfd_viz import __version__ + + assert isinstance(__version__, str) + assert len(__version__) > 0 + + def test_vtk_data_repr(self, tmp_path): + """VTKData repr should be informative.""" + nx, ny = 50, 50 + u = np.ones((ny, nx)) + v = np.zeros((ny, nx)) + vtk_file = tmp_path / "flow_field.vtk" + _write_structured_points(vtk_file, nx=nx, ny=ny, vectors=(u, v)) + + data = read_vtk_file(str(vtk_file)) + r = repr(data) + assert "VTKData" in r + assert "50" in r + assert "u" in r