diff --git a/.gitignore b/.gitignore index 239b377..63342b5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ build # other typical case file names *.vti +*morphology.csv + # vscode settings cache .vscode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e83be34 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +Contributing to Raptor is easy: just open a pull request. Make main the destination branch on the Raptor repository and allow edits from maintainers. + +Your pull request must work with all current Raptor tutorial examples and be reviewed by at least one of the main developers. + +We use `pre-commit` to fix formatting into a consistent style. You can install `pre-commit` through `pip`. diff --git a/README.md b/README.md index b67ff36..84e2b41 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ --- -RAPTOR is a Python-based simulation tool for estimating porosity-related defects in Laser Powder Bed Fusion (LPBF) additive manufacturing processes. It uses a computationally efficient geometric approach to model the dynamic melt pool and identify regions of unmelted material, which correspond to lack-of-fusion pores. The core of RAPTOR is a geometric model of the melt pool cross-section whose dimensions (width, depth, and height) oscillate over time. By analyzing the volume swept by this dynamic melt pool along the laser scan paths, RAPTOR generates a 3D map of the final part's porosity. +Raptor is a Python-based simulation tool for estimating porosity-related defects in Laser Powder Bed Fusion (LPBF) additive manufacturing processes. It uses a computationally efficient geometric approach to model the dynamic melt pool and identify regions of unmelted material, which correspond to lack-of-fusion pores. The core of Raptor is a geometric model of the melt pool cross-section whose dimensions (width, depth, and height) oscillate over time. By analyzing the volume swept by this dynamic melt pool along the laser scan paths, Raptor generates a 3D map of the final part's porosity. ## License @@ -17,7 +17,7 @@ This project is licensed under the BSD 3-Clause [License](LICENSE). ## How It Works -**RAPTOR predicts porosity by following a multi-step process:** +**Raptor predicts porosity by following a multi-step process:** * **Domain Voxelization**: A 3D bounding box, or Representative Volume Element (RVE), is defined and discretized into a uniform grid of voxels. * **Scan Path Ingestion**: Scan path data is used to calculating the timing and trajectory for each laser vector. @@ -26,19 +26,25 @@ This project is licensed under the BSD 3-Clause [License](LICENSE). * **Porosity Prediction**: Any voxel that is not melted by the end of the simulation is flagged as porosity. * **Analysis and Output**: The final 3D porosity field is saved in the binary VTK ImageData (`.vti`) format. The morphological characteristics (e.g., volume, surface area, equivalent diameter) of contiguous pore structures can be quantified using the `scikit-image` library, and saved to a `.csv` file. +
+ example figure +
Stochastic undermelting defects occurring between tracks due to melt pool fluctuations.
+
+ ## Installation -RAPTOR requires requires Python 3 (tested with Python 3.8+). The following Python packages are necessary: +Raptor requires requires Python 3 (tested with Python 3.8+). The following Python packages are necessary: ```bash - numpy, numba, pyyaml, vtk, scikit-image + numpy, numba, pyyaml, vtk, scikit-image, pandas, pyvista ``` * **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 * **scikit-image**: For calculating pore morphologies. +* **pandas**: For writing morphology information to .csv +* **pyvista**: For visualization of `.vti` results. You can install all dependencies and Raptor itself by running ```pip install .``` in the cloned Raptor directory. @@ -55,29 +61,29 @@ The project is organized into several modules: * `io.py`: Contains functions for reading and parsing input files (scan paths, melt pool data). * `utilities.py`: Includes helper classes, such as the `ScanPathBuilder` for generating scan strategies. -RAPTOR can be used in two primary ways: through its Command-Line Interface (CLI) for quick, configuration-driven simulations, or as a Python Library (API) for integration into custom scripts and more complex workflows. +Raptor can be used in two primary ways: through its Command-Line Interface (CLI) for quick, configuration-driven simulations, or as a Python Library (API) for integration into custom scripts and simulation workflows. ### 1. Command-Line Interface (CLI) -The CLI is the simplest way to run a simulation. It is controlled by a single YAML configuration file that defines all inputs, parameters, and outputs. +The CLI usage requires scan path files corresponding to build information. These scan path files can be generated with the `ScanPathBuilder` class in `raptor.utilities`. The CLI is controlled by a single YAML input file that defines all inputs, parameters, and outputs. The CLI example contains a single scan path to show functionality and observe the fluctuations of the simulated melt pool. The API example is recommended for a more descriptive simulation of undermelting-induced defects. **How to Run (CLI):** -1. **Prepare Inputs**: Create scan path files, melt pool data files, and a `config.yaml` file (detailed below). +1. **Prepare Inputs**: Create scan path files, melt pool data files, and a `input.yaml` file (detailed below). 2. **Execute Script**: Run the following command from your terminal, providing the path to your configuration file: ```bash - raptor path/to/your/config.yaml + raptor path/to/your/input.yaml ``` 3. **Check Outputs**: * Progress will be printed to the console. * The 3D porosity map is saved to the `.vti` file specified in the config. - * The pore morphology data is saved to the `.csv` file (if configured). + * The pore morphology data is saved to the `.csv` file (if configured -- the example does not save the morphology information.). -#### CLI Input: The `config.yaml` File +#### CLI Input: The `input.yaml` File -Running RAPTOR from the CLI requires a YAML configuration file to specify all parameters. +Running Raptor from the CLI requires a YAML input file to specify all parameters. -**Example `config.yaml`:** +**Example `input.yaml`:** ```yaml # List of scan path files (relative to this config file's location) scan_paths: @@ -126,7 +132,6 @@ output: - "label" - "area" - "equivalent_diameter_area" - - "extent" ``` #### Configuration Details: @@ -147,13 +152,15 @@ output: 0 0.001 0.0001 0.000 200 0.8 ``` + * The RVE min and max points *filter the scan paths for those that are near* the box defined by `min_point` and `max_point`; a large number of scan path files (such as from a part-scale build) can be downselected using this parameter setting. + * **Melt Pool Data Files**: These files provide the data for the `melt_pool_data` section of the config. * If `type: "time_series"`, the file should be a two-column text or CSV file: `[time, value]`. * If `type: "spectral_components"`, the file should be a three-column text or CSV file: `[amplitude, frequency, phase]`. ### 2. Python Library (API) -For advanced use cases, RAPTOR's core functions can be imported directly into your Python scripts. This allows for programmatic parameter studies, custom workflows, and integration with other tools. An example is provided in examples/api_example/rve.py. +The API allows for programmatic parameter studies, custom workflows, and integration with other tools. The core functionality of Raptor can be called by scripting with the API library. An example is provided in `examples/api_example/rve.py`, which is an RVE simulation of defects in 500µm edge length cube. The following is a breakdown of the main steps for running a simulation programmatically. @@ -181,8 +188,8 @@ from raptor.utilities import ScanPathBuilder # 2. Create path vectors through the representative volume element (RVE) power = 370 velocity = 1.7 -hatch_spacing = 130e-6 -layer_height = 50e-6 +hatch_spacing = 140e-6 +layer_height = 30e-6 rotation = 67 scan_extension = max(max_point - min_point) extra_layers = 0 @@ -252,7 +259,7 @@ porosity = compute_porosity( ``` #### Step 5: Write Results to a VTK File -Finally, use the write_vtk helper function to save the resulting porosity NumPy array to a .vti file for visualization in tools like ParaView. +Use the write_vtk helper function to save the resulting porosity NumPy array to a `.vti` file for visualization in tools like ParaView. Note that this `.vti` will contain 0 for the voxels that are melted, and 1 for unmelted voxels. Paraview's contour feature can be used to isolate the defects within the RVE. ```python from raptor.api import write_vtk @@ -260,6 +267,28 @@ from raptor.api import write_vtk # 5. Write porosity field to .VTI write_vtk(grid.origin, grid.resolution, porosity, "rve.vti") ``` + +#### Step 6: Compute and Write Morphology Descriptors +Optionally use the `compute_morphology` and `write_morphology` functions to compute global descriptors such as volume, equivalent diameter, etc. For a full list of possible descriptors, see https://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.regionprops. + +```python +from raptor.api import compute_morphology, write_morphology + +# 6. Compute morphology +morphology = compute_morphology(porosity, voxel_resolution, ['area', 'equivalent_diameter_area']) +write_morphology(morphology, "rve_morphology.csv") +``` +#### Step 7: Visualize the Output +Optionally use the `visualize` function to open an interactive window via `pyvista`. To perform more advanced visualizations, the output `.vti` file needs to be contoured to isolate the unmelted voxels (value 1) from the melted voxels (value 0). This contouring is automatically performed in `visualize`. The default scaling converts meters to microns for cleaner labeling in the interactive plot, but the scaling argument can be user-assigned. + +```python +from raptor.api import visualize + +#7. Visualize using PyVista +visualize("./rve.vti") +``` + + ## References The melt pool measurements in the examples are scans performed in Ti6Al4V from the following study: * Miner, Justin; Narra, Sneha Prabha (2024). Dataset of Melt Pool Variability Measurements for Powder Bed Fusion - Laser Beam of Ti-6Al-4V. Carnegie Mellon University. Dataset. https://doi.org/10.1184/R1/25696293.v1 diff --git a/examples/api_example/rve.py b/examples/api_example/rve.py index 1cc6da8..8f5cb2a 100644 --- a/examples/api_example/rve.py +++ b/examples/api_example/rve.py @@ -17,6 +17,9 @@ create_grid, compute_porosity, write_vtk, + compute_morphology, + write_morphology, + visualize, ) from raptor.utilities import ScanPathBuilder @@ -31,8 +34,8 @@ # 2. Create path vectors through the representative volume element (RVE) power = 370 velocity = 1.7 -hatch_spacing = 130e-6 -layer_height = 50e-6 +hatch_spacing = 140e-6 +layer_height = 30e-6 rotation = 67 scan_extension = max(max_point - min_point) extra_layers = 0 @@ -82,3 +85,12 @@ # 5. Write porosity field to .VTI write_vtk(grid.origin, grid.resolution, porosity, "rve.vti") + +# 6. Compute morphology +morphology = compute_morphology( + porosity, voxel_resolution, ["area", "equivalent_diameter_area"] +) +write_morphology(morphology, "rve_morphology.csv") + +# 7. Visualize using PyVista +visualize("./rve.vti") diff --git a/examples/cli_example/input.yaml b/examples/cli_example/input.yaml index 7fa09fb..4533f73 100644 --- a/examples/cli_example/input.yaml +++ b/examples/cli_example/input.yaml @@ -1,5 +1,5 @@ scan_paths: - - "../data/scanPathData/scanPath_ULI" + - "../data/scanPathData/ULI_single_scanpath" parameters: layer_height: 50.0e-6 # (meters) @@ -34,16 +34,7 @@ rve: output: vtk: - file_name: "ULI_porosity.vti" + file_name: "ULI_singletrack.vti" morphology: - file_name: "ULI_morphology.csv" + file_name: "ULI_singletrack_morphology.txt" fields: - # - "label" - # - "bbox" - # - "area" - # - "area_bbox" - # - "area_filled" - # - "centroid" - # - "equivalent_diameter_area" - # - "euler_number" - # - "extent" diff --git a/examples/data/scanPathData/scanPath_ULI b/examples/data/scanPathData/ULI_single_scanpath similarity index 100% rename from examples/data/scanPathData/scanPath_ULI rename to examples/data/scanPathData/ULI_single_scanpath diff --git a/pyproject.toml b/pyproject.toml index 20caf4e..c8cff2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,9 @@ dependencies = [ "PyYAML", "vtk", "scikit-image", - "pytest" + "pytest", + "pandas", + "pyvista" ] [project.scripts] diff --git a/src/raptor/api.py b/src/raptor/api.py index 6436607..35bab8e 100644 --- a/src/raptor/api.py +++ b/src/raptor/api.py @@ -11,8 +11,10 @@ import time from typing import List, Tuple, Optional, Dict, Any import numpy as np +import pandas as pd import vtk from vtk.util import numpy_support +import pyvista as pv from skimage import measure from skimage.morphology import remove_small_objects @@ -251,7 +253,7 @@ def compute_morphology( filtered_defects = remove_small_objects(labeled_defects, minsize) return measure.regionprops_table( - filtered_defects, spacing=voxel_resolution, properties=morphology_fields + labeled_defects, spacing=voxel_resolution, properties=morphology_fields ) @@ -259,15 +261,45 @@ def write_morphology(properties: dict, morphology_output_path: str) -> None: """ Writes morphology output as a .csv. """ - columns = ",".join([key for key in properties.keys()]) - morphology = np.vstack([properties[key] for key in properties.keys()]).transpose() - - np.savetxt( - morphology_output_path, morphology, header=columns, delimiter=",", comments="" - ) + morphology_df = pd.DataFrame(properties, index=None) + morphology_df.to_csv(morphology_output_path, index=False) print( - f"Morphology features of {morphology.shape[0]} " + f"Morphology features of {len(morphology_df)} " f"defects written to: {morphology_output_path}" ) + + +def visualize(vtk_output_path: str, scaling=1e6) -> None: + """ + Visualizes porosity field using PyVista. Defaults to scaling from meters to microns for better labeling. + """ + rve = pv.read(vtk_output_path) + rve.scale( + [scaling, scaling, scaling], inplace=True + ) # Scale for better visualization + isosurface = rve.contour(isosurfaces=5) + + # Outline of the original domain + outline = rve.outline() + + # Set up the plotter + pl = pv.Plotter() + pl.add_mesh(isosurface, color="red", opacity=0.8) + pl.add_mesh(outline, color="black", line_width=1) + label_args = { + "font_size": 12, + "color": "black", + "font_family": "arial", + "fmt": "%.0f", + } + pl.show_grid( + xtitle="X (um)", + ytitle="Y (um)", + ztitle="Z (um)", + grid=False, + location="outer", + **label_args, + ) + pl.show() diff --git a/src/raptor/cli.py b/src/raptor/cli.py index dbbf1b2..43642b8 100644 --- a/src/raptor/cli.py +++ b/src/raptor/cli.py @@ -201,11 +201,10 @@ def main() -> int: # write morphology metrics (optional) if morphology_fields: - write_morphology - ( - compute_morphology(porosity, voxel_resolution, morphology_fields), - morphology_file, + defect_morphologies = compute_morphology( + porosity, voxel_resolution, morphology_fields ) + write_morphology(defect_morphologies, morphology_file) except FileNotFoundError as e: print(f"Error: {e}") diff --git a/src/raptor/utilities.py b/src/raptor/utilities.py index 29d9260..a2e2cc7 100644 --- a/src/raptor/utilities.py +++ b/src/raptor/utilities.py @@ -154,11 +154,16 @@ def process_vectors(self): all_vectors.append(vec) return all_vectors - def write_layers(self, ouput_name): + def write_layers(self, output_name, mode="layers"): """ Writes the generated raw scan paths to text files. - """ + Args: + output_name: Base name for the output files. + mode: "layers" to write separate files for each layer, "all" to write a single file with all layers. + """ + if mode == "all": + all_layers = [] for l_key, (l_start, l_end) in self.layers.items(): if l_start.size == 0: continue @@ -181,13 +186,30 @@ def write_layers(self, ouput_name): for s, e in zip(l_start, l_end) ] - allpaths = np.vstack(se_pairs) + all_paths = np.vstack(se_pairs) + if mode == "all": + all_layers.append(all_paths) + continue header_str = "Mode X(m) Y(m) Z(m) Power(W) tParam" filename = f"{output_name}_layer_{l_key}.txt" np.savetxt( filename, - allpaths, + all_paths, + fmt="%.6f", + delimiter=" ", + header=header_str, + comments="", + ) + print(f"Wrote file {filename}") + + if mode == "all" and all_layers: + all_layers = np.vstack(all_layers) + header_str = "Mode X(m) Y(m) Z(m) Power(W) tParam" + filename = f"{output_name}.txt" + np.savetxt( + filename, + all_layers, fmt="%.6f", delimiter=" ", header=header_str, diff --git a/tests/test_api.py b/tests/test_api.py index 7885c3a..dd9a0dd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -116,6 +116,12 @@ def sample_melt_pool_dict(sample_time_series_data): } +@pytest.fixture +def sample_morphology_fields(): + """Fixture providing sample morphology fields.""" + return ["area", "centroid"] + + @pytest.fixture def temp_output_dir(): """Fixture providing a temporary directory for output files.""" @@ -532,17 +538,46 @@ def test_write_morphology_column_headers(self, temp_output_dir): class TestApiIntegration: """Integration tests combining multiple API functions.""" - def test_full_workflow_basic(self, temp_output_dir): + def test_full_workflow_basic( + self, + sample_voxel_resolution, + sample_bound_box, + sample_process_parameters, + sample_melt_pool_dict, + temp_output_dir, + ): """Test complete workflow from grid creation to VTK output.""" - # TODO: Create end-to-end test - pass + grid = create_grid(sample_voxel_resolution, bound_box=sample_bound_box) + path_vectors = create_path_vectors( + sample_bound_box, **sample_process_parameters + ) + melt_pool = create_melt_pool(sample_melt_pool_dict, enable_random_phases=False) + porosity = compute_porosity(grid, path_vectors, melt_pool, jit_warmup=True) + output_path = temp_output_dir / "full_workflow.vti" + write_vtk(grid.origin, grid.resolution, porosity, str(output_path)) + assert output_path.exists() + assert output_path.stat().st_size > 0 - def test_full_workflow_with_morphology(self, temp_output_dir): + def test_full_workflow_with_morphology( + self, + sample_voxel_resolution, + sample_bound_box, + sample_process_parameters, + sample_melt_pool_dict, + sample_morphology_fields, + 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 + grid = create_grid(sample_voxel_resolution, bound_box=sample_bound_box) + path_vectors = create_path_vectors( + sample_bound_box, **sample_process_parameters + ) + melt_pool = create_melt_pool(sample_melt_pool_dict, enable_random_phases=False) + porosity = compute_porosity(grid, path_vectors, melt_pool, jit_warmup=True) + morphology = compute_morphology( + porosity, sample_voxel_resolution, sample_morphology_fields + ) + morphology_output_path = temp_output_dir / "morphology.csv" + write_morphology(morphology, str(morphology_output_path)) + assert morphology_output_path.exists() + assert morphology_output_path.stat().st_size > 0