diff --git a/README.md b/README.md index 7918099..e9527c2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ RAPTOR requires requires Python 3 (tested with Python 3.8+). The following Pytho ``` * **NumPy**: For numerical operations and array manipulation. - + * **Numba**: For JIT compilation and performance acceleration. * **PyYAML**: For reading and parsing YAML configuration files * **VTK**: For writing the output porosity map in `.vti` format diff --git a/pyproject.toml b/pyproject.toml index 2fa0a21..20caf4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,8 @@ dependencies = [ "numba", "PyYAML", "vtk", - "scikit-image" + "scikit-image", + "pytest" ] [project.scripts] diff --git a/src/raptor/api.py b/src/raptor/api.py index 18f6e0b..6436607 100644 --- a/src/raptor/api.py +++ b/src/raptor/api.py @@ -69,18 +69,18 @@ def compute_spectral_components(melt_pool_data: np.ndarray, n_modes: int) -> np. fft_resolution = np.fft.fft(melt_pool_data[:, 1]) F = np.zeros_like(fft_resolution) n_fft = len(fft_resolution) - - for i in range(1, n_modes): - F[i] = fft_resolution[i] - F[n_fft - i] = fft_resolution[n_fft - i] - - frequencies = np.float64(1 / (dt * n_fft)) * np.arange(n_modes, dtype=np.float64) - phases = np.float64(np.angle(F[:n_modes])) - amplitudes = np.float64(np.abs(F[:n_modes]) / n_fft) - if n_modes == 1: spectral_array = np.array([[mode0, 0, 0]]) else: + for i in range(1, n_modes): + F[i] = fft_resolution[i] + F[n_fft - i] = fft_resolution[n_fft - i] + + frequencies = np.float64(1 / (dt * n_fft)) * np.arange( + n_modes, dtype=np.float64 + ) + phases = np.float64(np.angle(F[:n_modes])) + amplitudes = np.float64(np.abs(F[:n_modes]) / n_fft) spectral_array = np.vstack( [ np.array([mode0, 0, 0]), @@ -250,7 +250,7 @@ def compute_morphology( minsize = 2 filtered_defects = remove_small_objects(labeled_defects, minsize) - return measure.regionproperties_table( + return measure.regionprops_table( filtered_defects, spacing=voxel_resolution, properties=morphology_fields ) diff --git a/src/raptor/structures.py b/src/raptor/structures.py index 3d0b8c2..fe36e97 100644 --- a/src/raptor/structures.py +++ b/src/raptor/structures.py @@ -183,15 +183,32 @@ def __init__( bound_box: Optional[np.ndarray] = None, path_vectors: Optional[List[PathVector]] = None, ): + if voxel_resolution <= 0.0: + raise ValueError("Voxel resolution must be a positive non-zero value.") self.resolution = voxel_resolution if bound_box is not None: # Option 1: Grid is constructed from a user-defined bounding box. + if bound_box.shape != (2, 3): + raise ValueError( + "Bounding box must be of shape (2, 3) " + "representing [[x0, y0, z0], [x1, y1, z1]]." + ) + if np.any(bound_box[1] <= bound_box[0]): + raise ValueError( + "Invalid bounding box: " + "Maximum corner must be greater than minimum corner." + ) gx0, gy0, gz0 = bound_box[0] gx1, gy1, gz1 = bound_box[1] elif path_vectors is not None: # Option 2: Grid is constructed from boundaries of path vectors. + for pv in path_vectors: + if not isinstance(pv, PathVector): + raise ValueError( + "All elements in 'path_vectors' must be of type PathVector." + ) all_points = np.vstack( [p.start_point for p in path_vectors] + [p.end_point for p in path_vectors] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d572071 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +# ============================================================================= +# Copyright (c) 2025 Oak Ridge National Laboratory +# +# All rights reserved. +# +# This file is part of Raptor. +# +# For details, see the top-level LICENSE file at: +# https://github.com/ORNL-MDF/Raptor/LICENSE +# ============================================================================= diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..7885c3a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,548 @@ +# ============================================================================= +# Copyright (c) 2025 Oak Ridge National Laboratory +# +# All rights reserved. +# +# This file is part of Raptor. +# +# For details, see the top-level LICENSE file at: +# https://github.com/ORNL-MDF/Raptor/LICENSE +# ============================================================================= +""" +Test suite for raptor.api module. + +This module contains unit tests for all public API functions in the raptor.api module, +including grid creation, path vector generation, spectral component computation, +melt pool creation, porosity computation, and VTK output generation. +""" + +import pytest +import numpy as np +import tempfile +import os +from pathlib import Path +from typing import List, Dict, Any + +# Import the module under test +from raptor.api import ( + create_grid, + create_path_vectors, + compute_spectral_components, + create_melt_pool, + compute_porosity, + write_vtk, + compute_morphology, + write_morphology, +) +from raptor.structures import Grid, PathVector, MeltPool + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def sample_bound_box(): + """Fixture providing a sample bounding box for testing.""" + return np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 0.5]]) # min point # max point + + +@pytest.fixture +def sample_voxel_resolution(): + """Fixture providing a sample voxel resolution.""" + return 0.01 + + +@pytest.fixture +def sample_path_vectors(): + """Fixture providing sample path vectors.""" + start_point = np.array([0.0, 0.0, 0.0]) + end_point = np.array([1.0, 1.0, 0.0]) + start_time = 0.0 + end_time = 1.0 + path_vector = PathVector( + start_point=start_point, + end_point=end_point, + start_time=start_time, + end_time=end_time, + ) + return [path_vector] + + +@pytest.fixture +def sample_process_parameters(): + """Fixture providing sample process parameters.""" + return { + "power": 200.0, + "scan_speed": 1.0, + "hatch_spacing": 0.1, + "layer_height": 0.05, + "rotation": 67.0, + "scan_extension": 0.1, + "extra_layers": 0, + } + + +@pytest.fixture +def sample_time_series_data(): + """Fixture providing sample time series data for melt pool.""" + t = np.linspace(0, 1, 100) + values = 0.0001 + 0.00002 * np.sin(2 * np.pi * 5 * t) + return np.column_stack([t, values]) + + +@pytest.fixture +def sample_spectral_components(): + """Fixture providing sample spectral components.""" + # Format: [amplitude, frequency, phase] + return np.array( + [ + [0.0001, 0.0, 0.0], # mode 0 (mean) + [0.00002, 5.0, 0.0], # mode 1 + [0.00001, 10.0, np.pi / 2], # mode 2 + ], + dtype=np.float64, + ) + + +@pytest.fixture +def sample_melt_pool_dict(sample_time_series_data): + """Fixture providing a sample melt pool dictionary.""" + return { + "width": (sample_time_series_data, 3, 1.0, 2.0), + "depth": (sample_time_series_data, 3, 1.0, 2.0), + "height": (sample_time_series_data, 3, 1.0, 2.0), + } + + +@pytest.fixture +def temp_output_dir(): + """Fixture providing a temporary directory for output files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +# ============================================================================= +# Tests for create_grid +# ============================================================================= + + +class TestCreateGrid: + """Test cases for the create_grid function.""" + + def test_create_grid_with_bound_box( + self, sample_voxel_resolution, sample_bound_box + ): + """Test grid creation with a bounding box.""" + grid = create_grid(sample_voxel_resolution, bound_box=sample_bound_box) + + assert isinstance(grid, Grid) + assert grid.resolution == sample_voxel_resolution + assert grid.origin.shape == (3,) + assert grid.shape[0] * grid.shape[1] * grid.shape[2] > 0 + assert grid.voxels.shape == (grid.shape[0] * grid.shape[1] * grid.shape[2], 3) + + def test_create_grid_with_path_vectors( + self, sample_voxel_resolution, sample_path_vectors + ): + """Test grid creation with path vectors.""" + grid = create_grid(sample_voxel_resolution, path_vectors=sample_path_vectors) + + assert isinstance(grid, Grid) + assert grid.resolution == sample_voxel_resolution + assert np.all(grid.origin == np.array([0.0, 0.0, 0.0])) + + def test_create_grid_invalid_resolution(self, bound_box=sample_bound_box): + """Test grid creation with invalid voxel resolution.""" + with pytest.raises(ValueError): + create_grid(-0.01, bound_box=bound_box) + + def test_create_grid_invalid_bound_box(self, sample_voxel_resolution): + """Test grid creation with invalid bounding box.""" + invalid_bound_box = np.array([[0, 0, 0], [1, -1, 1]]) + with pytest.raises(ValueError): + create_grid(sample_voxel_resolution, bound_box=invalid_bound_box) + + def test_create_grid_invalid_path_vectors(self, sample_voxel_resolution): + """Test grid creation with invalid path vectors.""" + invalid_path_vectors = [123, "invalid", None] + with pytest.raises(ValueError): + create_grid(sample_voxel_resolution, path_vectors=invalid_path_vectors) + + +# ============================================================================= +# Tests for create_path_vectors +# ============================================================================= + + +class TestCreatePathVectors: + """Test cases for the create_path_vectors function.""" + + def test_create_path_vectors_basic( + self, sample_bound_box, sample_process_parameters + ): + """Test basic path vector generation.""" + path_vectors = create_path_vectors( + sample_bound_box, **sample_process_parameters + ) + + assert isinstance(path_vectors, list) + assert len(path_vectors) > 0 + assert all(isinstance(pv, PathVector) for pv in path_vectors) + + def test_create_path_vectors_single_layer( + self, sample_bound_box, sample_process_parameters + ): + """Test path vector generation for a single layer.""" + params = sample_process_parameters.copy() + params["extra_layers"] = 0 + + path_vectors = create_path_vectors(sample_bound_box, **params) + + # TODO: Verify single layer generation + assert len(path_vectors) > 0 + + def test_create_path_vectors_multiple_layers( + self, sample_bound_box, sample_process_parameters + ): + """Test path vector generation for multiple layers.""" + params = sample_process_parameters.copy() + params["extra_layers"] = 3 + + path_vectors = create_path_vectors(sample_bound_box, **params) + + # TODO: Verify multiple layer generation + assert len(path_vectors) > 0 + + def test_create_path_vectors_rotation( + self, sample_bound_box, sample_process_parameters + ): + """Test path vector generation with rotation.""" + # TODO: Test different rotation angles + pass + + def test_create_path_vectors_hatch_spacing( + self, sample_bound_box, sample_process_parameters + ): + """Test path vector generation with different hatch spacings.""" + # TODO: Test effect of hatch spacing on vector count + pass + + def test_create_path_vectors_invalid_parameters(self, sample_bound_box): + """Test path vector generation with invalid parameters.""" + # TODO: Test with negative or invalid values + pass + + +# ============================================================================= +# Tests for compute_spectral_components +# ============================================================================= + + +class TestComputeSpectralComponents: + """Test cases for the compute_spectral_components function.""" + + def test_compute_spectral_components_basic(self, sample_time_series_data): + """Test basic spectral component computation.""" + n_modes = 3 + spectral_array = compute_spectral_components(sample_time_series_data, n_modes) + + assert isinstance(spectral_array, np.ndarray) + assert spectral_array.shape == (n_modes, 3) + assert spectral_array.dtype == np.float64 + + def test_compute_spectral_components_single_mode(self, sample_time_series_data): + """Test spectral component computation with single mode.""" + n_modes = 1 + spectral_array = compute_spectral_components(sample_time_series_data, n_modes) + + assert spectral_array.shape == (1, 3) + assert spectral_array[0, 1] == 0 # frequency should be 0 + assert spectral_array[0, 2] == 0 # phase should be 0 + + def test_compute_spectral_components_multiple_modes(self, sample_time_series_data): + """Test spectral component computation with multiple modes.""" + for n_modes in [2, 5, 10]: + spectral_array = compute_spectral_components( + sample_time_series_data, n_modes + ) + assert spectral_array.shape == (n_modes, 3) + + def test_compute_spectral_components_mean_value(self, sample_time_series_data): + """Test that mode 0 matches the mean of input data.""" + spectral_array = compute_spectral_components(sample_time_series_data, 3) + expected_mean = sample_time_series_data[:, 1].mean() + + np.testing.assert_allclose(spectral_array[0, 0], expected_mean) + + def test_compute_spectral_components_invalid_input(self): + """Test spectral component computation with invalid input.""" + # TODO: Test with malformed input data + pass + + +# ============================================================================= +# Tests for create_melt_pool +# ============================================================================= + + +class TestCreateMeltPool: + """Test cases for the create_melt_pool function.""" + + def test_create_melt_pool_basic(self, sample_melt_pool_dict): + """Test basic melt pool creation.""" + melt_pool = create_melt_pool(sample_melt_pool_dict, enable_random_phases=False) + + assert isinstance(melt_pool, MeltPool) + assert melt_pool.enable_random_phases == False + + def test_create_melt_pool_random_phases(self, sample_melt_pool_dict): + """Test melt pool creation with random phases enabled.""" + melt_pool = create_melt_pool(sample_melt_pool_dict, enable_random_phases=True) + + assert melt_pool.enable_random_phases == True + + def test_create_melt_pool_spectral_input(self, sample_spectral_components): + """Test melt pool creation with spectral component input.""" + melt_pool_dict = { + "width": (sample_spectral_components, 3, 1.0, 2.0), + "depth": (sample_spectral_components, 3, 1.0, 2.0), + "height": (sample_spectral_components, 3, 1.0, 2.0), + } + + melt_pool = create_melt_pool(melt_pool_dict, enable_random_phases=False) + + assert isinstance(melt_pool, MeltPool) + + def test_create_melt_pool_mode_padding(self): + """Test that melt pool correctly pads modes to match maximum.""" + # TODO: Create inputs with different mode counts + pass + + def test_create_melt_pool_scaling(self, sample_time_series_data): + """Test that scaling is correctly applied.""" + scale_factor = 2.0 + melt_pool_dict = { + "width": (sample_time_series_data, 3, scale_factor, 2.0), + "depth": (sample_time_series_data, 3, 1.0, 2.0), + "height": (sample_time_series_data, 3, 1.0, 2.0), + } + + melt_pool = create_melt_pool(melt_pool_dict, enable_random_phases=False) + + # TODO: Verify scaling is applied correctly + assert isinstance(melt_pool, MeltPool) + + def test_create_melt_pool_shape_factors(self, sample_melt_pool_dict): + """Test that shape factors are correctly set.""" + melt_pool = create_melt_pool(sample_melt_pool_dict, enable_random_phases=False) + + assert melt_pool.depth_shape_factor == 2.0 + assert melt_pool.height_shape_factor == 2.0 + + def test_create_melt_pool_invalid_data_shape(self): + """Test melt pool creation with invalid data shape.""" + # TODO: Test with data that is not Nx2 or Nx3 + pass + + +# ============================================================================= +# Tests for compute_porosity +# ============================================================================= + + +class TestComputePorosity: + """Test cases for the compute_porosity function.""" + + def test_compute_porosity_basic(self): + """Test basic porosity computation.""" + # TODO: Create minimal grid, path vectors, and melt pool + pass + + def test_compute_porosity_with_warmup(self): + """Test porosity computation with JIT warmup enabled.""" + # TODO: Test with jit_warmup=True + pass + + def test_compute_porosity_without_warmup(self): + """Test porosity computation with JIT warmup disabled.""" + # TODO: Test with jit_warmup=False + pass + + def test_compute_porosity_output_shape(self): + """Test that output porosity field has correct shape.""" + # TODO: Verify output shape matches grid shape + pass + + def test_compute_porosity_output_dtype(self): + """Test that output porosity field has correct dtype.""" + # TODO: Verify output dtype is int8 + pass + + def test_compute_porosity_single_vector(self): + """Test porosity computation with a single path vector.""" + # TODO: Test minimal case + pass + + +# ============================================================================= +# Tests for write_vtk +# ============================================================================= + + +class TestWriteVtk: + """Test cases for the write_vtk function.""" + + def test_write_vtk_basic(self, temp_output_dir): + """Test basic VTK file writing.""" + origin = np.array([0.0, 0.0, 0.0]) + voxel_resolution = 0.01 + porosity = np.zeros((10, 10, 10), dtype=np.int8) + porosity[5, 5, 5] = 1 + + output_path = temp_output_dir / "test_output.vti" + + write_vtk(origin, voxel_resolution, porosity, str(output_path)) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_write_vtk_file_creation(self, temp_output_dir): + """Test that VTK file is created at specified path.""" + origin = np.array([0.0, 0.0, 0.0]) + porosity = np.ones((5, 5, 5), dtype=np.int8) + output_path = temp_output_dir / "porosity.vti" + + write_vtk(origin, 0.01, porosity, str(output_path)) + + assert output_path.exists() + + def test_write_vtk_different_origins(self, temp_output_dir): + """Test VTK writing with different origin points.""" + # TODO: Test with various origin coordinates + pass + + def test_write_vtk_different_resolutions(self, temp_output_dir): + """Test VTK writing with different voxel resolutions.""" + # TODO: Test with various resolutions + pass + + def test_write_vtk_large_array(self, temp_output_dir): + """Test VTK writing with large porosity array.""" + # TODO: Test with larger array sizes + pass + + def test_write_vtk_invalid_path(self): + """Test VTK writing with invalid output path.""" + # TODO: Test with invalid file path + pass + + +# ============================================================================= +# Tests for compute_morphology +# ============================================================================= + + +class TestComputeMorphology: + """Test cases for the compute_morphology function.""" + + def test_compute_morphology_basic(self): + """Test basic morphology computation.""" + porosity = np.zeros((20, 20, 20), dtype=np.uint8) + porosity[5:8, 5:8, 5:8] = 1 # Add a pore + porosity[15:18, 15:18, 15:18] = 1 # Add another pore + + morphology_fields = ["area", "centroid"] + properties = compute_morphology(porosity, 0.01, morphology_fields) + + assert isinstance(properties, (dict, np.ndarray)) + # TODO: Add more specific assertions + + def test_compute_morphology_single_pore(self): + """Test morphology computation with a single pore.""" + # TODO: Create porosity with single isolated pore + pass + + def test_compute_morphology_multiple_pores(self): + """Test morphology computation with multiple pores.""" + # TODO: Create porosity with multiple isolated pores + pass + + def test_compute_morphology_no_pores(self): + """Test morphology computation with no pores.""" + porosity = np.zeros((10, 10, 10), dtype=np.uint8) + + properties = compute_morphology(porosity, 0.01, ["area"]) + + # TODO: Verify behavior with no pores + assert isinstance(properties, (dict, np.ndarray)) + + def test_compute_morphology_all_fields(self): + """Test morphology computation with all available fields.""" + # TODO: Test with comprehensive list of morphology fields + pass + + +# ============================================================================= +# Tests for write_morphology +# ============================================================================= + + +class TestWriteMorphology: + """Test cases for the write_morphology function.""" + + def test_write_morphology_basic(self, temp_output_dir): + """Test basic morphology file writing.""" + properties = { + "area": np.array([1.0, 2.0, 3.0]), + "centroid-0": np.array([0.5, 1.5, 2.5]), + "centroid-1": np.array([0.5, 1.5, 2.5]), + "centroid-2": np.array([0.5, 1.5, 2.5]), + } + + output_path = temp_output_dir / "morphology.csv" + + write_morphology(properties, str(output_path)) + + assert output_path.exists() + assert output_path.stat().st_size > 0 + + def test_write_morphology_file_format(self, temp_output_dir): + """Test that morphology file has correct CSV format.""" + # TODO: Parse and verify CSV structure + pass + + def test_write_morphology_empty_properties(self, temp_output_dir): + """Test morphology writing with empty properties.""" + # TODO: Test edge case with no defects + pass + + def test_write_morphology_column_headers(self, temp_output_dir): + """Test that column headers match property keys.""" + # TODO: Verify CSV headers + pass + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestApiIntegration: + """Integration tests combining multiple API functions.""" + + def test_full_workflow_basic(self, temp_output_dir): + """Test complete workflow from grid creation to VTK output.""" + # TODO: Create end-to-end test + pass + + def test_full_workflow_with_morphology(self, temp_output_dir): + """Test complete workflow including morphology analysis.""" + # TODO: Create end-to-end test with morphology + pass + + def test_grid_to_porosity_pipeline(self): + """Test pipeline from grid creation through porosity computation.""" + # TODO: Test combined workflow + pass diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..43c49dd --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,625 @@ +# ============================================================================= +# Copyright (c) 2025 Oak Ridge National Laboratory +# +# All rights reserved. +# +# This file is part of Raptor. +# +# For details, see the top-level LICENSE file at: +# https://github.com/ORNL-MDF/Raptor/LICENSE +# ============================================================================= +""" +Test suite for raptor.core module. + +This module contains unit tests for the core computation functions including +geometric containment testing, melt mask computation, and the main parallelized +implicit melt mask calculation. +""" + +import pytest +import numpy as np +from typing import List +from unittest.mock import Mock, MagicMock + +# Import the module under test +from raptor.core import is_inside, compute_melt_mask, compute_melt_mask_implicit +from raptor.structures import MeltPool, PathVector + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture +def sample_melt_pool(): + """Fixture providing a sample MeltPool object.""" + width_osc = np.array( + [[0.0001, 0.0, 0.0], [0.00002, 5.0, 0.0], [0.00001, 10.0, 0.0]], + dtype=np.float64, + ) + + depth_osc = np.array( + [[0.00008, 0.0, 0.0], [0.00001, 5.0, 0.0], [0.00001, 10.0, 0.0]], + dtype=np.float64, + ) + + height_osc = np.array( + [[0.00006, 0.0, 0.0], [0.00001, 5.0, 0.0], [0.00001, 10.0, 0.0]], + dtype=np.float64, + ) + + return MeltPool( + width_oscillations=width_osc, + depth_oscillations=depth_osc, + height_oscillations=height_osc, + width_max=0.00012, + depth_max=0.0001, + height_max=0.00008, + width_shape_factor=2.0, + height_shape_factor=2.0, + depth_shape_factor=2.0, + enable_random_phases=False, + ) + + +@pytest.fixture +def sample_path_vector(): + """Fixture providing a sample PathVector object.""" + start = np.array([0.0, 0.0, 0.0], dtype=np.float64) + end = np.array([0.001, 0.0, 0.0], dtype=np.float64) + + path_vec = PathVector( + start_point=start, end_point=end, start_time=0.0, end_time=0.001 + ) + path_vec.set_coordinate_frame() + + return path_vec + + +@pytest.fixture +def sample_path_vectors(sample_path_vector): + """Fixture providing a list of sample PathVector objects.""" + vectors = [] + + # Create multiple parallel vectors + for i in range(3): + start = np.array([0.0, i * 0.0001, 0.0], dtype=np.float64) + end = np.array([0.001, i * 0.0001, 0.0], dtype=np.float64) + + vec = PathVector( + start_point=start, + end_point=end, + start_time=i * 0.001, + end_time=(i + 1) * 0.001, + ) + vec.set_coordinate_frame() + vectors.append(vec) + + return vectors + + +@pytest.fixture +def sample_voxels(): + """Fixture providing a sample voxel array.""" + # Create a small grid of voxels + x = np.linspace(0, 0.001, 10) + y = np.linspace(0, 0.0005, 5) + z = np.linspace(0, 0.0002, 3) + + xx, yy, zz = np.meshgrid(x, y, z, indexing="ij") + + voxels = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()]) + + return voxels.astype(np.float64) + + +@pytest.fixture +def minimal_voxels(): + """Fixture providing a minimal voxel array for testing.""" + return np.array( + [[0.0, 0.0, 0.0], [0.0005, 0.0, 0.0], [0.001, 0.0, 0.0]], dtype=np.float64 + ) + + +# ============================================================================= +# Tests for is_inside function +# ============================================================================= + + +class TestIsInside: + """Test cases for the is_inside geometric containment function.""" + + def test_is_inside_center_point(self): + """Test that the center point (0,0) is always inside.""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + result = is_inside(0.0, 0.0, width, height, depth, 2.0, 2.0) + + assert result == True + + def test_is_inside_ellipse_shape(self): + """Test containment with ellipse shape (n=2).""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Point on the boundary (should be inside) + y = width / 2.0 + z = 0.0 + result = is_inside(y, z, width, height, depth, 2.0, 2.0) + assert result == True + + # Point clearly outside + y = width + z = height + result = is_inside(y, z, width, height, depth, 2.0, 2.0) + assert result == False + + def test_is_inside_parabola_shape(self): + """Test containment with parabola shape (n=1).""" + # TODO: Test with n=1 + pass + + def test_is_inside_bell_shape(self): + """Test containment with bell-like shape (n=0.5).""" + # TODO: Test with n=0.5 + pass + + def test_is_inside_positive_z(self): + """Test containment for points with positive z (using height).""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Point with positive z + y = 0.0 + z = height / 2.0 # Should be inside + result = is_inside(y, z, width, height, depth, 2.0, 2.0) + assert result == True + + def test_is_inside_negative_z(self): + """Test containment for points with negative z (using depth).""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Point with negative z + y = 0.0 + z = -depth / 2.0 # Should be inside + result = is_inside(y, z, width, height, depth, 2.0, 2.0) + assert result == True + + def test_is_inside_different_shape_factors(self): + """Test containment with different shape factors for height and depth.""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Different shape factors + result = is_inside(0.00005, 0.00003, width, height, depth, 2.0, 10.0) + # TODO: Verify different shape factor behavior + + def test_is_inside_boundary_cases(self): + """Test points exactly on the boundary.""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Point at width boundary (y = width/2, z = 0) + result = is_inside(width / 2.0, 0.0, width, height, depth, 2.0, 2.0) + assert result == True + + # Point at height boundary (y = 0, z = height) + result = is_inside(0.0, height, width, height, depth, 2.0, 2.0) + assert result == True + + def test_is_inside_outside_points(self): + """Test points clearly outside the shape.""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Point far outside + result = is_inside(width * 2, height * 2, width, height, depth, 2.0, 2.0) + assert result == False + + def test_is_inside_zero_dimensions(self): + """Test with zero or very small dimensions.""" + # TODO: Test edge case with zero width/height/depth + pass + + def test_is_inside_symmetry(self): + """Test symmetry about y-axis.""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Test symmetry for positive and negative y + result_pos = is_inside(0.00005, 0.00002, width, height, depth, 2.0, 2.0) + result_neg = is_inside(-0.00005, 0.00002, width, height, depth, 2.0, 2.0) + + assert result_pos == result_neg + + +# ============================================================================= +# Tests for compute_melt_mask function +# ============================================================================= + + +class TestComputeMeltMask: + """Test cases for the compute_melt_mask wrapper function.""" + + def test_compute_melt_mask_basic( + self, minimal_voxels, sample_melt_pool, sample_path_vectors + ): + """Test basic melt mask computation.""" + # Set melt pool properties for path vectors + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask( + minimal_voxels, sample_melt_pool, sample_path_vectors + ) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.bool_ + assert result.shape[0] == minimal_voxels.shape[0] + + def test_compute_melt_mask_unpacking( + self, minimal_voxels, sample_melt_pool, sample_path_vectors + ): + """Test that MeltPool and PathVector objects are correctly unpacked.""" + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask( + minimal_voxels, sample_melt_pool, sample_path_vectors + ) + + # TODO: Verify correct unpacking of properties + assert isinstance(result, np.ndarray) + + def test_compute_melt_mask_single_voxel( + self, sample_melt_pool, sample_path_vectors + ): + """Test with a single voxel.""" + single_voxel = np.array([[0.0005, 0.0, 0.0]], dtype=np.float64) + + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask(single_voxel, sample_melt_pool, sample_path_vectors) + + assert result.shape[0] == 1 + assert isinstance(result[0], (bool, np.bool_)) + + def test_compute_melt_mask_single_path_vector( + self, minimal_voxels, sample_melt_pool, sample_path_vector + ): + """Test with a single path vector.""" + sample_path_vector.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask( + minimal_voxels, sample_melt_pool, [sample_path_vector] + ) + + assert result.shape[0] == minimal_voxels.shape[0] + + def test_compute_melt_mask_output_shape( + self, sample_voxels, sample_melt_pool, sample_path_vectors + ): + """Test that output shape matches input voxel count.""" + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask(sample_voxels, sample_melt_pool, sample_path_vectors) + + assert result.shape[0] == sample_voxels.shape[0] + + def test_compute_melt_mask_dtype( + self, minimal_voxels, sample_melt_pool, sample_path_vectors + ): + """Test that output has correct boolean dtype.""" + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask( + minimal_voxels, sample_melt_pool, sample_path_vectors + ) + + assert result.dtype == np.bool_ + + +# ============================================================================= +# Tests for compute_melt_mask_implicit function +# ============================================================================= + + +class TestComputeMeltMaskImplicit: + """Test cases for the compute_melt_mask_implicit parallelized function.""" + + def test_compute_melt_mask_implicit_basic( + self, minimal_voxels, sample_melt_pool, sample_path_vectors + ): + """Test basic implicit melt mask computation.""" + # Set up melt pool properties + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + # Prepare inputs + n_voxels = minimal_voxels.shape[0] + melt_mask = np.zeros(n_voxels, dtype=np.bool_) + + start_points = np.array([v.start_point for v in sample_path_vectors]) + end_points = np.array([v.end_point for v in sample_path_vectors]) + e0 = np.array([v.e0 for v in sample_path_vectors]) + e1 = np.array([v.e1 for v in sample_path_vectors]) + e2 = np.array([v.e2 for v in sample_path_vectors]) + L0 = np.array([v.L0 for v in sample_path_vectors]) + L1 = np.array([v.L1 for v in sample_path_vectors]) + L2 = np.array([v.L2 for v in sample_path_vectors]) + start_times = np.array([v.start_time for v in sample_path_vectors]) + end_times = np.array([v.end_time for v in sample_path_vectors]) + AABB = np.array([v.AABB for v in sample_path_vectors]) + phases = np.array([v.phases for v in sample_path_vectors]) + centroids = np.array([v.centroid for v in sample_path_vectors]) + distances = np.array([v.distance for v in sample_path_vectors]) + + width_amp = sample_melt_pool.width_oscillations[:, 0] + width_freq = sample_melt_pool.width_oscillations[:, 1] + depth_amp = sample_melt_pool.depth_oscillations[:, 0] + depth_freq = sample_melt_pool.depth_oscillations[:, 1] + height_amp = sample_melt_pool.height_oscillations[:, 0] + height_freq = sample_melt_pool.height_oscillations[:, 1] + + result = compute_melt_mask_implicit( + minimal_voxels, + melt_mask, + start_points, + end_points, + e0, + e1, + e2, + L0, + L1, + L2, + start_times, + end_times, + AABB, + phases, + centroids, + distances, + width_amp, + width_freq, + depth_amp, + depth_freq, + height_amp, + height_freq, + sample_melt_pool.height_shape_factor, + sample_melt_pool.depth_shape_factor, + ) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.bool_ + assert result.shape[0] == n_voxels + + def test_compute_melt_mask_implicit_aabb_culling(self): + """Test that AABB (axis-aligned bounding box) culling works correctly.""" + # TODO: Create test where voxels are outside AABB + pass + + def test_compute_melt_mask_implicit_obb_culling(self): + """Test that OBB (oriented bounding box) culling works correctly.""" + # TODO: Create test where voxels pass AABB but fail OBB test + pass + + def test_compute_melt_mask_implicit_time_fraction(self): + """Test time fraction calculation along path vector.""" + # TODO: Test time interpolation along scan path + pass + + def test_compute_melt_mask_implicit_coordinate_transform(self): + """Test transformation to local coordinate system.""" + # TODO: Verify local coordinate calculation + pass + + def test_compute_melt_mask_implicit_oscillation_computation(self): + """Test that oscillations are correctly computed.""" + # TODO: Verify width, depth, height oscillation calculations + pass + + def test_compute_melt_mask_implicit_zero_distance_vector(self): + """Test handling of path vectors with zero distance.""" + # TODO: Test edge case with zero-length vectors + pass + + def test_compute_melt_mask_implicit_boundary_voxels(self): + """Test voxels exactly on path boundaries.""" + # TODO: Test boundary conditions + pass + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestCoreIntegration: + """Integration tests combining multiple core functions.""" + + def test_is_inside_with_compute_melt_mask( + self, minimal_voxels, sample_melt_pool, sample_path_vectors + ): + """Test integration of is_inside with compute_melt_mask.""" + for vec in sample_path_vectors: + vec.set_melt_pool_properties(sample_melt_pool) + + result = compute_melt_mask( + minimal_voxels, sample_melt_pool, sample_path_vectors + ) + + # Verify result is consistent + assert isinstance(result, np.ndarray) + + +# ============================================================================= +# Geometric Tests +# ============================================================================= + + +class TestGeometricAccuracy: + """Tests for geometric accuracy of computations.""" + + def test_lamé_curve_accuracy(self): + """Test accuracy of Lamé curve implementation.""" + # TODO: Compare with analytical solutions + pass + + def test_coordinate_transformation_accuracy(self): + """Test accuracy of coordinate transformations.""" + # TODO: Verify orthogonality and normalization + pass + + def test_distance_calculation_accuracy(self): + """Test accuracy of distance calculations.""" + # TODO: Compare with known distances + pass + + def test_time_interpolation_accuracy(self): + """Test accuracy of time interpolation along paths.""" + # TODO: Verify linear interpolation + pass + + def test_bounding_box_accuracy(self): + """Test that bounding boxes correctly contain geometry.""" + # TODO: Verify AABB and OBB correctness + pass + + +# ============================================================================= +# Edge Cases and Error Handling +# ============================================================================= + + +class TestCoreEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_is_inside_very_small_dimensions(self): + """Test is_inside with very small dimensions.""" + result = is_inside(0.0, 0.0, 1e-10, 1e-10, 1e-10, 2.0, 2.0) + # TODO: Verify behavior with tiny dimensions + pass + + def test_compute_melt_mask_single_mode(self): + """Test with single mode (no oscillations).""" + # TODO: Test with n_modes = 1 + pass + + +# ============================================================================= +# Shape Factor Tests +# ============================================================================= + + +class TestShapeFactors: + """Detailed tests for different shape factors.""" + + def test_ellipse_shape_k2(self): + """Test elliptical cross-section (k=2).""" + width = 0.0002 + height = 0.0001 + depth = 0.00008 + + # Test points on ellipse boundary + # For ellipse: (y/a)^2 + (z/b)^k = 1 + # k=2 + a = width / 2.0 + b = height + + # Point on boundary + y = a * np.cos(np.pi / 4) + z = b * np.sin(np.pi / 4) + + result = is_inside(y, z, width, height, depth, 2.0, 2.0) + assert result == True + + def test_parabola_shape_k1(self): + """Test parabolic cross-section (k=1).""" + # TODO: Test with k=1 for parabolic shape + pass + + def test_bell_shape_k05(self): + """Test bell-like cross-section (k=0.5).""" + # TODO: Test with k=0.5 for bell-like shape + pass + + def test_mixed_shape_factors(self): + """Test with different shape factors for top and bottom.""" + # TODO: Test height_shape_factor != depth_shape_factor + pass + + +# ============================================================================= +# Oscillation Tests +# ============================================================================= + + +class TestOscillations: + """Tests for melt pool oscillation effects.""" + + def test_oscillation_amplitude_effect(self): + """Test effect of oscillation amplitudes on melt mask.""" + # TODO: Vary amplitudes and check melt mask changes + pass + + def test_oscillation_frequency_effect(self): + """Test effect of oscillation frequencies on melt mask.""" + # TODO: Vary frequencies and check melt mask changes + pass + + def test_oscillation_phase_effect(self): + """Test effect of phase shifts on melt mask.""" + # TODO: Vary phases and check melt mask changes + pass + + def test_multiple_modes_superposition(self): + """Test that multiple modes combine correctly.""" + # TODO: Verify superposition of multiple oscillation modes + pass + + def test_random_phases(self): + """Test melt pool with random phases enabled.""" + # TODO: Test random phase generation and effects + pass + + +# ============================================================================= +# Coordinate Frame Tests +# ============================================================================= + + +class TestCoordinateFrames: + """Tests for coordinate frame transformations.""" + + def test_local_to_global_transformation(self): + """Test transformation from local to global coordinates.""" + # TODO: Verify coordinate transformations + pass + + def test_orthogonal_basis_vectors(self): + """Test that e0, e1, e2 form an orthogonal basis.""" + # TODO: Verify orthogonality + pass + + def test_coordinate_frame_along_x_axis(self): + """Test coordinate frame for vector along X axis.""" + # TODO: Test aligned with X + pass + + def test_coordinate_frame_arbitrary_direction(self): + """Test coordinate frame for arbitrary vector direction.""" + # TODO: Test arbitrary orientation + pass