diff --git a/docs/geos-mesh.rst b/docs/geos-mesh.rst index 061f596db..2221cc943 100644 --- a/docs/geos-mesh.rst +++ b/docs/geos-mesh.rst @@ -9,6 +9,8 @@ GEOS Mesh tools ./geos_mesh_docs/doctor + ./geos_mesh_docs/doctor_filters + ./geos_mesh_docs/converter ./geos_mesh_docs/io diff --git a/docs/geos_mesh_docs/doctor.rst b/docs/geos_mesh_docs/doctor.rst index 0e66d84fd..48fcc3363 100644 --- a/docs/geos_mesh_docs/doctor.rst +++ b/docs/geos_mesh_docs/doctor.rst @@ -1,9 +1,9 @@ Mesh Doctor ---------------- +----------- -``mesh-doctor`` is a ``python`` executable that can be used through the command line to perform various checks, validations, and tiny fixes to the ``vtk`` mesh that are meant to be used in ``geos``. -``mesh-doctor`` is organized as a collection of modules with their dedicated sets of options. -The current page will introduce those modules, but the details and all the arguments can be retrieved by using the ``--help`` option for each module. +| ``mesh-doctor`` is a ``python`` executable that can be used through the command line to perform various checks, validations, and tiny fixes to the ``vtkUnstructuredGrid`` mesh that are meant to be used in ``geos``. + ``mesh-doctor`` is organized as a collection of modules with their dedicated sets of options. +| The current page will introduce those modules, but the details and all the arguments can be retrieved by using the ``--help`` option for each module. Prerequisites ^^^^^^^^^^^^^ @@ -310,8 +310,55 @@ It will also verify that the ``VTK_POLYHEDRON`` cells can effectively get conver .. code-block:: $ mesh-doctor supported_elements --help - usage: mesh_doctor.py supported_elements [-h] [--chunck_size 1] [--nproc 8] + usage: mesh_doctor.py supported_elements [-h] [--chunk_size 1] [--nproc 8] options: -h, --help show this help message and exit - --chunck_size 1 [int]: Defaults chunk size for parallel processing to 1 - --nproc 8 [int]: Number of threads used for parallel processing. Defaults to your CPU count 8. \ No newline at end of file + --chunk_size 1 [int]: Defaults chunk size for parallel processing to 1 + --nproc 8 [int]: Number of threads used for parallel processing. Defaults to your CPU count 8. + + +Why only use vtkUnstructuredGrid? +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +| The mesh doctor is designed specifically for unstructured meshes used in GEOS. +| All input files are expected to be ``.vtu`` (VTK Unstructured Grid) format. +| What about other formats? + +VTK Hierarchy +""""""""""""" + +Supposedly, other grid types that are part of the following VTK hierarchy could be used:: + + vtkDataObject + └── vtkDataSet + └── vtkCartesianGrid + └── vtkRectilinearGrid + └── vtkImageData + └── vtkStructuredPoints + └── vtkUniformGrid + └── vtkPointSet + └── vtkExplicitStructuredGrid + └── vtkPolyData + └── vtkStructuredGrid + └── vtkUnstructuredGrid + +And when looking at specific methods used in mesh-doctor, it could suggest that other formats could be used: + +* Points access: ``mesh.GetPoints()`` - Available in all vtkPointSet subclasses ✓ +* Cell iteration: ``mesh.GetNumberOfCells()``, ``mesh.GetCell()`` - Available in all vtkDataSet subclasses ✓ +* Cell types: ``mesh.GetCellType()`` - Available in all vtkDataSet subclasses ✓ +* Cell/Point data: ``mesh.GetCellData()``, ``mesh.GetPointData()`` - Available in all vtkDataSet subclasses ✓ + +VTK Filter Compatibility +"""""""""""""""""""""""" + +| ``vtkCellSizeFilter``, ``vtkMeshQuality``, and other VTK filters used in the actions expect ``vtkDataSet`` or its subclasses + ``vtkUnstructuredGrid`` is compatible with all VTK filters used. +| ``vtkPolyData`` has a different data structure, not suitable for 3D volumetric meshes. + +Specific Operations Require vtkUnstructuredGrid +""""""""""""""""""""""""""""""""""""""""""""""" + +* ``GetCellNeighbors()`` - Only available in vtkUnstructuredGrid +* ``GetFaceStream()`` - Only available in vtkUnstructuredGrid (for polyhedron support) +* ``GetDistinctCellTypesArray()`` - Only available in vtkUnstructuredGrid \ No newline at end of file diff --git a/docs/geos_mesh_docs/doctor_filters.rst b/docs/geos_mesh_docs/doctor_filters.rst new file mode 100644 index 000000000..119ee508e --- /dev/null +++ b/docs/geos_mesh_docs/doctor_filters.rst @@ -0,0 +1,19 @@ +VTK Filters +=========== + +In addition to the command-line interface, mesh-doctor functionality is also available as VTK filters for programmatic use in Python. These filters provide the same mesh validation and processing capabilities but can be integrated directly into Python workflows and visualization pipelines. + +The VTK filters offer several advantages: + +* **Integration**: Easy integration with existing VTK/ParaView workflows +* **Scripting**: Programmatic access for batch processing and automation +* **Visualization**: Direct output suitable for ParaView visualization +* **Flexibility**: Configurable parameters and output options +* **Chaining**: Ability to combine multiple filters in processing pipelines + +For detailed documentation on available filters, their parameters, and usage examples, see: + +.. toctree:: + :maxdepth: 1 + + filters/index \ No newline at end of file diff --git a/docs/geos_mesh_docs/filters/Checks.rst b/docs/geos_mesh_docs/filters/Checks.rst new file mode 100644 index 000000000..7828c4b17 --- /dev/null +++ b/docs/geos_mesh_docs/filters/Checks.rst @@ -0,0 +1,18 @@ +AllChecks and MainChecks Filters +================================ + +.. automodule:: geos.mesh.doctor.filters.Checks + :members: + :undoc-members: + :show-inheritance: + +See Also +-------- + +* :doc:`CollocatedNodes ` - Individual collocated nodes check +* :doc:`ElementVolumes ` - Individual element volume check +* :doc:`NonConformal ` - Individual non-conformal interface check +* :doc:`SelfIntersectingElements ` - Individual self-intersection check +* :doc:`SupportedElements ` - Individual supported elements check + + diff --git a/docs/geos_mesh_docs/filters/CollocatedNodes.rst b/docs/geos_mesh_docs/filters/CollocatedNodes.rst new file mode 100644 index 000000000..80adcbf58 --- /dev/null +++ b/docs/geos_mesh_docs/filters/CollocatedNodes.rst @@ -0,0 +1,50 @@ +CollocatedNodes Filter +====================== + +.. automodule:: geos.mesh.doctor.filters.CollocatedNodes + :members: + :undoc-members: + :show-inheritance: + +Understanding the Results +------------------------- + +**Collocated Node Buckets** + +Each bucket is a tuple containing node indices that are within the specified tolerance of each other: + +.. code-block:: python + + # Example result: [(0, 15, 23), (7, 42), (100, 101, 102, 103)] + # This means: + # - Nodes 0, 15, and 23 are collocated + # - Nodes 7 and 42 are collocated + # - Nodes 100, 101, 102, and 103 are collocated + +**Wrong Support Elements** + +Cell element IDs where the same node appears multiple times in the element's connectivity. This usually indicates: + +* Degenerate elements +* Mesh generation errors +* Topology problems + +Common Use Cases +---------------- + +* **Mesh Quality Assessment**: Identify potential mesh issues before simulation +* **Mesh Preprocessing**: Clean up meshes by detecting node duplicates +* **Debugging**: Understand why meshes might have connectivity problems +* **Validation**: Ensure mesh meets quality standards for specific applications + +I/O +--- + +* **Input**: vtkUnstructuredGrid +* **Output**: vtkUnstructuredGrid with optional arrays marking problematic elements +* **Additional Data**: When painting is enabled, adds "WrongSupportElements" array to cell data + +See Also +-------- + +* :doc:`AllChecks and MainChecks ` - Includes collocated nodes check among others diff --git a/docs/geos_mesh_docs/filters/ElementVolumes.rst b/docs/geos_mesh_docs/filters/ElementVolumes.rst new file mode 100644 index 000000000..d7afd45f5 --- /dev/null +++ b/docs/geos_mesh_docs/filters/ElementVolumes.rst @@ -0,0 +1,63 @@ +ElementVolumes Filter +===================== + +.. automodule:: geos.mesh.doctor.filters.ElementVolumes + :members: + :undoc-members: + :show-inheritance: + +Understanding Volume Issues +--------------------------- + +**Negative Volumes** + +Indicate elements with inverted geometry: + +* **Tetrahedra**: Nodes ordered incorrectly (clockwise instead of counter-clockwise) +* **Hexahedra**: Faces oriented inward instead of outward +* **Other elements**: Similar orientation/ordering issues + +**Zero Volumes** + +Indicate degenerate elements: + +* **Collapsed elements**: All nodes lie in the same plane (3D) or line (2D) +* **Duplicate nodes**: Multiple nodes at the same location within the element +* **Extreme aspect ratios**: Elements stretched to near-zero thickness + +**Impact on Simulations** + +Elements with non-positive volumes can cause: + +* Numerical instabilities +* Convergence problems +* Incorrect physical results +* Solver failures + +Common Fixes +------------ + +**For Negative Volumes:** + +1. **Reorder nodes**: Correct the connectivity order +2. **Flip elements**: Use mesh repair tools +3. **Regenerate mesh**: If issues are widespread + +**For Zero Volumes:** + +1. **Remove degenerate elements**: Delete problematic cells +2. **Merge collocated nodes**: Fix duplicate node issues +3. **Improve mesh quality**: Regenerate with better settings + +I/O +--- + +* **Input**: vtkUnstructuredGrid +* **Output**: vtkUnstructuredGrid with volume information added as cell data +* **Volume Array**: "Volume" array added to cell data containing computed volumes +* **Additional Data**: When returnNegativeZeroVolumes is enabled, provides detailed problematic element information + +See Also +-------- + +* :doc:`AllChecks and MainChecks ` - Includes element volumes check among others diff --git a/docs/geos_mesh_docs/filters/GenerateFractures.rst b/docs/geos_mesh_docs/filters/GenerateFractures.rst new file mode 100644 index 000000000..03ead101f --- /dev/null +++ b/docs/geos_mesh_docs/filters/GenerateFractures.rst @@ -0,0 +1,78 @@ +GenerateFractures Filter +======================== + +.. automodule:: geos.mesh.doctor.filters.GenerateFractures + :members: + :undoc-members: + :show-inheritance: + +Fracture Policies +----------------- + +**Field Fractures Policy (field)** + +| Obtained by setting ``--policy field``. +| +| Creates fractures by using internal interfaces between cells regions. +| These interfaces corresponds to a change in the values of a CellData array stored in your mesh. +| Suppose you have a CellData array named "Regions" with 3 different values indicating geological layers. 1 is caprock, 2 is reservoir, and 3 is underburden as represented here (below is a 2D representation along Z for simplicity): +| +| 111111111111111111 +| 111111222222222222 +| 222222222223333333 +| 333333333333333333 +| +| In this example, I define the interface between reservoir and caprock as a fracture, and the interface between reservoir and underburden as another fracture. +| So when specifying your field values as ``--values 1,2:2,3``, the filter will create a fracture along the interface between these two regions. The result will be a new mesh with the fracture represented as a separate entity. The nodes will be split along these interfaces, allowing for discontinuities in the mesh: +| +| 111111111111111111 +| 111111------------ +| ------222222222222 +| 22222222222+++++++ +| +++++++++++3333333 +| 333333333333333333 +| +| with ``----`` representing the first fracture and ``+++++++`` representing the second fracture. + +**Internal Fractures Policy (internal_surfaces)** + +| Obtained by setting ``--policy internal_surfaces``. +| +| Creates fractures by using tagged 2D cells that are identified as a fracture. +| In VTK, ``2D cells`` are refering to ``VTK_TRIANGLE`` or ``VTK_QUAD`` cell types. +| These 2D cells should be part of the input mesh and tagged with a specific CellData array value. +| Suppose you have a CellData array named "isFault" with 3 different values to tag fractures. 0 is for all non fault cells, 1 is fault1, and 2 is fault2 as represented here (below is a 2D representation along Z for simplicity): +| +| 000000000000000000 +| 000000111111111111 +| 111111000000000000 +| 000000000002222222 +| 222222222220000000 +| 000000000000000000 +| +| So when specifying your field values as ``--values 1:2``, the filter will create one fracture for each cells tagged with 1 and 2. + +Understanding Fracture Generation +--------------------------------- + +**Process** + +1. **Identification**: Find interfaces between cells with different field values +2. **Node Duplication**: Create separate nodes for each side of the fracture +3. **Mesh Splitting**: Update connectivity to use duplicated nodes +4. **Fracture Extraction**: Generate separate meshes for fracture surfaces + +**Output Structure** + +* **Split Mesh**: Original mesh with fractures as discontinuities +* **Fracture Meshes**: Individual surface meshes for each fracture + +I/O +--- + +* **Input**: vtkUnstructuredGrid with fracture identification field or tagged 2D cells +* **Outputs**: + + * Split mesh with fractures as discontinuities + * Individual fracture surface meshes +* **File Output**: Automatic writing of fracture meshes to specified directory diff --git a/docs/geos_mesh_docs/filters/GenerateRectilinearGrid.rst b/docs/geos_mesh_docs/filters/GenerateRectilinearGrid.rst new file mode 100644 index 000000000..fba3cc930 --- /dev/null +++ b/docs/geos_mesh_docs/filters/GenerateRectilinearGrid.rst @@ -0,0 +1,14 @@ +GenerateRectilinearGrid Filter +============================== + +.. automodule:: geos.mesh.doctor.filters.GenerateRectilinearGrid + :members: + :undoc-members: + :show-inheritance: + +I/O +--- + +* **Input**: Not needed +* **Output**: vtkUnstructuredGrid +* **Additional Data**: New fields on points or cells if user requests them. \ No newline at end of file diff --git a/docs/geos_mesh_docs/filters/MeshDoctorFilterBase.rst b/docs/geos_mesh_docs/filters/MeshDoctorFilterBase.rst new file mode 100644 index 000000000..d9a24feeb --- /dev/null +++ b/docs/geos_mesh_docs/filters/MeshDoctorFilterBase.rst @@ -0,0 +1,14 @@ +MeshDoctorFilterBase +==================== + +.. automodule:: geos.mesh.doctor.filters.MeshDoctorFilterBase + :members: + :undoc-members: + :show-inheritance: + +See Also +-------- + +* :doc:`AllChecks and MainChecks ` - Comprehensive mesh quality assessment using MeshDoctorFilterBase +* :doc:`GenerateRectilinearGrid ` - Grid generation using MeshDoctorGeneratorBase +* :doc:`GenerateFractures ` - Fracture mesh generation using MeshDoctorFilterBase diff --git a/docs/geos_mesh_docs/filters/NonConformal.rst b/docs/geos_mesh_docs/filters/NonConformal.rst new file mode 100644 index 000000000..4e8df5ee0 --- /dev/null +++ b/docs/geos_mesh_docs/filters/NonConformal.rst @@ -0,0 +1,57 @@ +NonConformal Filter +=================== + +.. automodule:: geos.mesh.doctor.filters.NonConformal + :members: + :undoc-members: + :show-inheritance: + +Understanding Non-Conformal Interfaces +--------------------------------------- + +**Conformal vs Non-Conformal** + +**Conformal Interface**: Adjacent cells share exact nodes and faces + +.. code-block:: + + Cell A: nodes [1, 2, 3, 4] + Cell B: nodes [3, 4, 5, 6] # Shares nodes 3,4 with Cell A + → CONFORMAL + +**Non-Conformal Interface**: Adjacent cells do not share nodes/faces exactly + +.. code-block:: + + Cell A: nodes [1, 2, 3, 4] + Cell B: nodes [5, 6, 7, 8] # No shared nodes with Cell A but geometrically adjacent + → NON-CONFORMAL + +**Types of Non-Conformity** + +1. **Hanging Nodes**: T-junctions where one element edge intersects another element face +2. **Mismatched Boundaries**: Interfaces where element boundaries don't align +3. **Gap Interfaces**: Small gaps between elements that should be connected +4. **Overlapping Interfaces**: Elements that overlap slightly due to meshing errors + +Output +------ + +* **Input**: vtkUnstructuredGrid +* **Output**: vtkUnstructuredGrid with optional marking arrays +* **Marking Array**: When painting is enabled, adds "IsNonConformal" array to cell data +* **Cell Pairs**: List of non-conformal cell index pairs + +Best Practices +-------------- + +* **Set appropriate tolerances** based on mesh precision and simulation requirements +* **Use painting** to visualize non-conformal regions in ParaView +* **Consider physics requirements** when deciding if non-conformity is acceptable +* **Combine with other checks** for comprehensive mesh validation +* **Document intentional non-conformity** for future reference + +See Also +-------- + +* :doc:`AllChecks ` - Includes non-conformal check among others diff --git a/docs/geos_mesh_docs/filters/SelfIntersectingElements.rst b/docs/geos_mesh_docs/filters/SelfIntersectingElements.rst new file mode 100644 index 000000000..0dfd97d88 --- /dev/null +++ b/docs/geos_mesh_docs/filters/SelfIntersectingElements.rst @@ -0,0 +1,114 @@ +SelfIntersectingElements Filter +=============================== + +.. automodule:: geos.mesh.doctor.filters.SelfIntersectingElements + :members: + :undoc-members: + :show-inheritance: + +Understanding Element Problems +------------------------------ + +**Wrong Number of Points** + +Each VTK cell type has a specific number of points: + +* Triangle: 3 points +* Quadrilateral: 4 points +* Tetrahedron: 4 points +* Hexahedron: 8 points +* etc. + +**Self-Intersecting Edges** + +Edges that cross over themselves: + +.. code-block:: + + Valid triangle: Invalid triangle (bow-tie): + A A + / \ /|\ + / \ / | \ + B-----C B--+--C + \|/ + D + +**Self-Intersecting Faces** + +3D elements where faces intersect: + +.. code-block:: + + Valid tetrahedron Invalid tetrahedron (inverted) + D D + /|\ /|\ + / | \ / | \ + A--+--C C--+--A (face ABC flipped) + \ | / \ | / + \|/ \|/ + B B + +**Non-Contiguous Edges** + +Element boundaries that don't form continuous loops: + +* Missing edges between consecutive points +* Duplicate edges +* Gaps in the boundary + +**Non-Convex Elements** + +Elements that have internal angles > 180 degrees: + +* Can cause numerical issues in finite element calculations +* May indicate mesh generation problems +* Some solvers require strictly convex elements + +**Incorrectly Oriented Faces** + +Faces with normals pointing in wrong directions: + +* Outward normals pointing inward +* Inconsistent winding order +* Can affect normal-based calculations + +Common Causes and Solutions +--------------------------- + +**Wrong Number of Points** + +* **Cause**: Mesh file corruption, wrong cell type specification +* **Solution**: Fix cell type definitions or regenerate mesh + +**Self-Intersecting Edges/Faces** + +* **Cause**: Node coordinate errors, mesh deformation, bad mesh generation +* **Solution**: Fix node coordinates, improve mesh quality settings + +**Non-Contiguous Edges** + +* **Cause**: Missing connectivity information, duplicate nodes +* **Solution**: Fix element connectivity, merge duplicate nodes + +**Non-Convex Elements** + +* **Cause**: Poor mesh quality, extreme deformation +* **Solution**: Improve mesh generation parameters, element quality checks + +**Wrong Face Orientation** + +* **Cause**: Inconsistent node ordering, mesh processing errors +* **Solution**: Fix element node ordering, use mesh repair tools + +Output +------ + +* **Input**: vtkUnstructuredGrid +* **Output**: vtkUnstructuredGrid with optional marking arrays +* **Problem Lists**: Separate lists for each type of geometric problem +* **Marking Arrays**: When painting is enabled, adds arrays identifying problem types + +See Also +-------- + +* :doc:`AllChecks and MainChecks ` - Includes self-intersection check among others \ No newline at end of file diff --git a/docs/geos_mesh_docs/filters/SupportedElements.rst b/docs/geos_mesh_docs/filters/SupportedElements.rst new file mode 100644 index 000000000..3bb366239 --- /dev/null +++ b/docs/geos_mesh_docs/filters/SupportedElements.rst @@ -0,0 +1,45 @@ +SupportedElements Filter +======================== + +.. automodule:: geos.mesh.doctor.filters.SupportedElements + :members: + :undoc-members: + :show-inheritance: + +GEOS Supported Element Types +---------------------------- + +GEOS supports the following VTK element types: + +**0D & 1D Elements** + +* VTK_VERTEX (1): These are individual point elements. +* VTK_LINE (3): These are linear, 1D elements defined by two points. + +**2D Elements** + +* VTK_TRIANGLE (5): These are 2D, three-noded triangular elements. +* VTK_POLYGON (7): This is a generic cell for 2D elements with more than four vertices. +* VTK_QUAD (9): These are 2D, four-noded quadrilateral elements. + +**3D Elements** + +* VTK_TETRA (10): These are four-noded tetrahedral elements (a triangular pyramid). +* VTK_HEXAHEDRON (12): These are eight-noded hexahedral elements, often called bricks or cubes. +* VTK_WEDGE (13): These are six-noded wedge or triangular prism elements. +* VTK_PYRAMID (14): These are five-noded pyramids with a quadrilateral base. +* VTK_PENTAGONAL_PRISM (15): These are ten-noded prisms with a pentagonal base. +* VTK_HEXAGONAL_PRISM (16): These are twelve-noded prisms with a hexagonal base. +* VTK_POLYHEDRON (42): Corresponds to Polyhedron as well as HeptagonalPrism, OctagonalPrism, NonagonalPrism, DecagonalPrism, and HendecagonalPrism. This is a general-purpose 3D cell type for arbitrary polyhedra not covered by the other specific types. + +I/O +--- + +* **Input**: vtkUnstructuredGrid +* **Output**: vtkUnstructuredGrid with optional arrays marking problematic elements +* **Additional Data**: When painting is enabled, adds "WrongSupportElements" array to cell data + +See Also +-------- + +* :doc:`AllChecks ` - Includes supported-elements check among others \ No newline at end of file diff --git a/docs/geos_mesh_docs/filters/index.rst b/docs/geos_mesh_docs/filters/index.rst new file mode 100644 index 000000000..e6dbf88be --- /dev/null +++ b/docs/geos_mesh_docs/filters/index.rst @@ -0,0 +1,160 @@ +Mesh Doctor Filters +=================== + +The mesh doctor filters provide VTK-based tools for mesh quality assessment, validation, and processing. All filters work with vtkUnstructuredGrid data and follow a consistent interface pattern. + +Quality Assessment Filters +-------------------------- + +These filters analyze existing meshes for various quality issues and geometric problems. + +.. toctree:: + :maxdepth: 1 + + Checks + CollocatedNodes + ElementVolumes + SelfIntersectingElements + NonConformal + +Mesh Generation Filters +----------------------- + +These filters create new meshes from scratch or modify existing meshes. + +.. toctree:: + :maxdepth: 1 + + GenerateRectilinearGrid + GenerateFractures + +Processing Filters +------------------ + +These filters perform specialized processing and validation tasks. + +.. toctree:: + :maxdepth: 1 + + SupportedElements + +Filter Categories Explained +=========================== + +Quality Assessment +------------------ + +**Purpose**: Identify mesh quality issues, geometric problems, and topology errors + +**When to use**: +- Before running simulations +- After mesh generation or modification +- During mesh debugging +- For mesh quality reporting + +**Key filters**: +- **AllChecks/MainChecks**: Comprehensive validation suites +- **CollocatedNodes**: Duplicate node detection +- **ElementVolumes**: Volume validation +- **SelfIntersectingElements**: Geometric integrity +- **NonConformal**: Interface validation + +Mesh Generation +--------------- + +**Purpose**: Create new meshes or modify existing ones + +**When to use**: +- Creating test meshes +- Generating simple geometries +- Adding fractures to existing meshes +- Prototyping mesh-based algorithms + +**Key filters**: +- **GenerateRectilinearGrid**: Simple structured grids +- **GenerateFractures**: Fracture network generation + +Processing +---------- + +**Purpose**: Specialized mesh processing and validation + +**When to use**: +- Validating element type compatibility +- Preparing meshes for specific solvers +- Advanced geometric analysis + +**Key filters**: +- **SupportedElements**: GEOS compatibility validation + +Quick Reference +=============== + +Filter Selection Guide +---------------------- + +**For mesh validation**: + Use :doc:`MainChecks ` for fast, essential checks + +**For comprehensive analysis**: + Use :doc:`AllChecks ` for detailed validation + +**For specific problems**: + - Duplicate nodes → :doc:`CollocatedNodes ` + - Negative volumes → :doc:`ElementVolumes ` + - Invalid geometry → :doc:`SelfIntersectingElements ` + - Interface issues → :doc:`NonConformal ` + +**For mesh generation**: + - Simple grids → :doc:`GenerateRectilinearGrid ` + - Fracture networks → :doc:`GenerateFractures ` + +**For compatibility checking**: + - GEOS support → :doc:`SupportedElements ` + +Parameter Guidelines +-------------------- + +**Painting New Properties**: + - Enable (1) for visualization in ParaView + - Disable (0) for performance in batch processing + +**Output Modes**: + - Binary for large meshes and performance + - ASCII for debugging and text processing + +Best Practices +============== + +Workflow Integration +-------------------- + +1. **Start with quality assessment** using MainChecks or AllChecks +2. **Address specific issues** with targeted filters +3. **Validate fixes** by re-running quality checks +4. **Document mesh quality** for simulation reference + +Error Handling +-------------- + +- Check filter results before proceeding with simulations +- Save problematic meshes for debugging +- Document known issues and their acceptable thresholds +- Use multiple validation approaches for critical applications + +Base Classes +============ + +The fundamental base classes that all mesh doctor filters inherit from. + +.. toctree:: + :maxdepth: 1 + + MeshDoctorFilterBase + +See Also +======== + +- **GEOS Documentation**: Supported element types and mesh requirements +- **VTK Documentation**: VTK data formats and cell types +- **ParaView**: Visualization of mesh quality results \ No newline at end of file diff --git a/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py b/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py index 253165d94..f8f8d2c2a 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py @@ -1,4 +1,6 @@ from dataclasses import dataclass +from typing import Any +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid from geos.mesh.doctor.register import __load_module_action from geos.mesh.doctor.parsing.cli_parsing import setup_logger @@ -6,21 +8,33 @@ @dataclass( frozen=True ) class Options: checks_to_perform: list[ str ] - checks_options: dict[ str, any ] - check_displays: dict[ str, any ] + checks_options: dict[ str, Any ] + check_displays: dict[ str, Any ] @dataclass( frozen=True ) class Result: - check_results: dict[ str, any ] + check_results: dict[ str, Any ] -def action( vtk_input_file: str, options: Options ) -> list[ Result ]: +def get_check_results( vtk_input: str | vtkUnstructuredGrid, options: Options ) -> dict[ str, Any ]: + isFilepath: bool = isinstance( vtk_input, str ) + isVtkUnstructuredGrid: bool = isinstance( vtk_input, vtkUnstructuredGrid ) + assert isFilepath | isVtkUnstructuredGrid, "Invalid input type, should either be a filepath to .vtu file" \ + " or a vtkUnstructuredGrid object" check_results: dict[ str, any ] = dict() for check_name in options.checks_to_perform: - check_action = __load_module_action( check_name ) + if isVtkUnstructuredGrid: # we need to call the mesh_action function that takes a vtkPointSet as input + check_action = __load_module_action( check_name, "mesh_action" ) + else: # because its a filepath, we can call the regular action function + check_action = __load_module_action( check_name ) setup_logger.info( f"Performing check '{check_name}'." ) option = options.checks_options[ check_name ] - check_result = check_action( vtk_input_file, option ) + check_result = check_action( vtk_input, option ) check_results[ check_name ] = check_result + return check_results + + +def action( vtk_input_file: str, options: Options ) -> Result: + check_results: dict[ str, Any ] = get_check_results( vtk_input_file, options ) return Result( check_results=check_results ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py b/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py index 4881a1d38..aa6d2276f 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py @@ -1,11 +1,10 @@ from collections import defaultdict from dataclasses import dataclass import numpy -from typing import Collection, Iterable from vtkmodules.vtkCommonCore import reference, vtkPoints -from vtkmodules.vtkCommonDataModel import vtkIncrementalOctreePointLocator +from vtkmodules.vtkCommonDataModel import vtkCell, vtkIncrementalOctreePointLocator, vtkUnstructuredGrid from geos.mesh.doctor.parsing.cli_parsing import setup_logger -from geos.mesh.io.vtkIO import read_mesh +from geos.mesh.io.vtkIO import read_unstructured_grid @dataclass( frozen=True ) @@ -15,23 +14,22 @@ class Options: @dataclass( frozen=True ) class Result: - nodes_buckets: Iterable[ Iterable[ int ] ] # Each bucket contains the duplicated node indices. - wrong_support_elements: Collection[ int ] # Element indices with support node indices appearing more than once. + nodes_buckets: list[ tuple[ int ] ] # Each bucket contains the duplicated node indices. + wrong_support_elements: list[ int ] # Element indices with support node indices appearing more than once. -def __action( mesh, options: Options ) -> Result: - points = mesh.GetPoints() - +def find_collocated_nodes_buckets( mesh: vtkUnstructuredGrid, tolerance: float ) -> list[ tuple[ int ] ]: + points: vtkPoints = mesh.GetPoints() locator = vtkIncrementalOctreePointLocator() - locator.SetTolerance( options.tolerance ) + locator.SetTolerance( tolerance ) output = vtkPoints() locator.InitPointInsertion( output, points.GetBounds() ) # original ids to/from filtered ids. filtered_to_original = numpy.ones( points.GetNumberOfPoints(), dtype=int ) * -1 - rejected_points = defaultdict( list ) - point_id = reference( 0 ) + rejected_points: dict[ int, list[ int ] ] = defaultdict( list ) + point_id: int = reference( 0 ) for i in range( points.GetNumberOfPoints() ): is_inserted = locator.InsertUniquePoint( points.GetPoint( i ), point_id ) if not is_inserted: @@ -48,21 +46,29 @@ def __action( mesh, options: Options ) -> Result: # original_to_filtered[i] = point_id.get() filtered_to_original[ point_id.get() ] = i - tmp = [] + collocated_nodes_buckets: list[ tuple[ int ] ] = list() for n, ns in rejected_points.items(): - tmp.append( ( n, *ns ) ) + collocated_nodes_buckets.append( ( n, *ns ) ) + return collocated_nodes_buckets + +def find_wrong_support_elements( mesh: vtkUnstructuredGrid ) -> list[ int ]: # Checking that the support node indices appear only once per element. - wrong_support_elements = [] + wrong_support_elements: list[ int ] = list() for c in range( mesh.GetNumberOfCells() ): - cell = mesh.GetCell( c ) - num_points_per_cell = cell.GetNumberOfPoints() + cell: vtkCell = mesh.GetCell( c ) + num_points_per_cell: int = cell.GetNumberOfPoints() if len( { cell.GetPointId( i ) for i in range( num_points_per_cell ) } ) != num_points_per_cell: wrong_support_elements.append( c ) + return wrong_support_elements + - return Result( nodes_buckets=tmp, wrong_support_elements=wrong_support_elements ) +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + collocated_nodes_buckets = find_collocated_nodes_buckets( mesh, options.tolerance ) + wrong_support_elements = find_wrong_support_elements( mesh ) + return Result( nodes_buckets=collocated_nodes_buckets, wrong_support_elements=wrong_support_elements ) def action( vtk_input_file: str, options: Options ) -> Result: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py b/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py index e5380c3c0..c3d46a8c1 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py @@ -1,11 +1,11 @@ from dataclasses import dataclass -from typing import List, Tuple import uuid -from vtkmodules.vtkCommonDataModel import VTK_HEXAHEDRON, VTK_PYRAMID, VTK_TETRA, VTK_WEDGE +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_HEXAHEDRON, VTK_PYRAMID, VTK_TETRA, VTK_WEDGE from vtkmodules.vtkFiltersVerdict import vtkCellSizeFilter, vtkMeshQuality from vtkmodules.util.numpy_support import vtk_to_numpy from geos.mesh.doctor.parsing.cli_parsing import setup_logger -from geos.mesh.io.vtkIO import read_mesh +from geos.mesh.io.vtkIO import read_unstructured_grid @dataclass( frozen=True ) @@ -15,26 +15,22 @@ class Options: @dataclass( frozen=True ) class Result: - element_volumes: List[ Tuple[ int, float ] ] + element_volumes: list[ tuple[ int, float ] ] -def __action( mesh, options: Options ) -> Result: - cs = vtkCellSizeFilter() +SUPPORTED_TYPES = [ VTK_HEXAHEDRON, VTK_TETRA ] - cs.ComputeAreaOff() - cs.ComputeLengthOff() - cs.ComputeSumOff() - cs.ComputeVertexCountOff() - cs.ComputeVolumeOn() - volume_array_name = "__MESH_DOCTOR_VOLUME-" + str( uuid.uuid4() ) # Making the name unique - cs.SetVolumeArrayName( volume_array_name ) - cs.SetInputData( mesh ) - cs.Update() +def get_mesh_quality( mesh: vtkUnstructuredGrid ) -> vtkDataArray: + """Get the quality of the mesh. - mq = vtkMeshQuality() - SUPPORTED_TYPES = [ VTK_HEXAHEDRON, VTK_TETRA ] + Args: + mesh (vtkUnstructuredGrid): The input mesh. + Returns: + vtkDataArray: The array containing the quality of each cell. + """ + mq = vtkMeshQuality() mq.SetTetQualityMeasureToVolume() mq.SetHexQualityMeasureToVolume() if hasattr( mq, "SetPyramidQualityMeasureToVolume" ): # This feature is quite recent @@ -46,18 +42,50 @@ def __action( mesh, options: Options ) -> Result: setup_logger.warning( "Your \"pyvtk\" version does not bring pyramid nor wedge support with vtkMeshQuality. Using the fallback solution." ) - mq.SetInputData( mesh ) mq.Update() - volume = cs.GetOutput().GetCellData().GetArray( volume_array_name ) - quality = mq.GetOutput().GetCellData().GetArray( "Quality" ) # Name is imposed by vtk. + return mq.GetOutput().GetCellData().GetArray( "Quality" ) # Name is imposed by vtk. + + +def get_mesh_volume( mesh: vtkUnstructuredGrid ) -> vtkDataArray: + """Get the volume of the mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh. + + Returns: + vtkDataArray: The array containing the volume of each cell. + """ + cs = vtkCellSizeFilter() + cs.ComputeAreaOff() + cs.ComputeLengthOff() + cs.ComputeSumOff() + cs.ComputeVertexCountOff() + cs.ComputeVolumeOn() + + volume_array_name: str = "__MESH_DOCTOR_VOLUME-" + str( uuid.uuid4() ) # Making the name unique + cs.SetVolumeArrayName( volume_array_name ) + cs.SetInputData( mesh ) + cs.Update() + + return cs.GetOutput().GetCellData().GetArray( volume_array_name ) + + +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + volume: vtkDataArray = get_mesh_volume( mesh ) + if not volume: + setup_logger.error( "Volume computation failed." ) + raise RuntimeError( "Volume computation failed." ) + + quality: vtkDataArray = get_mesh_quality( mesh ) + if not quality: + setup_logger.error( "Quality computation failed." ) + raise RuntimeError( "Quality computation failed." ) - assert volume is not None - assert quality is not None volume = vtk_to_numpy( volume ) quality = vtk_to_numpy( quality ) - small_volumes: List[ Tuple[ int, float ] ] = [] + small_volumes: list[ tuple[ int, float ] ] = [] for i, pack in enumerate( zip( volume, quality ) ): v, q = pack vol = q if mesh.GetCellType( i ) in SUPPORTED_TYPES else v @@ -67,5 +95,5 @@ def __action( mesh, options: Options ) -> Result: def action( vtk_input_file: str, options: Options ) -> Result: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py b/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py index 3e00cf52e..5ab0b8914 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py @@ -1,27 +1,27 @@ from dataclasses import dataclass -from typing import Dict, FrozenSet, List, Set from vtkmodules.vtkCommonCore import vtkIdList +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid from geos.mesh.utils.genericHelpers import to_vtk_id_list -from geos.mesh.io.vtkIO import VtkOutput, read_mesh, write_mesh +from geos.mesh.io.vtkIO import VtkOutput, read_unstructured_grid, write_mesh @dataclass( frozen=True ) class Options: vtk_output: VtkOutput - cell_type_to_ordering: Dict[ int, List[ int ] ] + cell_type_to_ordering: dict[ int, list[ int ] ] @dataclass( frozen=True ) class Result: output: str - unchanged_cell_types: FrozenSet[ int ] + unchanged_cell_types: frozenset[ int ] -def __action( mesh, options: Options ) -> Result: +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: # The vtk cell type is an int and will be the key of the following mapping, # that will point to the relevant permutation. - cell_type_to_ordering: Dict[ int, List[ int ] ] = options.cell_type_to_ordering - unchanged_cell_types: Set[ int ] = set() # For logging purpose + cell_type_to_ordering: dict[ int, list[ int ] ] = options.cell_type_to_ordering + unchanged_cell_types: set[ int ] = set() # For logging purpose # Preparing the output mesh by first keeping the same instance type. output_mesh = mesh.NewInstance() @@ -49,5 +49,5 @@ def __action( mesh, options: Options ) -> Result: def action( vtk_input_file: str, options: Options ) -> Result: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py index f30d2089f..97bc254ff 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py @@ -1,13 +1,15 @@ from dataclasses import dataclass -import numpy +import numpy as np +import numpy.typing as npt from typing import Iterable, Sequence from vtkmodules.util.numpy_support import numpy_to_vtk from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkCommonDataModel import ( vtkCellArray, vtkHexahedron, vtkRectilinearGrid, vtkUnstructuredGrid, VTK_HEXAHEDRON ) -from geos.mesh.doctor.actions.generate_global_ids import __build_global_ids +from geos.mesh.doctor.actions.generate_global_ids import build_global_ids from geos.mesh.doctor.parsing.cli_parsing import setup_logger from geos.mesh.io.vtkIO import VtkOutput, write_mesh +from geos.mesh.utils.arrayModifiers import createConstantAttributeDataSet @dataclass( frozen=True ) @@ -38,9 +40,64 @@ class Options: @dataclass( frozen=True ) class XYZ: - x: numpy.ndarray - y: numpy.ndarray - z: numpy.ndarray + x: npt.NDArray + y: npt.NDArray + z: npt.NDArray + + +def build_coordinates( positions, num_elements ): + result = [] + it = zip( zip( positions, positions[ 1: ] ), num_elements ) + try: + coords, n = next( it ) + while True: + start, stop = coords + end_point = False + tmp = np.linspace( start=start, stop=stop, num=n + end_point, endpoint=end_point ) + coords, n = next( it ) + result.append( tmp ) + except StopIteration: + end_point = True + tmp = np.linspace( start=start, stop=stop, num=n + end_point, endpoint=end_point ) + result.append( tmp ) + return np.concatenate( result ) + + +def build_rectilinear_grid( x: npt.NDArray, y: npt.NDArray, z: npt.NDArray ) -> vtkUnstructuredGrid: + """ + Builds an unstructured vtk grid from the x,y,z coordinates. + :return: The unstructured mesh, even if it's topologically structured. + """ + rg = vtkRectilinearGrid() + rg.SetDimensions( len( x ), len( y ), len( z ) ) + rg.SetXCoordinates( numpy_to_vtk( x ) ) + rg.SetYCoordinates( numpy_to_vtk( y ) ) + rg.SetZCoordinates( numpy_to_vtk( z ) ) + + num_points = rg.GetNumberOfPoints() + num_cells = rg.GetNumberOfCells() + + points = vtkPoints() + points.Allocate( num_points ) + for i in range( rg.GetNumberOfPoints() ): + points.InsertNextPoint( rg.GetPoint( i ) ) + + cell_types = [ VTK_HEXAHEDRON ] * num_cells + cells = vtkCellArray() + cells.AllocateExact( num_cells, num_cells * 8 ) + + m = ( 0, 1, 3, 2, 4, 5, 7, 6 ) # VTK_VOXEL and VTK_HEXAHEDRON do not share the same ordering. + for i in range( rg.GetNumberOfCells() ): + c = rg.GetCell( i ) + new_cell = vtkHexahedron() + for j in range( 8 ): + new_cell.GetPointIds().SetId( j, c.GetPointId( m[ j ] ) ) + cells.InsertNextCell( new_cell ) + + mesh = vtkUnstructuredGrid() + mesh.SetPoints( points ) + mesh.SetCells( cell_types, cells ) + return mesh def build_rectilinear_blocks_mesh( xyzs: Iterable[ XYZ ] ) -> vtkUnstructuredGrid: @@ -89,50 +146,38 @@ def build_rectilinear_blocks_mesh( xyzs: Iterable[ XYZ ] ) -> vtkUnstructuredGri return mesh -def __add_fields( mesh: vtkUnstructuredGrid, fields: Iterable[ FieldInfo ] ) -> vtkUnstructuredGrid: +def add_fields( mesh: vtkUnstructuredGrid, fields: Iterable[ FieldInfo ] ) -> vtkUnstructuredGrid: + """ + Add constant fields to the mesh using arrayModifiers utilities. + Each field is filled with ones (1.0) for all components. + """ for field_info in fields: - if field_info.support == "CELLS": - data = mesh.GetCellData() - n = mesh.GetNumberOfCells() - elif field_info.support == "POINTS": - data = mesh.GetPointData() - n = mesh.GetNumberOfPoints() - array = numpy.ones( ( n, field_info.dimension ), dtype=float ) - vtk_array = numpy_to_vtk( array ) - vtk_array.SetName( field_info.name ) - data.AddArray( vtk_array ) + onPoints = field_info.support == "POINTS" + # Create list of values (all 1.0) for each component + listValues = [ 1.0 ] * field_info.dimension + # Use the robust createConstantAttributeDataSet function + success = createConstantAttributeDataSet( dataSet=mesh, + listValues=listValues, + attributeName=field_info.name, + onPoints=onPoints, + logger=setup_logger ) + if not success: + setup_logger.warning( f"Failed to create field {field_info.name}" ) return mesh def __build( options: Options ): - def build_coordinates( positions, num_elements ): - result = [] - it = zip( zip( positions, positions[ 1: ] ), num_elements ) - try: - coords, n = next( it ) - while True: - start, stop = coords - end_point = False - tmp = numpy.linspace( start=start, stop=stop, num=n + end_point, endpoint=end_point ) - coords, n = next( it ) - result.append( tmp ) - except StopIteration: - end_point = True - tmp = numpy.linspace( start=start, stop=stop, num=n + end_point, endpoint=end_point ) - result.append( tmp ) - return numpy.concatenate( result ) - x = build_coordinates( options.xs, options.nxs ) y = build_coordinates( options.ys, options.nys ) z = build_coordinates( options.zs, options.nzs ) cube = build_rectilinear_blocks_mesh( ( XYZ( x, y, z ), ) ) - cube = __add_fields( cube, options.fields ) - __build_global_ids( cube, options.generate_cells_global_ids, options.generate_points_global_ids ) + cube = add_fields( cube, options.fields ) + build_global_ids( cube, options.generate_cells_global_ids, options.generate_points_global_ids ) return cube -def __action( options: Options ) -> Result: +def mesh_action( options: Options ) -> Result: output_mesh = __build( options ) write_mesh( output_mesh, options.vtk_output ) return Result( info=f"Mesh was written to {options.vtk_output.output}" ) @@ -140,7 +185,7 @@ def __action( options: Options ) -> Result: def action( vtk_input_file: str, options: Options ) -> Result: try: - return __action( options ) + return mesh_action( options ) except BaseException as e: setup_logger.error( e ) return Result( info="Something went wrong." ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py index 32f809db9..953be3f16 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py @@ -15,7 +15,7 @@ from geos.mesh.doctor.parsing.cli_parsing import setup_logger from geos.mesh.utils.arrayHelpers import has_array from geos.mesh.utils.genericHelpers import to_vtk_id_list, vtk_iter -from geos.mesh.io.vtkIO import VtkOutput, read_mesh, write_mesh +from geos.mesh.io.vtkIO import VtkOutput, read_unstructured_grid, write_mesh """ TypeAliases cannot be used with Python 3.9. A simple assignment like described there will be used: https://docs.python.org/3/library/typing.html#typing.TypeAlias:~:text=through%20simple%20assignment%3A-,Vector%20%3D%20list%5Bfloat%5D,-Or%20marked%20with @@ -527,8 +527,8 @@ def __generate_fracture_mesh( old_mesh: vtkUnstructuredGrid, fracture_info: Frac return fracture_mesh -def __split_mesh_on_fractures( mesh: vtkUnstructuredGrid, - options: Options ) -> tuple[ vtkUnstructuredGrid, list[ vtkUnstructuredGrid ] ]: +def split_mesh_on_fractures( mesh: vtkUnstructuredGrid, + options: Options ) -> tuple[ vtkUnstructuredGrid, list[ vtkUnstructuredGrid ] ]: all_fracture_infos: list[ FractureInfo ] = list() for fracture_id in range( len( options.field_values_per_fracture ) ): fracture_info: FractureInfo = build_fracture_info( mesh, options, False, fracture_id ) @@ -546,8 +546,8 @@ def __split_mesh_on_fractures( mesh: vtkUnstructuredGrid, return ( output_mesh, fracture_meshes ) -def __action( mesh, options: Options ) -> Result: - output_mesh, fracture_meshes = __split_mesh_on_fractures( mesh, options ) +def mesh_action( mesh, options: Options ) -> Result: + output_mesh, fracture_meshes = split_mesh_on_fractures( mesh, options ) write_mesh( output_mesh, options.mesh_VtkOutput ) for i, fracture_mesh in enumerate( fracture_meshes ): write_mesh( fracture_mesh, options.all_fractures_VtkOutput[ i ] ) @@ -557,14 +557,14 @@ def __action( mesh, options: Options ) -> Result: def action( vtk_input_file: str, options: Options ) -> Result: try: - mesh = read_mesh( vtk_input_file ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) # Mesh cannot contain global ids before splitting. if has_array( mesh, [ "GLOBAL_IDS_POINTS", "GLOBAL_IDS_CELLS" ] ): err_msg: str = ( "The mesh cannot contain global ids for neither cells nor points. The correct procedure " + " is to split the mesh and then generate global ids for new split meshes." ) setup_logger.error( err_msg ) raise ValueError( err_msg ) - return __action( mesh, options ) + return mesh_action( mesh, options ) except BaseException as e: setup_logger.error( e ) return Result( info="Something went wrong" ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py index f4df18711..e73e21ca7 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from vtkmodules.vtkCommonCore import vtkIdTypeArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid from geos.mesh.doctor.parsing.cli_parsing import setup_logger -from geos.mesh.io.vtkIO import VtkOutput, read_mesh, write_mesh +from geos.mesh.io.vtkIO import VtkOutput, read_unstructured_grid, write_mesh @dataclass( frozen=True ) @@ -16,7 +17,7 @@ class Result: info: str -def __build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_global_ids: bool ) -> None: +def build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_global_ids: bool ) -> None: """ Adds the global ids for cells and points in place into the mesh instance. :param mesh: @@ -45,16 +46,16 @@ def __build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_g mesh.GetCellData().SetGlobalIds( cells_global_ids ) -def __action( mesh, options: Options ) -> Result: - __build_global_ids( mesh, options.generate_cells_global_ids, options.generate_points_global_ids ) +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + build_global_ids( mesh, options.generate_cells_global_ids, options.generate_points_global_ids ) write_mesh( mesh, options.vtk_output ) return Result( info=f"Mesh was written to {options.vtk_output.output}" ) def action( vtk_input_file: str, options: Options ) -> Result: try: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) except BaseException as e: setup_logger.error( e ) return Result( info="Something went wrong." ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py b/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py index d1c83a37a..728954ad3 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py @@ -1,10 +1,11 @@ from dataclasses import dataclass import math -import numpy +import numpy as np +import numpy.typing as npt from tqdm import tqdm -from typing import List, Tuple, Any +from typing import Any, Sequence from vtk import reference as vtk_reference -from vtkmodules.vtkCommonCore import vtkIdList, vtkPoints +from vtkmodules.vtkCommonCore import vtkDataArray, vtkIdList, vtkPoints from vtkmodules.vtkCommonDataModel import ( vtkBoundingBox, vtkCell, vtkCellArray, vtkPointSet, vtkPolyData, vtkStaticCellLocator, vtkStaticPointLocator, vtkUnstructuredGrid, VTK_POLYHEDRON ) @@ -14,7 +15,7 @@ from vtkmodules.vtkFiltersModeling import vtkCollisionDetectionFilter, vtkLinearExtrusionFilter from geos.mesh.doctor.actions import reorient_mesh, triangle_distance from geos.mesh.utils.genericHelpers import vtk_iter -from geos.mesh.io.vtkIO import read_mesh +from geos.mesh.io.vtkIO import read_unstructured_grid @dataclass( frozen=True ) @@ -26,12 +27,11 @@ class Options: @dataclass( frozen=True ) class Result: - non_conformal_cells: List[ Tuple[ int, int ] ] + non_conformal_cells: list[ tuple[ int, int ] ] class BoundaryMesh: - """ - A BoundaryMesh is the envelope of the 3d mesh on which we want to perform the simulations. + """A BoundaryMesh is the envelope of the 3d mesh on which we want to perform the simulations. It is computed by vtk. But we want to be sure that the normals of the envelope are directed outwards. The `vtkDataSetSurfaceFilter` does not have the same behavior for standard vtk cells (like tets or hexs), and for polyhedron meshes, for which the result is a bit brittle. @@ -40,9 +40,10 @@ class BoundaryMesh: """ def __init__( self, mesh: vtkUnstructuredGrid ): - """ - Builds a boundary mesh. - :param mesh: The 3d mesh. + """Builds a boundary mesh. + + Args: + mesh (vtkUnstructuredGrid): The 3d mesh. """ # Building the boundary meshes boundary_mesh, __normals, self.__original_cells = BoundaryMesh.__build_boundary_mesh( mesh ) @@ -53,13 +54,13 @@ def __init__( self, mesh: vtkUnstructuredGrid ): self.re_boundary_mesh, re_normals, _ = BoundaryMesh.__build_boundary_mesh( reoriented_mesh, consistency=False ) num_cells = boundary_mesh.GetNumberOfCells() # Precomputing the underlying cell type - self.__is_underlying_cell_type_a_polyhedron = numpy.zeros( num_cells, dtype=bool ) + self.__is_underlying_cell_type_a_polyhedron = np.zeros( num_cells, dtype=bool ) for ic in range( num_cells ): self.__is_underlying_cell_type_a_polyhedron[ ic ] = mesh.GetCell( self.__original_cells.GetValue( ic ) ).GetCellType() == VTK_POLYHEDRON # Precomputing the normals - self.__normals: numpy.ndarray = numpy.empty( ( num_cells, 3 ), dtype=numpy.double, - order='C' ) # Do not modify the storage layout + self.__normals: np.ndarray = np.empty( ( num_cells, 3 ), dtype=np.float64, + order='C' ) # Do not modify the storage layout for ic in range( num_cells ): if self.__is_underlying_cell_type_a_polyhedron[ ic ]: self.__normals[ ic, : ] = re_normals.GetTuple3( ic ) @@ -67,13 +68,16 @@ def __init__( self, mesh: vtkUnstructuredGrid ): self.__normals[ ic, : ] = __normals.GetTuple3( ic ) @staticmethod - def __build_boundary_mesh( mesh: vtkUnstructuredGrid, consistency=True ) -> Tuple[ vtkUnstructuredGrid, Any, Any ]: - """ - From a 3d mesh, build the envelope meshes. - :param mesh: The input 3d mesh. - :param consistency: The vtk option passed to the `vtkDataSetSurfaceFilter`. - :return: A tuple containing the boundary mesh, the normal vectors array, - an array that maps the id of the boundary element to the id of the 3d cell it touches. + def __build_boundary_mesh( mesh: vtkUnstructuredGrid, consistency=True ) -> tuple[ vtkUnstructuredGrid, Any, Any ]: + """From a 3d mesh, build the envelope meshes. + + Args: + mesh (vtkUnstructuredGrid): The input 3d mesh. + consistency (bool, optional): The vtk option passed to the `vtkDataSetSurfaceFilter`. Defaults to True. + + Returns: + tuple[ vtkUnstructuredGrid, Any, Any ]: A tuple containing the boundary mesh, the normal vectors array, + an array that maps the id of the boundary element to the id of the 3d cell it touches. """ f = vtkDataSetSurfaceFilter() f.PassThroughCellIdsOn() @@ -81,7 +85,7 @@ def __build_boundary_mesh( mesh: vtkUnstructuredGrid, consistency=True ) -> Tupl f.FastModeOff() # Note that we do not need the original points, but we could keep them as well if needed - original_cells_key = "ORIGINAL_CELLS" + original_cells_key: str = "ORIGINAL_CELLS" f.SetOriginalCellIdsName( original_cells_key ) boundary_mesh = vtkPolyData() @@ -94,7 +98,7 @@ def __build_boundary_mesh( mesh: vtkUnstructuredGrid, consistency=True ) -> Tupl n.ComputeCellNormalsOn() n.SetInputData( boundary_mesh ) n.Update() - normals = n.GetOutput().GetCellData().GetArray( "Normals" ) + normals: vtkDataArray = n.GetOutput().GetCellData().GetArray( "Normals" ) assert normals assert normals.GetNumberOfComponents() == 3 assert normals.GetNumberOfTuples() == boundary_mesh.GetNumberOfCells() @@ -103,74 +107,92 @@ def __build_boundary_mesh( mesh: vtkUnstructuredGrid, consistency=True ) -> Tupl return boundary_mesh, normals, original_cells def GetNumberOfCells( self ) -> int: - """ - The number of cells. - :return: An integer. + """The number of cells. + + Returns: + int """ return self.re_boundary_mesh.GetNumberOfCells() def GetNumberOfPoints( self ) -> int: - """ - The number of points. - :return: An integer. + """The number of points. + + Returns: + int """ return self.re_boundary_mesh.GetNumberOfPoints() - def bounds( self, i ) -> Tuple[ float, float, float, float, float, float ]: - """ - The boundrary box of cell `i`. - :param i: The boundary cell index. - :return: The vtk bounding box. + def bounds( self, i: int ) -> tuple[ float, float, float, float, float, float ]: + """The boundrary box of cell `i`. + + Args: + i (int): The boundary cell index. + + Returns: + tuple[ float, float, float, float, float, float ]: The vtk bounding box. """ return self.re_boundary_mesh.GetCell( i ).GetBounds() - def normals( self, i ) -> numpy.ndarray: - """ - The normal of cell `i`. This normal will be directed outwards - :param i: The boundary cell index. - :return: The normal as a length-3 numpy array. + def normals( self, i: int ) -> npt.NDArray: + """The normal of cell `i`. This normal will be directed outwards + + Args: + i (int): The boundary cell index. + + Returns: + npt.NDArray: The normal as a length-3 numpy array. """ return self.__normals[ i ] - def GetCell( self, i ) -> vtkCell: - """ - Cell i of the boundary mesh. This cell will have its normal directed outwards. - :param i: The boundary cell index. - :return: The cell instance. - :warning: This member function relies on the vtkUnstructuredGrid.GetCell member function which is not thread safe. + def GetCell( self, i: int ) -> vtkCell: + """Cell i of the boundary mesh. This cell will have its normal directed outwards. + This member function relies on the vtkUnstructuredGrid.GetCell member function which is not thread safe. + + Args: + i (int): The boundary cell index. + + Returns: + vtkCell: The cell instance. """ return self.re_boundary_mesh.GetCell( i ) - def GetPoint( self, i ) -> Tuple[ float, float, float ]: - """ - Point i of the boundary mesh. - :param i: The boundary point index. - :return: A length-3 tuple containing the coordinates of the point. - :warning: This member function relies on the vtkUnstructuredGrid.GetPoint member function which is not thread safe. + def GetPoint( self, i: int ) -> tuple[ float, float, float ]: + """Point i of the boundary mesh. + This member function relies on the vtkUnstructuredGrid.GetPoint member function which is not thread safe. + + Args: + i (int): The boundary point index. + + Returns: + tuple[ float, float, float ]: A length-3 tuple containing the coordinates of the point. """ return self.re_boundary_mesh.GetPoint( i ) @property - def original_cells( self ): - """ - Returns the 2d boundary cell to the 3d cell index of the original mesh. - :return: A 1d array. + def original_cells( self ) -> vtkDataArray: + """Returns the 2d boundary cell to the 3d cell index of the original mesh. + + Returns: + vtkDataArray: A 1d array. """ return self.__original_cells def build_poly_data_for_extrusion( i: int, boundary_mesh: BoundaryMesh ) -> vtkPolyData: - """ - Creates a vtkPolyData containing the unique cell `i` of the boundary mesh. + """Creates a vtkPolyData containing the unique cell `i` of the boundary mesh. This operation is needed to use the vtk extrusion filter. - :param i: The boundary cell index that will eventually be extruded. - :param boundary_mesh: - :return: The created vtkPolyData. + + Args: + i (int): The boundary cell index that will eventually be extruded. + boundary_mesh (BoundaryMesh) + + Returns: + vtkPolyData: The created vtkPolyData. """ cell = boundary_mesh.GetCell( i ) copied_cell = cell.NewInstance() copied_cell.DeepCopy( cell ) - points_ids_mapping = [] + points_ids_mapping: list[ int ] = list() for i in range( copied_cell.GetNumberOfPoints() ): copied_cell.GetPointIds().SetId( i, i ) points_ids_mapping.append( cell.GetPointId( i ) ) @@ -187,12 +209,15 @@ def build_poly_data_for_extrusion( i: int, boundary_mesh: BoundaryMesh ) -> vtkP def are_points_conformal( point_tolerance: float, cell_i: vtkCell, cell_j: vtkCell ) -> bool: - """ - Checks if points of cell `i` matches, one by one, the points of cell `j`. - :param point_tolerance: The point tolerance to consider that two points match. - :param cell_i: The first cell. - :param cell_j: The second cell. - :return: A boolean. + """Checks if points of cell `i` matches, one by one, the points of cell `j`. + + Args: + point_tolerance (float): The point tolerance to consider that two points match. + cell_i (vtkCell): The first cell. + cell_j (vtkCell): The second cell. + + Returns: + bool """ # In this last step, we check that the nodes are (or not) matching each other. if cell_i.GetNumberOfPoints() != cell_j.GetNumberOfPoints(): @@ -203,34 +228,34 @@ def are_points_conformal( point_tolerance: float, cell_i: vtkCell, cell_j: vtkCe points.SetPoints( cell_i.GetPoints() ) point_locator.SetDataSet( points ) point_locator.BuildLocator() - found_points = set() + found_points: set[ int ] = set() for ip in range( cell_j.GetNumberOfPoints() ): p = cell_j.GetPoints().GetPoint( ip ) squared_dist = vtk_reference( 0. ) # unused - found_point = point_locator.FindClosestPointWithinRadius( point_tolerance, p, squared_dist ) + found_point: int = point_locator.FindClosestPointWithinRadius( point_tolerance, p, squared_dist ) found_points.add( found_point ) return found_points == set( range( cell_i.GetNumberOfPoints() ) ) class Extruder: - """ - Computes and stores all the extrusions of the boundary faces. + """Computes and stores all the extrusions of the boundary faces. The main reason for this class is to be lazy and cache the extrusions. """ def __init__( self, boundary_mesh: BoundaryMesh, face_tolerance: float ): - self.__extrusions: List[ vtkPolyData ] = [ - None, - ] * boundary_mesh.GetNumberOfCells() - self.__boundary_mesh = boundary_mesh - self.__face_tolerance = face_tolerance + self.__extrusions: list[ vtkPolyData ] = [ None ] * boundary_mesh.GetNumberOfCells() + self.__boundary_mesh: BoundaryMesh = boundary_mesh + self.__face_tolerance: float = face_tolerance - def __extrude( self, polygon_poly_data, normal ) -> vtkPolyData: - """ - Extrude the polygon data to create a volume that will be used for intersection. - :param polygon_poly_data: The data to extrude - :param normal: The (uniform) direction of the extrusion. - :return: The extrusion. + def __extrude( self, polygon_poly_data: vtkPolyData, normal: Sequence[ float ] ) -> vtkPolyData: + """Extrude the polygon data to create a volume that will be used for intersection. + + Args: + polygon_poly_data (_type_): The data to extrude + normal (_type_): The (uniform) direction of the extrusion. + + Returns: + vtkPolyData: The extrusion. """ extruder = vtkLinearExtrusionFilter() extruder.SetExtrusionTypeToVectorExtrusion() @@ -240,11 +265,14 @@ def __extrude( self, polygon_poly_data, normal ) -> vtkPolyData: extruder.Update() return extruder.GetOutput() - def __getitem__( self, i ) -> vtkPolyData: - """ - Returns the vtk extrusion for boundary element i. - :param i: The cell index. - :return: The vtk instance. + def __getitem__( self, i: int ) -> vtkPolyData: + """Returns the vtk extrusion for boundary element i. + + Args: + i (int): The cell index. + + Returns: + vtkPolyData: The vtk instance. """ extrusion = self.__extrusions[ i ] if extrusion: @@ -257,14 +285,17 @@ def __getitem__( self, i ) -> vtkPolyData: def are_faces_conformal_using_extrusions( extrusions: Extruder, i: int, j: int, boundary_mesh: vtkUnstructuredGrid, point_tolerance: float ) -> bool: - """ - Tests if two boundary faces are conformal, checking for intersection between their normal extruded volumes. - :param extrusions: The extrusions cache. - :param i: The cell index of the first cell. - :param j: The cell index of the second cell. - :param boundary_mesh: The boundary mesh. - :param point_tolerance: The point tolerance to consider that two points match. - :return: A boolean. + """Tests if two boundary faces are conformal, checking for intersection between their normal extruded volumes. + + Args: + extrusions (Extruder): The extrusions cache. + i (int): The cell index of the first cell. + j (int): The cell index of the second cell. + boundary_mesh (vtkUnstructuredGrid): The boundary mesh. + point_tolerance (float): The point tolerance to consider that two points match. + + Returns: + bool """ collision = vtkCollisionDetectionFilter() collision.SetCollisionModeToFirstContact() @@ -289,21 +320,24 @@ def are_faces_conformal_using_extrusions( extrusions: Extruder, i: int, j: int, def are_faces_conformal_using_distances( i: int, j: int, boundary_mesh: vtkUnstructuredGrid, face_tolerance: float, point_tolerance: float ) -> bool: - """ - Tests if two boundary faces are conformal, checking the minimal distance between triangulated surfaces. - :param i: The cell index of the first cell. - :param j: The cell index of the second cell. - :param boundary_mesh: The boundary mesh. - :param face_tolerance: The tolerance under which we should consider the two faces "touching" each other. - :param point_tolerance: The point tolerance to consider that two points match. - :return: A boolean. + """Tests if two boundary faces are conformal, checking the minimal distance between triangulated surfaces. + + Args: + i (int): The cell index of the first cell. + j (int): The cell index of the second cell. + boundary_mesh (vtkUnstructuredGrid): The boundary mesh. + face_tolerance (float): The tolerance under which we should consider the two faces "touching" each other. + point_tolerance (float): The point tolerance to consider that two points match. + + Returns: + bool """ cp_i = boundary_mesh.GetCell( i ).NewInstance() cp_i.DeepCopy( boundary_mesh.GetCell( i ) ) cp_j = boundary_mesh.GetCell( j ).NewInstance() cp_j.DeepCopy( boundary_mesh.GetCell( j ) ) - def triangulate( cell ): + def triangulate( cell: vtkCell ): assert cell.GetCellDimension() == 2 __points_ids = vtkIdList() __points = vtkPoints() @@ -322,13 +356,13 @@ def build_numpy_triangles( points_ids ): __t = [] for __pi in points_ids[ __i:__i + 3 ]: __t.append( boundary_mesh.GetPoint( __pi ) ) - __triangles.append( numpy.array( __t, dtype=float ) ) + __triangles.append( np.array( __t, dtype=float ) ) return __triangles triangles_i = build_numpy_triangles( points_ids_i ) triangles_j = build_numpy_triangles( points_ids_j ) - min_dist = numpy.inf + min_dist = np.inf for ti, tj in [ ( ti, tj ) for ti in triangles_i for tj in triangles_j ]: # Note that here, we compute the exact distance to compare with the threshold. # We could improve by exiting the iterative distance computation as soon as @@ -344,44 +378,57 @@ def build_numpy_triangles( points_ids ): return are_points_conformal( point_tolerance, cp_i, cp_j ) -def __action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: - """ - Checks if the mesh is "conformal" (i.e. if some of its boundary faces may not be too close to each other without matching nodes). - :param mesh: The vtk mesh - :param options: The check options. - :return: The Result instance. - """ - boundary_mesh = BoundaryMesh( mesh ) - cos_theta = abs( math.cos( numpy.deg2rad( options.angle_tolerance ) ) ) - num_cells = boundary_mesh.GetNumberOfCells() +def compute_bounding_box( boundary_mesh: BoundaryMesh, face_tolerance: float ) -> npt.NDArray[ np.float64 ]: + # Precomputing the bounding boxes. + # The options are important to directly interact with memory in C++. + bounding_boxes = np.empty( ( boundary_mesh.GetNumberOfCells(), 6 ), dtype=np.float64, order="C" ) + for i in range( boundary_mesh.GetNumberOfCells() ): + bb = vtkBoundingBox( boundary_mesh.bounds( i ) ) + bb.Inflate( 2 * face_tolerance ) + assert bounding_boxes[ + i, : ].data.contiguous # Do not modify the storage layout since vtk deals with raw memory here. + bb.GetBounds( bounding_boxes[ i, : ] ) + return bounding_boxes + +def compute_number_cells_per_node( boundary_mesh: BoundaryMesh ) -> npt.NDArray[ np.int64 ]: # Computing the exact number of cells per node - num_cells_per_node = numpy.zeros( boundary_mesh.GetNumberOfPoints(), dtype=int ) + num_cells_per_node = np.zeros( boundary_mesh.GetNumberOfPoints(), dtype=int ) for ic in range( boundary_mesh.GetNumberOfCells() ): c = boundary_mesh.GetCell( ic ) point_ids = c.GetPointIds() for point_id in vtk_iter( point_ids ): num_cells_per_node[ point_id ] += 1 + return num_cells_per_node + +def build_cell_locator( mesh: vtkUnstructuredGrid, numberMaxCellPerNode: int ) -> vtkStaticCellLocator: cell_locator = vtkStaticCellLocator() cell_locator.Initialize() - cell_locator.SetNumberOfCellsPerNode( num_cells_per_node.max() ) - cell_locator.SetDataSet( boundary_mesh.re_boundary_mesh ) + cell_locator.SetNumberOfCellsPerNode( numberMaxCellPerNode ) + cell_locator.SetDataSet( mesh ) cell_locator.BuildLocator() + return cell_locator - # Precomputing the bounding boxes. - # The options are important to directly interact with memory in C++. - bounding_boxes = numpy.empty( ( boundary_mesh.GetNumberOfCells(), 6 ), dtype=numpy.double, order="C" ) - for i in range( boundary_mesh.GetNumberOfCells() ): - bb = vtkBoundingBox( boundary_mesh.bounds( i ) ) - bb.Inflate( 2 * options.face_tolerance ) - assert bounding_boxes[ - i, : ].data.contiguous # Do not modify the storage layout since vtk deals with raw memory here. - bb.GetBounds( bounding_boxes[ i, : ] ) - non_conformal_cells = [] +def find_non_conformal_cells( mesh: vtkUnstructuredGrid, options: Options ) -> list[ tuple[ int, int ] ]: + # Extracts the outer surface of the 3D mesh. + # Ensures that face normals are consistently oriented outward. + boundary_mesh = BoundaryMesh( mesh ) + num_cells: int = boundary_mesh.GetNumberOfCells() + + # Used to filter out face pairs that are not facing each other. + cos_theta: float = abs( math.cos( np.deg2rad( options.angle_tolerance ) ) ) + + # Prepares extruded volumes of boundary faces for intersection testing. extrusions = Extruder( boundary_mesh, options.face_tolerance ) + + num_cells_per_node = compute_number_cells_per_node( boundary_mesh ) + bounding_boxes = compute_bounding_box( boundary_mesh, options.face_tolerance ) + cell_locator = build_cell_locator( boundary_mesh.re_boundary_mesh, num_cells_per_node.max() ) + close_cells = vtkIdList() + non_conformal_cells_boundary_id: list[ tuple[ int, int ] ] = list() # Looping on all the pairs of boundary cells. We'll hopefully discard most of the pairs. for i in tqdm( range( num_cells ), desc="Non conformal elements" ): cell_locator.FindCellsWithinBounds( bounding_boxes[ i ], close_cells ) @@ -390,19 +437,34 @@ def __action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: continue # Discarding pairs that are not facing each others (with a threshold). normal_i, normal_j = boundary_mesh.normals( i ), boundary_mesh.normals( j ) - if numpy.dot( normal_i, normal_j ) > -cos_theta: # opposite directions only (can be facing or not) + if np.dot( normal_i, normal_j ) > -cos_theta: # opposite directions only (can be facing or not) continue # At this point, back-to-back and face-to-face pairs of elements are considered. if not are_faces_conformal_using_extrusions( extrusions, i, j, boundary_mesh, options.point_tolerance ): - non_conformal_cells.append( ( i, j ) ) + non_conformal_cells_boundary_id.append( ( i, j ) ) # Extracting the original 3d element index (and not the index of the boundary mesh). - tmp = [] - for i, j in non_conformal_cells: - tmp.append( ( boundary_mesh.original_cells.GetValue( i ), boundary_mesh.original_cells.GetValue( j ) ) ) + non_conformal_cells: list[ tuple[ int, int ] ] = list() + for i, j in non_conformal_cells_boundary_id: + non_conformal_cells.append( + ( boundary_mesh.original_cells.GetValue( i ), boundary_mesh.original_cells.GetValue( j ) ) ) + return non_conformal_cells - return Result( non_conformal_cells=tmp ) + +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + """Checks if the mesh is "conformal" + (i.e. if some of its boundary faces may not be too close to each other without matching nodes). + + Args: + mesh (vtkUnstructuredGrid): The vtk mesh + options (Options): The check options. + + Returns: + Result: The Result instance. + """ + non_conformal_cells = find_non_conformal_cells( mesh, options ) + return Result( non_conformal_cells=non_conformal_cells ) def action( vtk_input_file: str, options: Options ) -> Result: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py b/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py index 3b7d313ab..7c4922ae6 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from typing import Collection, List from vtkmodules.util.numpy_support import vtk_to_numpy from vtkmodules.vtkFiltersGeneral import vtkCellValidator from vtkmodules.vtkCommonCore import vtkOutputWindow, vtkFileOutputWindow -from geos.mesh.io.vtkIO import read_mesh +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.io.vtkIO import read_unstructured_grid @dataclass( frozen=True ) @@ -13,67 +13,84 @@ class Options: @dataclass( frozen=True ) class Result: - wrong_number_of_points_elements: Collection[ int ] - intersecting_edges_elements: Collection[ int ] - intersecting_faces_elements: Collection[ int ] - non_contiguous_edges_elements: Collection[ int ] - non_convex_elements: Collection[ int ] - faces_are_oriented_incorrectly_elements: Collection[ int ] + invalid_cell_ids: dict[ str, list[ int ] ] -def __action( mesh, options: Options ) -> Result: +def get_invalid_cell_ids( mesh: vtkUnstructuredGrid, min_distance: float ) -> dict[ str, list[ int ] ]: + """For every cell element in a vtk mesh, check if the cell is invalid regarding 8 specific criteria: + "wrong_number_of_points", "intersecting_edges", "intersecting_faces", "non_contiguous_edges","non_convex", + "faces_oriented_incorrectly", "non_planar_faces_elements" and "degenerate_faces_elements". + + If any of this criteria was met, the cell index is added to a list corresponding to this specific criteria. + The dict with the complete list of cell indices by criteria is returned. + + Args: + mesh (vtkUnstructuredGrid): A vtk grid. + min_distance (float): Minimum distance in the computation. + + Returns: + dict[ str, list[ int ] ]: + { + "wrong_number_of_points": [ 10, 34, ... ], + "intersecting_edges": [ ... ], + "intersecting_faces": [ ... ], + "non_contiguous_edges": [ ... ], + "non_convex": [ ... ], + "faces_oriented_incorrectly": [ ... ], + "non_planar_faces_elements": [ ... ], + "degenerate_faces_elements": [ ... ] + } + """ + # The goal of this first block is to silence the standard error output from VTK. The vtkCellValidator can be very + # verbose, printing a message for every cell it checks. We redirect the output to /dev/null to remove it. err_out = vtkFileOutputWindow() - err_out.SetFileName( "/dev/null" ) # vtkCellValidator outputs loads for each cell... + err_out.SetFileName( "/dev/null" ) vtk_std_err_out = vtkOutputWindow() vtk_std_err_out.SetInstance( err_out ) - valid = 0x0 - wrong_number_of_points = 0x01 - intersecting_edges = 0x02 - intersecting_faces = 0x04 - non_contiguous_edges = 0x08 - non_convex = 0x10 - faces_are_oriented_incorrectly = 0x20 - - wrong_number_of_points_elements: List[ int ] = [] - intersecting_edges_elements: List[ int ] = [] - intersecting_faces_elements: List[ int ] = [] - non_contiguous_edges_elements: List[ int ] = [] - non_convex_elements: List[ int ] = [] - faces_are_oriented_incorrectly_elements: List[ int ] = [] + # Different types of cell invalidity are defined as hexadecimal values, specific to vtkCellValidator + # Complete set of validity checks available in vtkCellValidator + error_masks: dict[ str, int ] = { + "wrongNumberOfPointsElements": 0x01, # 0000 0001 + "intersectingEdgesElements": 0x02, # 0000 0010 + "intersectingFacesElements": 0x04, # 0000 0100 + "nonContiguousEdgesElements": 0x08, # 0000 1000 + "nonConvexElements": 0x10, # 0001 0000 + "facesOrientedIncorrectlyElements": 0x20, # 0010 0000 + "nonPlanarFacesElements": 0x40, # 0100 0000 + "degenerateFacesElements": 0x80, # 1000 0000 + } - f = vtkCellValidator() - f.SetTolerance( options.min_distance ) + # The results can be stored in a dictionary where keys are the error names + # and values are the lists of cell indices with that error. + # We can initialize it directly from the keys of our error_masks dictionary. + invalid_cell_ids: dict[ str, list[ int ] ] = { error_name: list() for error_name in error_masks } + f = vtkCellValidator() + f.SetTolerance( min_distance ) f.SetInputData( mesh ) - f.Update() + f.Update() # executes the filter output = f.GetOutput() - validity = output.GetCellData().GetArray( "ValidityState" ) # Could not change name using the vtk interface. + validity = output.GetCellData().GetArray( "ValidityState" ) assert validity is not None + # array of np.int16 that combines the flags using a bitwise OR operation for each cell index. validity = vtk_to_numpy( validity ) - for i, v in enumerate( validity ): - if not v & valid: - if v & wrong_number_of_points: - wrong_number_of_points_elements.append( i ) - if v & intersecting_edges: - intersecting_edges_elements.append( i ) - if v & intersecting_faces: - intersecting_faces_elements.append( i ) - if v & non_contiguous_edges: - non_contiguous_edges_elements.append( i ) - if v & non_convex: - non_convex_elements.append( i ) - if v & faces_are_oriented_incorrectly: - faces_are_oriented_incorrectly_elements.append( i ) - return Result( wrong_number_of_points_elements=wrong_number_of_points_elements, - intersecting_edges_elements=intersecting_edges_elements, - intersecting_faces_elements=intersecting_faces_elements, - non_contiguous_edges_elements=non_contiguous_edges_elements, - non_convex_elements=non_convex_elements, - faces_are_oriented_incorrectly_elements=faces_are_oriented_incorrectly_elements ) + for cell_index, validity_flag in enumerate( validity ): + if not validity_flag: # Skip valid cells ( validity_flag == 0 or 0000 0000 ) + continue + for error_name, error_mask in error_masks.items(): # Check only invalid cells against all possible errors. + if validity_flag & error_mask: + invalid_cell_ids[ error_name ].append( cell_index ) + + return invalid_cell_ids + + +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + invalid_cell_ids: dict[ str, list[ int ] ] = get_invalid_cell_ids( mesh, options.min_distance ) + return Result( invalid_cell_ids ) def action( vtk_input_file: str, options: Options ) -> Result: - mesh = read_mesh( vtk_input_file ) - return __action( mesh, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/actions/supported_elements.py b/geos-mesh/src/geos/mesh/doctor/actions/supported_elements.py index 8d9fd46aa..52e42d4cf 100644 --- a/geos-mesh/src/geos/mesh/doctor/actions/supported_elements.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/supported_elements.py @@ -1,16 +1,17 @@ from dataclasses import dataclass import multiprocessing import networkx +from numpy import ones from tqdm import tqdm -from typing import FrozenSet, Iterable, Mapping, Optional +from typing import Iterable, Mapping, Optional from vtkmodules.util.numpy_support import vtk_to_numpy from vtkmodules.vtkCommonCore import vtkIdList from vtkmodules.vtkCommonDataModel import ( vtkCellTypes, vtkUnstructuredGrid, VTK_HEXAGONAL_PRISM, VTK_HEXAHEDRON, - VTK_PENTAGONAL_PRISM, VTK_POLYHEDRON, VTK_PYRAMID, VTK_TETRA, VTK_VOXEL, - VTK_WEDGE ) + VTK_LINE, VTK_PENTAGONAL_PRISM, VTK_POLYGON, VTK_POLYHEDRON, VTK_PYRAMID, + VTK_QUAD, VTK_TETRA, VTK_TRIANGLE, VTK_VERTEX, VTK_VOXEL, VTK_WEDGE ) from geos.mesh.doctor.actions.vtk_polyhedron import build_face_to_face_connectivity_through_edges, FaceStream from geos.mesh.doctor.parsing.cli_parsing import setup_logger -from geos.mesh.io.vtkIO import read_mesh +from geos.mesh.io.vtkIO import read_unstructured_grid from geos.mesh.utils.genericHelpers import vtk_iter @@ -22,32 +23,26 @@ class Options: @dataclass( frozen=True ) class Result: - unsupported_std_elements_types: FrozenSet[ int ] # list of unsupported types - unsupported_polyhedron_elements: FrozenSet[ + unsupported_std_elements_types: list[ str ] # list of unsupported types + unsupported_polyhedron_elements: frozenset[ int ] # list of polyhedron elements that could not be converted to supported std elements # for multiprocessing, vtkUnstructuredGrid cannot be pickled. Let's use a global variable instead. +# Global variable to be set in each worker process MESH: Optional[ vtkUnstructuredGrid ] = None -def init_worker_mesh( input_file_for_worker: str ): - """Initializer for multiprocessing.Pool to set the global MESH variable in each worker process. - - Args: - input_file_for_worker (str): Filepath to vtk grid - """ +def init_worker( mesh_to_init: vtkUnstructuredGrid ) -> None: + """Initializer for each worker process to set the global mesh.""" global MESH - setup_logger.debug( - f"Worker process (PID: {multiprocessing.current_process().pid}) initializing MESH from file: {input_file_for_worker}" - ) - MESH = read_mesh( input_file_for_worker ) - if MESH is None: - setup_logger.error( - f"Worker process (PID: {multiprocessing.current_process().pid}) failed to load mesh from {input_file_for_worker}" - ) - # You might want to raise an error here or ensure MESH being None is handled downstream - # For now, the assert MESH is not None in __call__ will catch this. + MESH = mesh_to_init + + +supported_cell_types: set[ int ] = { + VTK_HEXAGONAL_PRISM, VTK_HEXAHEDRON, VTK_LINE, VTK_PENTAGONAL_PRISM, VTK_POLYGON, VTK_POLYHEDRON, VTK_PYRAMID, + VTK_QUAD, VTK_TETRA, VTK_TRIANGLE, VTK_VERTEX, VTK_VOXEL, VTK_WEDGE +} class IsPolyhedronConvertible: @@ -58,11 +53,11 @@ def build_prism_graph( n: int, name: str ) -> networkx.Graph: """Builds the face to face connectivities (through edges) for prism graphs. Args: - n (int): The number of nodes of the basis (i.e. the pentagonal prims gets n = 5) - name (str): A human-readable name for logging purpose. + n (int): The number of nodes of the base (e.g., pentagonal prism gets n = 5). + name (str): A human-readable name for logging purposes. Returns: - networkx.Graph: A graph instance. + networkx.Graph: A graph instance representing the prism. """ tmp = networkx.cycle_graph( n ) for node in range( n ): @@ -91,33 +86,30 @@ def build_prism_graph( n: int, name: str ) -> networkx.Graph: def __is_polyhedron_supported( self, face_stream ) -> str: """Checks if a polyhedron can be converted into a supported cell. - If so, returns the name of the type. If not, the returned name will be empty. Args: - face_stream (_type_): The polyhedron. + face_stream: The polyhedron. Returns: str: The name of the supported type or an empty string. """ cell_graph = build_face_to_face_connectivity_through_edges( face_stream, add_compatibility=True ) - if cell_graph.order() not in self.__reference_graphs: - return "" for reference_graph in self.__reference_graphs[ cell_graph.order() ]: if networkx.is_isomorphic( reference_graph, cell_graph ): return str( reference_graph.name ) return "" def __call__( self, ic: int ) -> int: - """Checks if a vtk polyhedron cell can be converted into a supported GEOSX element. + """Check if a vtk polyhedron cell can be converted into a supported GEOS element. Args: - ic (int): The index element. + ic (int): The index of the element. Returns: - int: -1 if the polyhedron vtk element can be converted into a supported element type. The index otherwise. + int: -1 if the polyhedron vtk element can be converted into a supported element type, the index otherwise. """ global MESH - assert MESH is not None, f"MESH global variable not initialized in worker process (PID: {multiprocessing.current_process().pid}). This should have been set by init_worker_mesh." + assert MESH is not None if MESH.GetCellType( ic ) != VTK_POLYHEDRON: return -1 pt_ids = vtkIdList() @@ -128,50 +120,62 @@ def __call__( self, ic: int ) -> int: setup_logger.debug( f"Polyhedron cell {ic} can be converted into \"{converted_type_name}\"" ) return -1 else: - setup_logger.debug( - f"Polyhedron cell {ic} (in PID {multiprocessing.current_process().pid}) cannot be converted into any supported element." - ) + setup_logger.debug( f"Polyhedron cell {ic} cannot be converted into any supported element." ) return ic -def __action( vtk_input_file: str, options: Options ) -> Result: - # Main process loads the mesh for its own use - mesh = read_mesh( vtk_input_file ) - if mesh is None: - setup_logger.error( f"Main process failed to load mesh from {vtk_input_file}. Aborting." ) - # Return an empty/error result or raise an exception - return Result( unsupported_std_elements_types=frozenset(), unsupported_polyhedron_elements=frozenset() ) +def find_unsupported_std_elements_types( mesh: vtkUnstructuredGrid ) -> list[ str ]: + """Find unsupported standard element types in the mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. - if hasattr( mesh, "GetDistinctCellTypesArray" ): - cell_types_numpy = vtk_to_numpy( mesh.GetDistinctCellTypesArray() ) - cell_types = set( cell_types_numpy.tolist() ) + Returns: + list[ str ]: List of unsupported element type descriptions. + """ + if hasattr( mesh, "GetDistinctCellTypesArray" ): # For more recent versions of vtk. + unique_cell_types = set( vtk_to_numpy( mesh.GetDistinctCellTypesArray() ) ) else: - vtk_cell_types_obj = vtkCellTypes() - mesh.GetCellTypes( vtk_cell_types_obj ) - cell_types = set( vtk_iter( vtk_cell_types_obj ) ) + vtk_cell_types = vtkCellTypes() + mesh.GetCellTypes( vtk_cell_types ) + unique_cell_types = set( vtk_iter( vtk_cell_types ) ) + result_values: set[ int ] = unique_cell_types - supported_cell_types + results = [ f"Type {i}: {vtkCellTypes.GetClassNameFromTypeId( i )}" for i in frozenset( result_values ) ] + return results + + +def find_unsupported_polyhedron_elements( mesh: vtkUnstructuredGrid, options: Options ) -> list[ int ]: + """Find unsupported polyhedron elements in the mesh. - supported_cell_types = { - VTK_HEXAGONAL_PRISM, VTK_HEXAHEDRON, VTK_PENTAGONAL_PRISM, VTK_POLYHEDRON, VTK_PYRAMID, VTK_TETRA, VTK_VOXEL, - VTK_WEDGE - } - unsupported_std_elements_types = cell_types - supported_cell_types + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + options (Options): The options for processing. + Returns: + list[ int ]: List of element indices for unsupported polyhedrons. + """ # Dealing with polyhedron elements. - num_cells = mesh.GetNumberOfCells() - polyhedron_converter = IsPolyhedronConvertible() + num_cells: int = mesh.GetNumberOfCells() + result = ones( num_cells, dtype=int ) * -1 + # Use the initializer to set up each worker process + # Pass the mesh to the initializer + with multiprocessing.Pool( processes=options.nproc, initializer=init_worker, initargs=( mesh, ) ) as pool: + # Pass a mesh-free instance of the class to the workers. + # The MESH global will already be set in each worker. + generator = pool.imap_unordered( IsPolyhedronConvertible(), range( num_cells ), chunksize=options.chunk_size ) + for i, val in enumerate( tqdm( generator, total=num_cells, desc="Testing support for elements" ) ): + result[ i ] = val + + return [ i for i in result if i > -1 ] - unsupported_polyhedron_indices = [] - # Pass the vtk_input_file to the initializer - with multiprocessing.Pool( processes=options.nproc, initializer=init_worker_mesh, - initargs=( vtk_input_file, ) ) as pool: # Comma makes it a tuple - generator = pool.imap_unordered( polyhedron_converter, range( num_cells ), chunksize=options.chunk_size ) - for cell_index_or_neg_one in tqdm( generator, total=num_cells, desc="Testing support for elements" ): - if cell_index_or_neg_one != -1: - unsupported_polyhedron_indices.append( cell_index_or_neg_one ) - return Result( unsupported_std_elements_types=frozenset( unsupported_std_elements_types ), - unsupported_polyhedron_elements=frozenset( unsupported_polyhedron_indices ) ) +def mesh_action( mesh: vtkUnstructuredGrid, options: Options ) -> Result: + unsupported_std_elements_types: list[ str ] = find_unsupported_std_elements_types( mesh ) + unsupported_polyhedron_elements: list[ int ] = find_unsupported_polyhedron_elements( mesh, options ) + return Result( unsupported_std_elements_types=unsupported_std_elements_types, + unsupported_polyhedron_elements=frozenset( unsupported_polyhedron_elements ) ) def action( vtk_input_file: str, options: Options ) -> Result: - return __action( vtk_input_file, options ) + mesh: vtkUnstructuredGrid = read_unstructured_grid( vtk_input_file ) + return mesh_action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/Checks.py b/geos-mesh/src/geos/mesh/doctor/filters/Checks.py new file mode 100644 index 000000000..63c13f5bc --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/Checks.py @@ -0,0 +1,330 @@ +from types import SimpleNamespace +from typing import Any +from typing_extensions import Self +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.all_checks import Options, get_check_results +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing._shared_checks_parsing_logic import CheckFeature, display_results +from geos.mesh.doctor.parsing.all_checks_parsing import ( CHECK_FEATURES_CONFIG as cfc_all_checks, ORDERED_CHECK_NAMES + as ocn_all_checks ) +from geos.mesh.doctor.parsing.main_checks_parsing import ( CHECK_FEATURES_CONFIG as cfcMainChecks, ORDERED_CHECK_NAMES + as ocnMainChecks ) + +__doc__ = """ +Checks module performs comprehensive mesh validation checks on a vtkUnstructuredGrid. +This module contains AllChecks and MainChecks filters that run various quality checks including element validation, +node validation, topology checks, and geometric integrity verification. + +To use the AllChecks filter +--------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.Checks import AllChecks + + # instantiate the filter for all available checks + allChecksFilter = AllChecks(mesh) + + # optionally customize check parameters + allChecksFilter.setCheckParameter("collocated_nodes", "tolerance", 1e-6) + allChecksFilter.setAllChecksParameter("tolerance", 1e-6) # applies to all checks with tolerance parameter + + # execute the checks + success = allChecksFilter.applyFilter() + + # get check results + checkResults = allChecksFilter.getCheckResults() + + # get the processed mesh + output_mesh = allChecksFilter.getMesh() + +To use the MainChecks filter (subset of most important checks) +-------------------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.Checks import MainChecks + + # instantiate the filter for main checks only + mainChecksFilter = MainChecks(mesh) + + # execute the checks + success = mainChecksFilter.applyFilter() + + # get check results + checkResults = mainChecksFilter.getCheckResults() + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.Checks import allChecks, mainChecks + + # apply all checks directly + outputMesh, checkResults = allChecks( + mesh, + customParameters={"collocated_nodes": {"tolerance": 1e-6}} + ) + + # or apply main checks only + outputMesh, checkResults = mainChecks( + mesh, + customParameters={"element_volumes": {"minVolume": 0.0}} + ) +""" + +loggerTitle: str = "Mesh Doctor Checks Filter" + + +class Checks( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + checksToPerform: list[ str ], + checkFeaturesConfig: dict[ str, CheckFeature ], + orderedCheckNames: list[ str ], + useExternalLogger: bool = False, + ) -> None: + """Initialize the mesh doctor checks filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to check + checksToPerform (list[str]): List of check names to perform + checkFeaturesConfig (dict[str, CheckFeature]): Configuration for check features + orderedCheckNames (list[str]): Ordered list of available check names + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.checksToPerform: list[ str ] = checksToPerform + self.checkParameters: dict[ str, dict[ str, Any ] ] = {} # Custom parameters override + self.checkResults: dict[ str, Any ] = {} + self.checkFeaturesConfig: dict[ str, CheckFeature ] = checkFeaturesConfig + self.orderedCheckNames: list[ str ] = orderedCheckNames + + def applyFilter( self: Self ) -> bool: + """Apply the mesh validation checks. + + Returns: + bool: True if checks completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + # Build the options using the parsing logic structure + options = self._buildOptions() + self.checkResults = get_check_results( self.mesh, options ) + + # Display results using the standard display logic + resultsWrapper = SimpleNamespace( checkResults=self.checkResults ) + display_results( options, resultsWrapper ) + + self.logger.info( f"Performed {len(self.checkResults)} checks" ) + self.logger.info( f"The filter {self.logger.name} succeeded" ) + return True + + def getAvailableChecks( self: Self ) -> list[ str ]: + """Get the list of available check names. + + Returns: + list[str]: List of available check names + """ + return self.orderedCheckNames + + def getCheckResults( self: Self ) -> dict[ str, Any ]: + """Get the results of all performed checks. + + Returns: + dict[str, Any]: Dictionary mapping check names to their results. + """ + return self.checkResults + + def getDefaultParameters( self: Self, checkName: str ) -> dict[ str, Any ]: + """Get the default parameters for a specific check. + + Args: + checkName (str): Name of the check. + + Returns: + dict[str, Any]: Dictionary of default parameters. + """ + if checkName in self.checkFeaturesConfig: + return self.checkFeaturesConfig[ checkName ].default_params + return {} + + def setAllChecksParameter( self: Self, parameterName: str, value: Any ) -> None: + """Set a parameter for all checks that support it. + + Args: + parameterName (str): Name of the parameter (e.g., "tolerance") + value (Any): Value to set for the parameter + """ + for checkName in self.checksToPerform: + if checkName in self.checkFeaturesConfig: + defaultParams = self.checkFeaturesConfig[ checkName ].default_params + if parameterName in defaultParams: + self.setCheckParameter( checkName, parameterName, value ) + + def setCheckParameter( self: Self, checkName: str, parameterName: str, value: Any ) -> None: + """Set a parameter for a specific check. + + Args: + checkName (str): Name of the check (e.g., "collocated_nodes") + parameterName (str): Name of the parameter (e.g., "tolerance") + value (Any): Value to set for the parameter + """ + if checkName not in self.checkParameters: + self.checkParameters[ checkName ] = {} + self.checkParameters[ checkName ][ parameterName ] = value + + def setChecksToPerform( self: Self, checksToPerform: list[ str ] ) -> None: + """Set which checks to perform. + + Args: + checksToPerform (list[str]): List of check names to perform + """ + self.checksToPerform = checksToPerform + + def _buildOptions( self: Self ) -> Options: + """Build Options object using the same logic as the parsing system. + + Returns: + Options: Properly configured options for all checks. + """ + # Start with default parameters for all configured checks + defaultParams: dict[ str, dict[ str, Any ] ] = { + name: feature.default_params.copy() + for name, feature in self.checkFeaturesConfig.items() + } + finalCheckParams: dict[ str, dict[ str, Any ] ] = { + name: defaultParams[ name ] + for name in self.checksToPerform + } + + # Apply any custom parameter overrides + for checkName in self.checksToPerform: + if checkName in self.checkParameters: + finalCheckParams[ checkName ].update( self.checkParameters[ checkName ] ) + + # Instantiate Options objects for the selected checks + individualCheckOptions: dict[ str, Any ] = {} + individualCheckDisplay: dict[ str, Any ] = {} + + for checkName in self.checksToPerform: + if checkName not in self.checkFeaturesConfig: + self.logger.warning( f"Check '{checkName}' is not available. Skipping." ) + continue + + params = finalCheckParams[ checkName ] + featureConfig = self.checkFeaturesConfig[ checkName ] + try: + individualCheckOptions[ checkName ] = featureConfig.options_cls( **params ) + individualCheckDisplay[ checkName ] = featureConfig.display + except Exception as e: + self.logger.error( f"Failed to create options for check '{checkName}': {e}. " + f"This check will be skipped." ) + + return Options( checks_to_perform=list( individualCheckOptions.keys() ), + checks_options=individualCheckOptions, + check_displays=individualCheckDisplay ) + + +class AllChecks( Checks ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + useExternalLogger: bool = False, + ) -> None: + """Initialize the all_checks filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to check. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, + checksToPerform=ocn_all_checks, + checkFeaturesConfig=cfc_all_checks, + orderedCheckNames=ocn_all_checks, + useExternalLogger=useExternalLogger ) + + +class MainChecks( Checks ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + useExternalLogger: bool = False, + ) -> None: + """Initialize the main checks filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to check. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, + checksToPerform=ocnMainChecks, + checkFeaturesConfig=cfcMainChecks, + orderedCheckNames=ocnMainChecks, + useExternalLogger=useExternalLogger ) + + +# Main functions for backward compatibility and standalone use +def allChecks( + mesh: vtkUnstructuredGrid, + customParameters: dict[ str, dict[ str, Any ] ] = None ) -> tuple[ vtkUnstructuredGrid, dict[ str, Any ] ]: + """Apply all available mesh checks to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to check. + customParameters (dict[str, dict[str, Any]]): Custom parameters for checks. Defaults to None. + + Returns: + tuple[vtkUnstructuredGrid, dict[str, Any]]: + Processed mesh, check results + """ + filterInstance = AllChecks( mesh ) + + if customParameters: + for checkName, params in customParameters.items(): + for param_name, value in params.items(): + filterInstance.setCheckParameter( checkName, param_name, value ) + + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "allChecks calculation failed." ) + + return ( + filterInstance.getMesh(), + filterInstance.getCheckResults(), + ) + + +def mainChecks( + mesh: vtkUnstructuredGrid, + customParameters: dict[ str, dict[ str, Any ] ] = None ) -> tuple[ vtkUnstructuredGrid, dict[ str, Any ] ]: + """Apply main mesh checks to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to check. + customParameters (dict[str, dict[str, Any]]): Custom parameters for checks. Defaults to None. + + Returns: + tuple[vtkUnstructuredGrid, dict[str, Any]]: + Processed mesh, check results + """ + filterInstance = MainChecks( mesh ) + + if customParameters: + for checkName, params in customParameters.items(): + for param_name, value in params.items(): + filterInstance.setCheckParameter( checkName, param_name, value ) + + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "mainChecks calculation failed." ) + + return ( + filterInstance.getMesh(), + filterInstance.getCheckResults(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/CollocatedNodes.py b/geos-mesh/src/geos/mesh/doctor/filters/CollocatedNodes.py new file mode 100644 index 000000000..35d273426 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/CollocatedNodes.py @@ -0,0 +1,174 @@ +from typing_extensions import Self +import numpy as np +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.collocated_nodes import find_collocated_nodes_buckets, find_wrong_support_elements +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.collocated_nodes_parsing import logger_results + +__doc__ = """ +CollocatedNodes module identifies and handles duplicated/collocated nodes in a vtkUnstructuredGrid. +The filter can detect nodes that are within a specified tolerance distance and optionally identify elements +that have support nodes appearing more than once (wrong support elements). + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.CollocatedNodes import CollocatedNodes + + # instantiate the filter + collocatedNodesFilter = CollocatedNodes(mesh, tolerance=1e-6, writeWrongSupportElements=True) + + # execute the filter + success = collocatedNodesFilter.applyFilter() + + # get results + collocatedBuckets = collocatedNodesFilter.getCollocatedNodeBuckets() + wrongSupportElements = collocatedNodesFilter.getWrongSupportElements() + + # get the processed mesh + outputMesh = collocatedNodesFilter.getMesh() + + # write the output mesh + collocatedNodesFilter.writeGrid("output/mesh_with_collocated_info.vtu") + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.CollocatedNodes import collocatedNodes + + # apply filter directly + outputMesh, collocatedBuckets, wrongSupportElements = collocatedNodes( + mesh, + outputPath="output/mesh_with_collocated_info.vtu", + tolerance=1e-6, + writeWrongSupportElements=True + ) +""" + +loggerTitle: str = "Collocated Nodes Filter" + + +class CollocatedNodes( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + tolerance: float = 0.0, + writeWrongSupportElements: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the collocated nodes filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + tolerance (float): Distance tolerance for detecting collocated nodes. Defaults to 0.0. + writeWrongSupportElements (bool): Whether to mark wrong support elements in output. Defaults to False. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.tolerance: float = tolerance + self.writeWrongSupportElements: bool = writeWrongSupportElements + self.collocatedNodeBuckets: list[ tuple[ int ] ] = [] + self.wrongSupportElements: list[ int ] = [] + + def applyFilter( self: Self ) -> bool: + """Apply the collocated nodes analysis. + + Returns: + bool: True if analysis completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + self.collocatedNodeBuckets: list[ tuple[ int ] ] = find_collocated_nodes_buckets( self.mesh, self.tolerance ) + self.wrongSupportElements: list[ int ] = find_wrong_support_elements( self.mesh ) + + # Add marking arrays if requested + if self.writeWrongSupportElements and self.wrongSupportElements: + self._addWrongSupportElementsArray() + + logger_results( self.logger, self.collocatedNodeBuckets, self.wrongSupportElements ) + + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getCollocatedNodeBuckets( self: Self ) -> list[ tuple[ int ] ]: + """Returns the nodes buckets that contain the duplicated node indices. + + Returns: + list[tuple[int]]: Groups of collocated node indices. + """ + return self.collocatedNodeBuckets + + def getWrongSupportElements( self: Self ) -> list[ int ]: + """Returns the element indices with support node indices appearing more than once. + + Returns: + list[int]: Element indices with problematic support nodes. + """ + return self.wrongSupportElements + + def setTolerance( self: Self, tolerance: float ) -> None: + """Set the tolerance parameter to define if two points are collocated or not. + + Args: + tolerance (float): Distance tolerance. + """ + self.tolerance = tolerance + + def setWriteWrongSupportElements( self: Self, write: bool ) -> None: + """Set whether to create arrays marking wrong support elements in output data. + + Args: + write (bool): True to enable marking, False to disable. + """ + self.writeWrongSupportElements = write + + def _addWrongSupportElementsArray( self: Self ) -> None: + """Add array marking wrong support elements.""" + numCells: int = self.mesh.GetNumberOfCells() + wrongSupportArray = np.zeros( numCells, dtype=np.int32 ) + wrongSupportArray[ self.wrongSupportElements ] = 1 + + vtkArray: vtkDataArray = numpy_to_vtk( wrongSupportArray ) + vtkArray.SetName( "WrongSupportElements" ) + self.mesh.GetCellData().AddArray( vtkArray ) + + +# Main function for standalone use +def collocatedNodes( + mesh: vtkUnstructuredGrid, + outputPath: str, + tolerance: float = 0.0, + writeWrongSupportElements: bool = False, +) -> tuple[ vtkUnstructuredGrid, list[ tuple[ int ] ], list[ int ] ]: + """Apply collocated nodes analysis to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + outputPath (str): Output file path if writeOutput is True. + tolerance (float): Distance tolerance for detecting collocated nodes. Defaults to 0.0. + writeWrongSupportElements (bool): Whether to mark wrong support elements. Defaults to False. + writeOutput (bool): Whether to write output mesh to file. Defaults to False. + + Returns: + tuple[vtkUnstructuredGrid, list[tuple[int]], list[int]]: + Processed mesh, collocated node buckets, wrong support elements. + """ + filterInstance = CollocatedNodes( mesh, tolerance, writeWrongSupportElements ) + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "Element volumes calculation failed." ) + + filterInstance.writeGrid( outputPath ) + + return ( + filterInstance.getMesh(), + filterInstance.getCollocatedNodeBuckets(), + filterInstance.getWrongSupportElements(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/ElementVolumes.py b/geos-mesh/src/geos/mesh/doctor/filters/ElementVolumes.py new file mode 100644 index 000000000..eac41495a --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/ElementVolumes.py @@ -0,0 +1,184 @@ +import numpy as np +import numpy.typing as npt +from typing_extensions import Self +from vtkmodules.util.numpy_support import numpy_to_vtk, vtk_to_numpy +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.element_volumes import get_mesh_quality, get_mesh_volume, SUPPORTED_TYPES +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.element_volumes_parsing import logger_results + +__doc__ = """ +ElementVolumes module calculates the volumes of all elements in a vtkUnstructuredGrid. +The filter can identify elements with volume inferior to a specified threshold (usually 0.0), which typically indicate +mesh quality issues such as inverted elements or degenerate cells. + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.ElementVolumes import ElementVolumes + + # instantiate the filter + elementVolumesFilter = ElementVolumes(mesh, minVolume=0.0, writeIsBelowVolume=True) + + # execute the filter + success = elementVolumesFilter.applyFilter() + + # get problematic elements + invalidVolumes = elementVolumesFilter.getInvalidVolumes() + # returns the list of tuples (element index, volume) + + # get the processed mesh with volume information + outputMesh = elementVolumesFilter.getMesh() + + # write the output mesh with volume information + elementVolumesFilter.writeGrid("output/mesh_with_volumes.vtu") + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.ElementVolumes import elementVolumes + + # apply filter directly + outputMesh, volumes, belowVolumes = elementVolumes( + mesh, + outputPath="output/mesh_with_volumes.vtu", + minVolume=0.0, + writeIsBelowVolume=True + ) +""" + +loggerTitle: str = "Element Volumes Filter" + + +class ElementVolumes( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + minVolume: float = 0.0, + writeIsBelowVolume: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the element volumes filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + minVolume (float): Minimum volume threshold for elements. Defaults to 0.0. + writeIsBelowVolume (bool): Whether to add new CellData array with values 0 or 1 if below the minimum volume. + Defaults to False. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.minVolume: float = minVolume + self.volumes: vtkDataArray = None + self.belowVolumes: list[ tuple[ int, float ] ] = [] + self.writeIsBelowVolume: bool = writeIsBelowVolume + + def applyFilter( self: Self ) -> bool: + """Apply the element volumes calculation. + + Returns: + bool: True if calculation completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + volume: vtkDataArray = get_mesh_volume( self.mesh ) + if not volume: + self.logger.error( "Volume computation failed." ) + return False + + quality: vtkDataArray = get_mesh_quality( self.mesh ) + if not quality: + self.logger.error( "Quality computation failed." ) + return False + + volume = vtk_to_numpy( volume ) + quality = vtk_to_numpy( quality ) + self.belowVolumes = [] + for i, pack in enumerate( zip( volume, quality ) ): + v, q = pack + vol = q if self.mesh.GetCellType( i ) in SUPPORTED_TYPES else v + if vol < self.minVolume: + self.belowVolumes.append( ( i, float( vol ) ) ) + + logger_results( self.logger, self.belowVolumes ) + + if self.writeIsBelowVolume and self.belowVolumes: + self._addBelowVolumeArray() + + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getBelowVolumes( self: Self ) -> list[ tuple[ int, float ] ]: + """Get the list of volumes below the minimum threshold. + + Returns: + list[ tuple[ int, float ] ]: List of tuples containing element index and volume. + """ + return self.belowVolumes + + def getVolumes( self: Self ) -> vtkDataArray: + """Get the computed volume array. + + Returns: + vtkDataArray: The volume data array, or None if not computed yet. + """ + return self.volumes + + def setWriteIsBelowVolume( self: Self, write: bool ) -> None: + """Set whether to write elements below the volume threshold. + + Args: + write (bool): True to enable writing, False to disable. + """ + self.writeIsBelowVolume = write + + def _addBelowVolumeArray( self: Self ) -> None: + """Add an array marking elements below the minimum volume threshold on the mesh.""" + self.logger.info( "Adding CellData array marking elements below the minimum volume threshold." ) + numCells = self.mesh.GetNumberOfCells() + belowVolumeArray = np.zeros( numCells, dtype=np.int32 ) + belowVolumeArray[ [ i for i, _ in self.belowVolumes ] ] = 1 + + vtkArray: vtkDataArray = numpy_to_vtk( belowVolumeArray ) + vtkArray.SetName( "BelowVolumeThresholdOf" + str( self.minVolume ) ) + self.mesh.GetCellData().AddArray( vtkArray ) + + +# Main function for standalone use +def elementVolumes( + mesh: vtkUnstructuredGrid, + outputPath: str, + minVolume: float = 0.0, + writeIsBelowVolume: bool = False, +) -> tuple[ vtkUnstructuredGrid, npt.NDArray, list[ tuple[ int, float ] ] ]: + """Apply element volumes calculation to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + minVolume (float): Minimum volume threshold for elements. Defaults to 0.0. + writeIsBelowVolume (bool): Whether to write elements below the volume threshold. Defaults to False. + writeOutput (bool): Whether to write output mesh to file. Defaults to False. + outputPath (str): Output file path if writeOutput is True. + + Returns: + tuple[vtkUnstructuredGrid, npt.NDArray, list[ tuple[ int, float ] ]]: + Processed mesh, array of volumes, list of volumes below the threshold. + """ + filterInstance = ElementVolumes( mesh, minVolume, writeIsBelowVolume ) + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "Element volumes calculation failed." ) + + filterInstance.writeGrid( outputPath ) + + return ( + filterInstance.getMesh(), + filterInstance.getVolumes(), + filterInstance.getBelowVolumes(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/GenerateFractures.py b/geos-mesh/src/geos/mesh/doctor/filters/GenerateFractures.py new file mode 100644 index 000000000..9c9c83384 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/GenerateFractures.py @@ -0,0 +1,281 @@ +from typing_extensions import Self +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.generate_fractures import Options, split_mesh_on_fractures +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.generate_fractures_parsing import convert, convert_to_fracture_policy +from geos.mesh.doctor.parsing.generate_fractures_parsing import ( __FIELD_NAME, __FIELD_VALUES, __FRACTURES_DATA_MODE, + __FRACTURES_OUTPUT_DIR, __POLICIES, __POLICY ) +from geos.mesh.doctor.parsing.vtk_output_parsing import __OUTPUT_BINARY_MODE_VALUES, __OUTPUT_FILE +from geos.mesh.io.vtkIO import VtkOutput, write_mesh +from geos.mesh.utils.arrayHelpers import has_array + +__doc__ = """ +GenerateFractures module splits a vtkUnstructuredGrid along non-embedded fractures. +When a fracture plane is defined between two cells, the nodes of the shared face will be duplicated +to create a discontinuity. The filter generates both the split main mesh and separate fracture meshes. + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.GenerateFractures import GenerateFractures + + # instantiate the filter + generateFracturesFilter = GenerateFractures( + mesh, + policy=1, + fieldName="fracture_field", + fieldValues="1,2", + fracturesOutputDir="./fractures/", + outputDataMode=0, + fracturesDataMode=1 + ) + + # execute the filter + success = generateFracturesFilter.applyFilter() + + # get the results + splitMesh = generateFracturesFilter.getMesh() + fractureMeshes = generateFracturesFilter.getFractureMeshes() + + # write all meshes + generateFracturesFilter.writeMeshes("output/split_mesh.vtu", isDataModeBinary=True) + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.GenerateFractures import generateFractures + + # apply fracture generation directly + splitMesh, fractureMeshes = generateFractures( + mesh, + outputPath="output/split_mesh.vtu", + policy=1, + fieldName="fracture_field", + fieldValues="1,2", + fracturesOutputDir="./fractures/", + outputDataMode=0, + fracturesDataMode=1 + ) +""" + +FIELD_NAME = __FIELD_NAME +FIELD_VALUES = __FIELD_VALUES +FRACTURES_DATA_MODE = __FRACTURES_DATA_MODE +FRACTURES_OUTPUT_DIR = __FRACTURES_OUTPUT_DIR +POLICIES = __POLICIES +POLICY = __POLICY +OUTPUT_BINARY_MODE = "data_mode" +OUTPUT_BINARY_MODE_VALUES = __OUTPUT_BINARY_MODE_VALUES +OUTPUT_FILE = __OUTPUT_FILE + +loggerTitle: str = "Generate Fractures Filter" + + +class GenerateFractures( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + policy: int = 1, + fieldName: str = None, + fieldValues: str = None, + fracturesOutputDir: str = None, + outputDataMode: int = 0, + fracturesDataMode: int = 0, + useExternalLogger: bool = False, + ) -> None: + """Initialize the generate fractures filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to split. + policy (int): Fracture policy (0 for field, 1 for internal_surfaces). Defaults to 1. + fieldName (str): Field name that defines fracture regions. Defaults to None. + fieldValues (str): Comma-separated field values that identify fracture boundaries. Defaults to None. + fracturesOutputDir (str): Output directory for fracture meshes. Defaults to None. + outputDataMode (int): Data mode for main mesh (0 for binary, 1 for ASCII). Defaults to 0. + fracturesDataMode (int): Data mode for fracture meshes (0 for binary, 1 for ASCII). Defaults to 0. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + if outputDataMode not in [ 0, 1 ]: + self.logger.error( f"Invalid output data mode: {outputDataMode}. Must be 0 (binary) or 1 (ASCII)." + " Set to 0 (binary)." ) + outputDataMode = 0 + if fracturesDataMode not in [ 0, 1 ]: + self.logger.error( f"Invalid fractures data mode: {fracturesDataMode}. Must be 0 (binary) or 1 (ASCII)." + " Set to 0 (binary)." ) + fracturesDataMode = 0 + + self.allOptions: dict[ str, str ] = { + POLICY: convert_to_fracture_policy( POLICIES[ policy ] ), + FIELD_NAME: fieldName, + FIELD_VALUES: fieldValues, + OUTPUT_FILE: "./mesh.vtu", + OUTPUT_BINARY_MODE: OUTPUT_BINARY_MODE_VALUES[ outputDataMode ], + FRACTURES_OUTPUT_DIR: fracturesOutputDir, + FRACTURES_DATA_MODE: OUTPUT_BINARY_MODE_VALUES[ fracturesDataMode ] + } + self.fractureMeshes: list[ vtkUnstructuredGrid ] = [] + self.mesh: vtkUnstructuredGrid = mesh + self._options: Options = None + + def applyFilter( self: Self ) -> bool: + """Apply the fracture generation. + + Returns: + bool: True if fractures generated successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}." ) + + # Check for global IDs which are not allowed + if has_array( self.mesh, [ "GLOBAL_IDS_POINTS", "GLOBAL_IDS_CELLS" ] ): + self.logger.error( + "The mesh cannot contain global ids for neither cells nor points." + " The correct procedure is to split the mesh and then generate global ids for new split meshes." ) + return False + + try: + self._options = convert( self.allOptions ) + except Exception as e: + self.logger.error( f"Failed to convert options: {e}.\nCannot generate fractures." + "You must set all variables before trying to create fractures." ) + return False + + # Perform the fracture generation + self.mesh, self.fractureMeshes = split_mesh_on_fractures( self.mesh, self._options ) + + self.logger.info( f"Generated {len(self.fractureMeshes)} fracture meshes." ) + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getAllGrids( self: Self ) -> tuple[ vtkUnstructuredGrid, list[ vtkUnstructuredGrid ] ]: + """Get both the split main mesh and fracture meshes. + + Returns: + tuple[vtkUnstructuredGrid, list[vtkUnstructuredGrid]]: Split mesh and fracture meshes. + """ + return ( self.mesh, self.fractureMeshes ) + + def getFractureMeshes( self: Self ) -> list[ vtkUnstructuredGrid ]: + """Get the generated fracture meshes. + + Returns: + list[vtkUnstructuredGrid]: List of fracture meshes. + """ + return self.fractureMeshes + + def setFieldName( self: Self, fieldName: str ) -> None: + """Set the field name that defines fracture regions. + + Args: + fieldName (str): Name of the field. + """ + self.allOptions[ FIELD_NAME ] = fieldName + + def setFieldValues( self: Self, fieldValues: str ) -> None: + """Set the field values that identify fracture boundaries. + + Args: + fieldValues (str): Comma-separated field values. + """ + self.allOptions[ FIELD_VALUES ] = fieldValues + + def setFracturesDataMode( self: Self, choice: int ) -> None: + """Set the data mode for fracture mesh outputs. + + Args: + choice (int): 0 for ASCII, 1 for binary. + """ + if choice not in [ 0, 1 ]: + self.logger.error( f"setFracturesDataMode: Please choose either 0 for {OUTPUT_BINARY_MODE_VALUES[0]} " + f"or 1 for {OUTPUT_BINARY_MODE_VALUES[1]}, not '{choice}'." ) + else: + self.allOptions[ FRACTURES_DATA_MODE ] = OUTPUT_BINARY_MODE_VALUES[ choice ] + + def setFracturesOutputDirectory( self: Self, directory: str ) -> None: + """Set the output directory for fracture meshes. + + Args: + directory (str): Directory path. + """ + self.allOptions[ FRACTURES_OUTPUT_DIR ] = directory + + def setOutputDataMode( self: Self, choice: int ) -> None: + """Set the data mode for the main mesh output. + + Args: + choice (int): 0 for ASCII, 1 for binary. + """ + if choice not in [ 0, 1 ]: + self.logger.error( f"setOutputDataMode: Please choose either 0 for {OUTPUT_BINARY_MODE_VALUES[0]} or 1 for" + f" {OUTPUT_BINARY_MODE_VALUES[1]}, not '{choice}'." ) + else: + self.allOptions[ OUTPUT_BINARY_MODE ] = OUTPUT_BINARY_MODE_VALUES[ choice ] + + def setPolicy( self: Self, choice: int ) -> None: + """Set the fracture policy. + + Args: + choice (int): 0 for field, 1 for internal surfaces. + """ + if choice not in [ 0, 1 ]: + self.logger.error( + f"setPolicy: Please choose either 0 for {POLICIES[0]} or 1 for {POLICIES[1]}, not '{choice}'." ) + else: + self.policy = convert_to_fracture_policy( POLICIES[ choice ] ) + + def writeMeshes( self: Self, filepath: str, isDataModeBinary: bool = True, canOverwrite: bool = False ) -> None: + """Write both the split main mesh and all fracture meshes. + + Args: + filepath (str): Path for the main split mesh. + isDataModeBinary (bool): Whether to use binary format for main mesh. Defaults to True. + canOverwrite (bool): Whether to allow overwriting existing files. Defaults to False. + """ + if self.mesh: + write_mesh( self.mesh, VtkOutput( filepath, isDataModeBinary ), canOverwrite ) + else: + self.logger.error( f"No output grid was built. Cannot output vtkUnstructuredGrid at {filepath}." ) + + for i, fractureMesh in enumerate( self.fractureMeshes ): + write_mesh( fractureMesh, self._options.all_fractures_VtkOutput[ i ] ) + + +# Main function for standalone use +def generateFractures( mesh: vtkUnstructuredGrid, + outputPath: str, + policy: int = 1, + fieldName: str = None, + fieldValues: str = None, + fracturesOutputDir: str = None, + outputDataMode: int = 0, + fracturesDataMode: int = 0 ) -> tuple[ vtkUnstructuredGrid, list[ vtkUnstructuredGrid ] ]: + """Apply fracture generation to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh. + outputPath (str): Output file path if write_output is True. + policy (int): Fracture policy (0 for internal, 1 for boundary). Defaults to 1. + fieldName (str): Field name that defines fracture regions. Defaults to None. + fieldValues (str): Comma-separated field values that identify fracture boundaries. Defaults to None. + fracturesOutputDir (str): Output directory for fracture meshes. Defaults to None. + outputDataMode (int): Data mode for main mesh (0 for binary, 1 for ASCII). Defaults to 0. + fracturesDataMode (int): Data mode for fracture meshes (0 for binary, 1 for ASCII). Defaults to 0. + + Returns: + tuple[vtkUnstructuredGrid, list[vtkUnstructuredGrid]]: + Split mesh and fracture meshes. + """ + filterInstance = GenerateFractures( mesh, policy, fieldName, fieldValues, fracturesOutputDir, outputDataMode, + fracturesDataMode ) + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "Fracture generation failed." ) + + filterInstance.writeMeshes( outputPath ) + + return filterInstance.getAllGrids() diff --git a/geos-mesh/src/geos/mesh/doctor/filters/GenerateRectilinearGrid.py b/geos-mesh/src/geos/mesh/doctor/filters/GenerateRectilinearGrid.py new file mode 100644 index 000000000..008a0f99f --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/GenerateRectilinearGrid.py @@ -0,0 +1,246 @@ +import numpy.typing as npt +from typing import Iterable, Sequence +from typing_extensions import Self +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.generate_global_ids import build_global_ids +from geos.mesh.doctor.actions.generate_cube import FieldInfo, add_fields, build_coordinates, build_rectilinear_grid +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorGeneratorBase + +__doc__ = """ +GenerateRectilinearGrid module is a vtk filter that allows to create a simple vtkUnstructuredGrid rectilinear grid. +GlobalIds for points and cells can be added. +You can create CellArray and PointArray of constant value = 1 and any dimension >= 1. + +No filter input and one filter output which is vtkUnstructuredGrid. + +To use the filter +----------------- + +.. code-block:: python + + from filters.GenerateRectilinearGrid import GenerateRectilinearGrid + + # instantiate the filter + generateRectilinearGridFilter: GenerateRectilinearGrid = GenerateRectilinearGrid() + + # set the coordinates of each block border for the X, Y and Z axis + generateRectilinearGridFilter.setCoordinates( [ 0.0, 5.0, 10.0 ], [ 0.0, 5.0, 10.0 ], [ 0.0, 10.0 ] ) + + # for each block defined, specify the number of cells that they should contain in the X, Y, Z axis + generateRectilinearGridFilter.setNumberElements( [ 5, 5 ], [ 5, 5 ], [ 10 ] ) + + # to add the GlobalIds for cells and points, set to True the generate global ids options + generateRectilinearGridFilter.setGenerateCellsGlobalIds( True ) + generateRectilinearGridFilter.setGeneratePointsGlobalIds( True ) + + # to create new arrays with a specific dimension, you can use the following commands + cells_dim1 = FieldInfo( "cell1", 1, "CELLS" ) # array "cell1" of shape ( number of cells, 1 ) + cells_dim3 = FieldInfo( "cell3", 3, "CELLS" ) # array "cell3" of shape ( number of cells, 3 ) + points_dim1 = FieldInfo( "point1", 1, "POINTS" ) # array "point1" of shape ( number of points, 1 ) + points_dim3 = FieldInfo( "point3", 3, "POINTS" ) # array "point3" of shape ( number of points, 3 ) + generateRectilinearGridFilter.setFields( [ cells_dim1, cells_dim3, points_dim1, points_dim3 ] ) + + # execute the filter + success = elementVolumesFilter.applyFilter() + + # get the generated mesh + outputMesh = generateRectilinearGridFilter.getGrid() + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from filters.GenerateRectilinearGrid import generateRectilinearGrid, FieldInfo + + # generate grid directly + outputMesh = generateRectilinearGrid( + coordsX=[0.0, 5.0, 10.0], + coordsY=[0.0, 5.0, 10.0], + coordsZ=[0.0, 10.0], + numberElementsX=[5, 5], + numberElementsY=[5, 5], + numberElementsZ=[10], + outputPath="output/rectilinear_grid.vtu", + fields=[FieldInfo("cell1", 1, "CELLS")], + generateCellsGlobalIds=True, + generatePointsGlobalIds=True + ) +""" + +loggerTitle: str = "Generate Rectilinear Grid" + + +class GenerateRectilinearGrid( MeshDoctorGeneratorBase ): + + def __init__( + self: Self, + generateCellsGlobalIds: bool = False, + generatePointsGlobalIds: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the rectilinear grid generator. + + Args: + generateCellsGlobalIds (bool): Whether to generate global cell IDs. Defaults to False. + generatePointsGlobalIds (bool): Whether to generate global point IDs. Defaults to False. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( loggerTitle, useExternalLogger ) + self.generateCellsGlobalIds: bool = generateCellsGlobalIds + self.generatePointsGlobalIds: bool = generatePointsGlobalIds + self.coordsX: Sequence[ float ] = None + self.coordsY: Sequence[ float ] = None + self.coordsZ: Sequence[ float ] = None + self.numberElementsX: Sequence[ int ] = None + self.numberElementsY: Sequence[ int ] = None + self.numberElementsZ: Sequence[ int ] = None + self.number_elements_z: Sequence[ int ] = None + self.fields: Iterable[ FieldInfo ] = list() + + def applyFilter( self: Self ) -> bool: + """Generate the rectilinear grid. + + Returns: + bool: True if grid generated successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + try: + # Validate inputs + required_fields = [ + self.coordsX, self.coordsY, self.coordsZ, self.numberElementsX, self.numberElementsY, + self.numberElementsZ + ] + if any( field is None for field in required_fields ): + self.logger.error( "Coordinates and number of elements must be set before generating grid." ) + return False + + # Build coordinates + x: npt.NDArray = build_coordinates( self.coordsX, self.numberElementsX ) + y: npt.NDArray = build_coordinates( self.coordsY, self.numberElementsY ) + z: npt.NDArray = build_coordinates( self.coordsZ, self.numberElementsZ ) + + # Build the rectilinear grid + self.mesh = build_rectilinear_grid( x, y, z ) + + # Add fields if specified + if self.fields: + self.mesh = add_fields( self.mesh, self.fields ) + + # Add global IDs if requested + build_global_ids( self.mesh, self.generateCellsGlobalIds, self.generatePointsGlobalIds ) + + self.logger.info( f"Generated rectilinear grid with {self.mesh.GetNumberOfPoints()} points " + f"and {self.mesh.GetNumberOfCells()} cells." ) + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + except Exception as e: + self.logger.error( f"Error in rectilinear grid generation: {e}" ) + self.logger.error( f"The filter {self.logger.name} failed." ) + return False + + def setCoordinates( + self: Self, + coordsX: Sequence[ float ], + coordsY: Sequence[ float ], + coordsZ: Sequence[ float ], + ) -> None: + """Set the coordinates of the block boundaries for the grid along X, Y and Z axis. + + Args: + coordsX (Sequence[float]): Block boundary coordinates along X axis. + coordsY (Sequence[float]): Block boundary coordinates along Y axis. + coordsZ (Sequence[float]): Block boundary coordinates along Z axis. + """ + self.coordsX = coordsX + self.coordsY = coordsY + self.coordsZ = coordsZ + + def setFields( self: Self, fields: Iterable[ FieldInfo ] ) -> None: + """Set the fields (arrays) to be added to the grid. + + Args: + fields (Iterable[FieldInfo]): Field information for arrays to create. + """ + self.fields = fields + + def setGenerateCellsGlobalIds( self: Self, generate: bool ) -> None: + """Set whether to generate global cell IDs. + + Args: + generate (bool): True to generate global cell IDs, False otherwise. + """ + self.generateCellsGlobalIds = generate + + def setGeneratePointsGlobalIds( self: Self, generate: bool ) -> None: + """Set whether to generate global point IDs. + + Args: + generate (bool): True to generate global point IDs, False otherwise. + """ + self.generatePointsGlobalIds = generate + + def setNumberElements( + self: Self, + numberElementsX: Sequence[ int ], + numberElementsY: Sequence[ int ], + numberElementsZ: Sequence[ int ], + ) -> None: + """Set the number of elements for each block along X, Y and Z axis. + + Args: + numberElementsX (Sequence[int]): Number of elements per block along X axis. + numberElementsY (Sequence[int]): Number of elements per block along Y axis. + numberElementsZ (Sequence[int]): Number of elements per block along Z axis. + """ + self.numberElementsX = numberElementsX + self.numberElementsY = numberElementsY + self.numberElementsZ = numberElementsZ + + +# Main function for standalone use +def generateRectilinearGrid( + coordsX: Sequence[ float ], + coordsY: Sequence[ float ], + coordsZ: Sequence[ float ], + numberElementsX: Sequence[ int ], + numberElementsY: Sequence[ int ], + numberElementsZ: Sequence[ int ], + outputPath: str, + fields: Iterable[ FieldInfo ] = None, + generateCellsGlobalIds: bool = False, + generatePointsGlobalIds: bool = False, +) -> vtkUnstructuredGrid: + """Generate a rectilinear grid mesh. + + Args: + coordsX (Sequence[float]): Block boundary coordinates along X axis. + coordsY (Sequence[float]): Block boundary coordinates along Y axis. + coordsZ (Sequence[float]): Block boundary coordinates along Z axis. + numberElementsX (Sequence[int]): Number of elements per block along X axis. + numberElementsY (Sequence[int]): Number of elements per block along Y axis. + numberElementsZ (Sequence[int]): Number of elements per block along Z axis. + outputPath (str): Output file path if write_output is True. + fields (Iterable[FieldInfo]): Field information for arrays to create. Defaults to None. + generateCellsGlobalIds (bool): Whether to generate global cell IDs. Defaults to False. + generatePointsGlobalIds (bool): Whether to generate global point IDs. Defaults to False. + + Returns: + vtkUnstructuredGrid: The generated mesh. + """ + filterInstance = GenerateRectilinearGrid( generateCellsGlobalIds, generatePointsGlobalIds ) + filterInstance.setCoordinates( coordsX, coordsY, coordsZ ) + filterInstance.setNumberElements( numberElementsX, numberElementsY, numberElementsZ ) + + if fields: + filterInstance.setFields( fields ) + + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "Rectilinear grid generation failed." ) + + filterInstance.writeGrid( outputPath ) + + return filterInstance.getMesh() diff --git a/geos-mesh/src/geos/mesh/doctor/filters/MeshDoctorFilterBase.py b/geos-mesh/src/geos/mesh/doctor/filters/MeshDoctorFilterBase.py new file mode 100644 index 000000000..e6bf682be --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/MeshDoctorFilterBase.py @@ -0,0 +1,242 @@ +from typing_extensions import Self +from typing import Union +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.utils.Logger import getLogger, Logger +from geos.mesh.io.vtkIO import VtkOutput, write_mesh + +__doc__ = """ +MeshDoctorFilterBase module provides base classes for all mesh doctor filters using direct mesh manipulation. + +MeshDoctorFilterBase serves as the foundation class for filters that process existing meshes, +while MeshDoctorGenerator is for filters that generate new meshes from scratch. + +These base classes provide common functionality including: +- Logger management and setup +- Mesh access and manipulation methods +- File I/O operations for writing VTK unstructured grids +- Consistent interface across all mesh doctor filters + +Unlike the VTK pipeline-based MeshDoctorBase, these classes work with direct mesh manipulation +following the FillPartialArrays pattern for simpler, more Pythonic usage. + +Example usage patterns +---------------------- + +.. code-block:: python + + # For filters that process existing meshes + from filters.MeshDoctorFilterBase import MeshDoctorFilterBase + + class MyProcessingFilter(MeshDoctorFilterBase): + def __init__(self, mesh, parameter1=default_value): + super().__init__(mesh, "My Filter Name") + self.parameter1 = parameter1 + + def applyFilter(self): + # Process self.mesh directly + # Return True on success, False on failure + pass + + # For filters that generate meshes from scratch + from filters.MeshDoctorFilterBase import MeshDoctorGeneratorBase + + class MyGeneratorFilter(MeshDoctorGeneratorBase): + def __init__(self, parameter1=default_value): + super().__init__("My Generator Name") + self.parameter1 = parameter1 + + def applyFilter(self): + # Generate new mesh and assign to self.mesh + # Return True on success, False on failure + pass +""" + + +class MeshDoctorFilterBase: + """Base class for all mesh doctor filters using direct mesh manipulation.""" + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + filterName: str, + useExternalLogger: bool = False, + ) -> None: + """Initialize the base mesh doctor filter.""" + # Check the 'mesh' input + if not isinstance( mesh, vtkUnstructuredGrid ): + raise TypeError( f"Input 'mesh' must be a vtkUnstructuredGrid, but got {type(mesh).__name__}." ) + if mesh.GetNumberOfCells() == 0: + raise ValueError( "Input 'mesh' cannot be empty." ) + + # Check the 'filterName' input + if not isinstance( filterName, str ): + raise TypeError( f"Input 'filterName' must be a string, but got {type(filterName).__name__}." ) + if not filterName.strip(): + raise ValueError( "Input 'filterName' cannot be an empty or whitespace-only string." ) + + # Check the 'useExternalLogger' input + if not isinstance( useExternalLogger, bool ): + raise TypeError( + f"Input 'useExternalLogger' must be a boolean, but got {type(useExternalLogger).__name__}." ) + + # Non-destructive behavior. + # The filter should contain a COPY of the mesh, not the original object. + self.mesh: vtkUnstructuredGrid = vtkUnstructuredGrid() + self.mesh.DeepCopy( mesh ) + self.filterName: str = filterName + + # Logger setup + self.logger: Logger + if not useExternalLogger: + self.logger = getLogger( filterName, True ) + else: + import logging + self.logger = logging.getLogger( filterName ) + self.logger.setLevel( logging.INFO ) + + def setLoggerHandler( self: Self, handler ) -> None: + """Set a specific handler for the filter logger. + + Args: + handler: The logging handler to add. + """ + if not self.logger.handlers: + self.logger.addHandler( handler ) + else: + self.logger.warning( "The logger already has a handler, to use yours set 'useExternalLogger' " + "to True during initialization." ) + + def getMesh( self: Self ) -> vtkUnstructuredGrid: + """Get the processed mesh. + + Returns: + vtkUnstructuredGrid: The processed mesh + """ + return self.mesh + + def writeGrid( self: Self, filepath: str, isDataModeBinary: bool = True, canOverwrite: bool = False ) -> None: + """Writes a .vtu file of the vtkUnstructuredGrid at the specified filepath. + + Args: + filepath (str): /path/to/your/file.vtu + isDataModeBinary (bool, optional): Writes the file in binary format or ascii. Defaults to True. + canOverwrite (bool, optional): Allows or not to overwrite if the filepath already leads to an existing file. + Defaults to False. + """ + if self.mesh: + vtk_output = VtkOutput( filepath, isDataModeBinary ) + write_mesh( self.mesh, vtk_output, canOverwrite ) + else: + self.logger.error( f"No mesh available. Cannot output vtkUnstructuredGrid at {filepath}." ) + + def copyMesh( self: Self, sourceMesh: vtkUnstructuredGrid ) -> vtkUnstructuredGrid: + """Helper method to create a copy of a mesh with structure and attributes. + + Args: + sourceMesh (vtkUnstructuredGrid): Source mesh to copy from. + + Returns: + vtkUnstructuredGrid: New mesh with copied structure and attributes. + """ + output_mesh: vtkUnstructuredGrid = sourceMesh.NewInstance() + output_mesh.CopyStructure( sourceMesh ) + output_mesh.CopyAttributes( sourceMesh ) + return output_mesh + + def applyFilter( self: Self ) -> bool: + """Apply the filter operation. + + This method should be overridden by subclasses to implement specific filter logic. + + Returns: + bool: True if filter applied successfully, False otherwise. + """ + raise NotImplementedError( "Subclasses must implement applyFilter method." ) + + +class MeshDoctorGeneratorBase: + """Base class for mesh doctor generator filters (no input mesh required). + + This class provides functionality for filters that generate meshes + from scratch without requiring input meshes. + """ + + def __init__( + self: Self, + filterName: str, + useExternalLogger: bool = False, + ) -> None: + """Initialize the base mesh doctor generator filter. + + Args: + filterName (str): Name of the filter for logging. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + # Check the 'filterName' input + if not isinstance( filterName, str ): + raise TypeError( f"Input 'filterName' must be a string, but got {type(filterName).__name__}." ) + if not filterName.strip(): + raise ValueError( "Input 'filterName' cannot be an empty or whitespace-only string." ) + + # Check the 'useExternalLogger' input + if not isinstance( useExternalLogger, bool ): + raise TypeError( + f"Input 'useExternalLogger' must be a boolean, but got {type(useExternalLogger).__name__}." ) + + self.mesh: Union[ vtkUnstructuredGrid, None ] = None + self.filterName: str = filterName + + # Logger setup + self.logger: Logger + if not useExternalLogger: + self.logger = getLogger( filterName, True ) + else: + import logging + self.logger = logging.getLogger( filterName ) + self.logger.setLevel( logging.INFO ) + + def setLoggerHandler( self: Self, handler ) -> None: + """Set a specific handler for the filter logger. + + Args: + handler: The logging handler to add. + """ + if not self.logger.handlers: + self.logger.addHandler( handler ) + else: + self.logger.warning( "The logger already has a handler, to use yours set 'useExternalLogger' " + "to True during initialization." ) + + def getMesh( self: Self ) -> Union[ vtkUnstructuredGrid, None ]: + """Get the generated mesh. + + Returns: + Union[vtkUnstructuredGrid, None]: The generated mesh, or None if not yet generated. + """ + return self.mesh + + def writeGrid( self: Self, filepath: str, isDataModeBinary: bool = True, canOverwrite: bool = False ) -> None: + """Writes a .vtu file of the vtkUnstructuredGrid at the specified filepath. + + Args: + filepath (str): /path/to/your/file.vtu + isDataModeBinary (bool, optional): Writes the file in binary format or ascii. Defaults to True. + canOverwrite (bool, optional): Allows or not to overwrite if the filepath already leads to an existing file. + Defaults to False. + """ + if self.mesh: + vtk_output = VtkOutput( filepath, isDataModeBinary ) + write_mesh( self.mesh, vtk_output, canOverwrite ) + else: + self.logger.error( f"No mesh generated. Cannot output vtkUnstructuredGrid at {filepath}." ) + + def applyFilter( self: Self ) -> bool: + """Apply the filter operation to generate a mesh. + + This method should be overridden by subclasses to implement specific generation logic. + The generated mesh should be assigned to self.mesh. + + Returns: + bool: True if mesh generated successfully, False otherwise. + """ + raise NotImplementedError( "Subclasses must implement applyFilter method." ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/NonConformal.py b/geos-mesh/src/geos/mesh/doctor/filters/NonConformal.py new file mode 100644 index 000000000..4ebedbc4f --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/NonConformal.py @@ -0,0 +1,224 @@ +import numpy as np +from typing_extensions import Self +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.non_conformal import Options, find_non_conformal_cells +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.non_conformal_parsing import logger_results + +__doc__ = """ +NonConformal module detects non-conformal mesh interfaces in a vtkUnstructuredGrid. +Non-conformal interfaces occur when adjacent cells do not share nodes or faces properly, which can indicate +mesh quality issues or intentional non-matching grid interfaces that require special handling. + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.NonConformal import NonConformal + + # instantiate the filter + nonConformalFilter = NonConformal( + mesh, + pointTolerance=1e-6, + faceTolerance=1e-6, + angleTolerance=10.0, + writeNonConformalCells=True + ) + + # execute the filter + success = nonConformalFilter.applyFilter() + + # get non-conformal cell pairs + nonConformalCells = nonConformalFilter.getNonConformalCells() + # returns list of tuples with (cell1_id, cell2_id) for non-conformal interfaces + + # get the processed mesh + output_mesh = nonConformalFilter.getMesh() + + # write the output mesh + nonConformalFilter.writeGrid("output/mesh_with_non_conformal_info.vtu") + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.NonConformal import nonConformal + + # apply filter directly + outputMesh, nonConformalCells = nonConformal( + mesh, + outputPath="output/mesh_with_non_conformal_info.vtu", + pointTolerance=1e-6, + faceTolerance=1e-6, + angleTolerance=10.0, + writeNonConformalCells=True + ) +""" + +loggerTitle: str = "Non-Conformal Filter" + + +class NonConformal( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + pointTolerance: float = 0.0, + faceTolerance: float = 0.0, + angleTolerance: float = 10.0, + writeNonConformalCells: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the non-conformal detection filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + pointTolerance (float): Tolerance for point matching. Defaults to 0.0. + faceTolerance (float): Tolerance for face matching. Defaults to 0.0. + angleTolerance (float): Angle tolerance in degrees. Defaults to 10.0. + writeNonConformalCells (bool): Whether to mark non-conformal cells in output. Defaults to False. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.pointTolerance: float = pointTolerance + self.faceTolerance: float = faceTolerance + self.angleTolerance: float = angleTolerance + self.writeNonConformalCells: bool = writeNonConformalCells + + # Results storage + self.nonConformalCells: list[ tuple[ int, int ] ] = [] + + def applyFilter( self: Self ) -> bool: + """Apply the non-conformal detection. + + Returns: + bool: True if detection completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + # Create options and find non-conformal cells + options = Options( self.angleTolerance, self.pointTolerance, self.faceTolerance ) + self.nonConformalCells = find_non_conformal_cells( self.mesh, options ) + + logger_results( self.logger, self.nonConformalCells ) + + # Add marking arrays if requested + if self.writeNonConformalCells and self.nonConformalCells: + self._addNonConformalCellsArray() + + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getAngleTolerance( self: Self ) -> float: + """Get the current angle tolerance. + + Returns: + float: Angle tolerance in degrees. + """ + return self.angleTolerance + + def getFaceTolerance( self: Self ) -> float: + """Get the current face tolerance. + + Returns: + float: Face tolerance value. + """ + return self.faceTolerance + + def getNonConformalCells( self: Self ) -> list[ tuple[ int, int ] ]: + """Get the detected non-conformal cell pairs. + + Returns: + list[tuple[int, int]]: List of cell ID pairs that are non-conformal. + """ + return self.nonConformalCells + + def getPointTolerance( self: Self ) -> float: + """Get the current point tolerance. + + Returns: + float: Point tolerance value. + """ + return self.pointTolerance + + def setAngleTolerance( self: Self, tolerance: float ) -> None: + """Set the angle tolerance parameter in degrees. + + Args: + tolerance (float): Angle tolerance in degrees. + """ + self.angleTolerance = tolerance + + def setFaceTolerance( self: Self, tolerance: float ) -> None: + """Set the face tolerance parameter. + + Args: + tolerance (float): Face tolerance value. + """ + self.faceTolerance = tolerance + + def setPointTolerance( self: Self, tolerance: float ) -> None: + """Set the point tolerance parameter. + + Args: + tolerance (float): Point tolerance value. + """ + self.pointTolerance = tolerance + + def setWriteNonConformalCells( self: Self, write: bool ) -> None: + """Set whether to create anarray marking non-conformal cells in output data. + + Args: + write (bool): True to enable marking, False to disable. + """ + self.writeNonConformalCells = write + + def _addNonConformalCellsArray( self: Self ) -> None: + """Add array marking non-conformal cells.""" + numCells: int = self.mesh.GetNumberOfCells() + uniqueNonConformalCells = frozenset( [ cell_id for pair in self.nonConformalCells for cell_id in pair ] ) + nonConformalArray = np.zeros( numCells, dtype=np.int32 ) + nonConformalArray[ list( uniqueNonConformalCells ) ] = 1 + + vtkArray: vtkDataArray = numpy_to_vtk( nonConformalArray ) + vtkArray.SetName( "IsNonConformal" ) + self.mesh.GetCellData().AddArray( vtkArray ) + + +# Main function for standalone use +def nonConformal( mesh: vtkUnstructuredGrid, + outputPath: str, + pointTolerance: float = 0.0, + faceTolerance: float = 0.0, + angleTolerance: float = 10.0, + writeNonConformalCells: bool = False ) -> tuple[ vtkUnstructuredGrid, list[ tuple[ int, int ] ] ]: + """Apply non-conformal detection to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + pointTolerance (float): Tolerance for point matching. Defaults to 0.0. + faceTolerance (float): Tolerance for face matching. Defaults to 0.0. + angleTolerance (float): Angle tolerance in degrees. Defaults to 10.0. + writeNonConformalCells (bool): Whether to mark non-conformal cells. Defaults to False. + write_output (bool): Whether to write output mesh to file. Defaults to False. + output_path (str): Output file path if write_output is True. + + Returns: + tuple[vtkUnstructuredGrid, list[tuple[int, int]]]: + Processed mesh, non-conformal cell pairs. + """ + filterInstance = NonConformal( mesh, pointTolerance, faceTolerance, angleTolerance, writeNonConformalCells ) + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "NonConformal detection failed." ) + + filterInstance.writeGrid( outputPath ) + + return ( + filterInstance.getMesh(), + filterInstance.getNonConformalCells(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/SelfIntersectingElements.py b/geos-mesh/src/geos/mesh/doctor/filters/SelfIntersectingElements.py new file mode 100644 index 000000000..46d1a348a --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/SelfIntersectingElements.py @@ -0,0 +1,174 @@ +import numpy as np +from typing_extensions import Self +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.self_intersecting_elements import get_invalid_cell_ids +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.self_intersecting_elements_parsing import logger_results + +__doc__ = """ +SelfIntersectingElements module identifies various types of invalid or problematic elements +in a vtkUnstructuredGrid. It detects elements with intersecting edges, intersecting faces, non-contiguous edges, +non-convex shapes, incorrectly oriented faces, wrong number of points, non planar faces elements and degenerate +faces elements. + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.SelfIntersectingElements import SelfIntersectingElements + + # instantiate the filter + selfIntersectingElementsFilter = SelfIntersectingElements( + mesh, + minDistance=1e-6, + writeInvalidElements=True + ) + + # execute the filter + success = selfIntersectingElementsFilter.applyFilter() + + # get the ids of problematic elements for every error types + selfIntersectingElementsFilter.getInvalidCells() + + # get the processed mesh + output_mesh = selfIntersectingElementsFilter.getMesh() + + # write the output mesh + selfIntersectingElementsFilter.writeGrid("output/mesh_with_invalid_elements.vtu") + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.SelfIntersectingElements import selfIntersectingElements + + # apply filter directly + outputMesh, invalidCellIds = selfIntersectingElements( + mesh, + outputPath="output/mesh_with_invalid_elements.vtu", + minDistance=1e-6, + writeInvalidElements=True + ) +""" + +loggerTitle: str = "Self-Intersecting Elements Filter" + + +class SelfIntersectingElements( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + minDistance: float = 0.0, + writeInvalidElements: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the self-intersecting elements detection filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + minDistance (float): Minimum distance parameter for intersection detection. Defaults to 0.0. + writeInvalidElements (bool): Whether to mark invalid elements in output. Defaults to False. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.invalidCellIds: dict[ str, list[ int ] ] = {} + self.minDistance: float = minDistance + self.writeInvalidElements: bool = writeInvalidElements + + def applyFilter( self: Self ) -> bool: + """Apply the self-intersecting elements detection. + + Returns: + bool: True if detection completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}" ) + + self.invalidCellIds = get_invalid_cell_ids( self.mesh, self.minDistance ) + logger_results( self.logger, self.invalidCellIds ) + + # Add marking arrays if requested + if self.writeInvalidElements: + self._addInvalidElementsArrays() + + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getInvalidCellIds( self: Self ) -> dict[ str, list[ int ] ]: + """Get all invalid elements organized by type. + + Returns: + dict[str, list[int]]: Dictionary mapping invalid element types to their IDs. + """ + return self.invalidCellIds + + def getMinDistance( self: Self ) -> float: + """Get the current minimum distance parameter. + + Returns: + float: Minimum distance value. + """ + return self.minDistance + + def setMinDistance( self: Self, distance: float ) -> None: + """Set the minimum distance parameter for intersection detection. + + Args: + distance (float): Minimum distance value. + """ + self.minDistance = distance + + def setWriteInvalidElements( self: Self, write: bool ) -> None: + """Set whether to create arrays marking invalid elements in output data. + + Args: + write (bool): True to enable marking, False to disable. + """ + self.writeInvalidElements = write + + def _addInvalidElementsArrays( self: Self ) -> None: + """Add arrays marking different types of invalid elements.""" + numCells: int = self.mesh.GetNumberOfCells() + if self.writeInvalidElements: + for invalidTypeName, invalidTypeData in self.invalidCellIds.items(): + if invalidTypeData: + array = np.zeros( numCells, dtype=np.int32 ) + array[ invalidTypeData ] = 1 + vtkArray: vtkDataArray = numpy_to_vtk( array ) + vtkArray.SetName( f"Is{invalidTypeName}" ) + self.mesh.GetCellData().AddArray( vtkArray ) + + +# Main function for standalone use +def selfIntersectingElements( + mesh: vtkUnstructuredGrid, + outputPath: str, + minDistance: float = 0.0, + writeInvalidElements: bool = False ) -> tuple[ vtkUnstructuredGrid, dict[ str, list[ int ] ] ]: + """Apply self-intersecting elements detection to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + outputPath (str): Output file path if write_output is True. + minDistance (float): Minimum distance parameter for intersection detection. Defaults to 0.0. + writeInvalidElements (bool): Whether to mark invalid elements. Defaults to False. + + Returns: + tuple[vtkUnstructuredGrid, dict[str, list[int]]]: + Processed mesh, dictionary of invalid element types and their IDs + """ + filter_instance = SelfIntersectingElements( mesh, minDistance, writeInvalidElements ) + success = filter_instance.applyFilter() + if not success: + raise RuntimeError( "Self-intersecting elements detection failed" ) + + filter_instance.writeGrid( outputPath ) + + return ( + filter_instance.getMesh(), + filter_instance.getInvalidCellIds(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/SupportedElements.py b/geos-mesh/src/geos/mesh/doctor/filters/SupportedElements.py new file mode 100644 index 000000000..69e1b4816 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/filters/SupportedElements.py @@ -0,0 +1,244 @@ +import numpy as np +from typing_extensions import Self +from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.vtkCommonCore import vtkDataArray +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid +from geos.mesh.doctor.actions.supported_elements import ( Options, find_unsupported_std_elements_types, + find_unsupported_polyhedron_elements ) +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase +from geos.mesh.doctor.parsing.supported_elements_parsing import logger_results + +__doc__ = """ +SupportedElements module identifies unsupported element types and problematic polyhedron +elements in a vtkUnstructuredGrid. It checks for element types that are not supported by GEOS and validates +polyhedron elements for geometric correctness. + +To use the filter +----------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.SupportedElements import SupportedElements + + # instantiate the filter + supportedElementsFilter = SupportedElements(mesh, writeUnsupportedElementTypes=True, + writeUnsupportedPolyhedrons=True, + numProc=4, chunkSize=1000) + + # execute the filter + success = supportedElementsFilter.applyFilter() + + # get unsupported element types + unsupportedTypes = supportedElementsFilter.getUnsupportedElementTypes() + + # get unsupported polyhedron elements + unsupportedPolyhedrons = supportedElementsFilter.getUnsupportedPolyhedronElements() + + # get the processed mesh with support information + outputMesh = supportedElementsFilter.getMesh() + + # write the output mesh + supportedElementsFilter.writeGrid("output/mesh_with_support_info.vtu") + +For standalone use without creating a filter instance +----------------------------------------------------- + +.. code-block:: python + + from geos.mesh.doctor.filters.SupportedElements import supportedElements + + # apply filter directly + outputMesh, unsupportedTypes, unsupportedPolyhedrons = supportedElements( + mesh, + outputPath="output/mesh_with_support_info.vtu", + numProc=4, + chunkSize=1000, + writeUnsupportedElementTypes=True, + writeUnsupportedPolyhedrons=True + ) +""" + +loggerTitle: str = "Supported Elements Filter" + + +class SupportedElements( MeshDoctorFilterBase ): + + def __init__( + self: Self, + mesh: vtkUnstructuredGrid, + numProc: int = 1, + chunkSize: int = 1, + writeUnsupportedElementTypes: bool = False, + writeUnsupportedPolyhedrons: bool = False, + useExternalLogger: bool = False, + ) -> None: + """Initialize the supported elements filter. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + writeUnsupportedElementTypes (bool): Whether to add new CellData array marking unsupported element types. + Defaults to False. + writeUnsupportedPolyhedrons (bool): Whether to add new CellData array marking unsupported polyhedrons. + Defaults to False. + numProc (int): Number of processes for multiprocessing. Defaults to 1. + chunkSize (int): Chunk size for multiprocessing. Defaults to 1. + useExternalLogger (bool): Whether to use external logger. Defaults to False. + """ + super().__init__( mesh, loggerTitle, useExternalLogger ) + self.numProc: int = numProc + self.chunkSize: int = chunkSize + self.unsupportedElementTypes: list[ str ] = [] + self.unsupportedPolyhedronElements: list[ int ] = [] + self.writeUnsupportedElementTypes: bool = writeUnsupportedElementTypes + self.writeUnsupportedPolyhedrons: bool = writeUnsupportedPolyhedrons + + def applyFilter( self: Self ) -> bool: + """Apply the supported elements analysis. + + Returns: + bool: True if analysis completed successfully, False otherwise. + """ + self.logger.info( f"Apply filter {self.logger.name}." ) + + # Find unsupported standard element types + self.unsupportedElementTypes = find_unsupported_std_elements_types( self.mesh ) + + if len( self.unsupportedElementTypes ) > 0: + if self.writeUnsupportedElementTypes: + self._addUnsupportedElementTypesArray() + + # Find unsupported polyhedron elements + options = Options( self.numProc, self.chunkSize ) + self.unsupportedPolyhedronElements = find_unsupported_polyhedron_elements( self.mesh, options ) + + if len( self.unsupportedPolyhedronElements ) > 0: + if self.writeUnsupportedPolyhedrons: + self._addUnsupportedPolyhedronsArray() + + logger_results( self.logger, self.unsupportedPolyhedronElements, self.unsupportedElementTypes ) + + self.logger.info( f"The filter {self.logger.name} succeeded." ) + return True + + def getUnsupportedElementTypes( self: Self ) -> list[ str ]: + """Get the list of unsupported element types. + + Returns: + list[ str ]: List of unsupported element type descriptions. + """ + return self.unsupportedElementTypes + + def getUnsupportedPolyhedronElements( self: Self ) -> list[ int ]: + """Get the list of unsupported polyhedron element indices. + + Returns: + list[ int ]: List of element indices for unsupported polyhedrons. + """ + return self.unsupportedPolyhedronElements + + def setWriteUnsupportedElementTypes( self: Self, write: bool ) -> None: + """Set whether to write unsupported element types. + + Args: + write (bool): True to enable writing, False to disable. + """ + self.writeUnsupportedElementTypes = write + + def setWriteUnsupportedPolyhedrons( self: Self, write: bool ) -> None: + """Set whether to write unsupported polyhedrons. + + Args: + write (bool): True to enable writing, False to disable. + """ + self.writeUnsupportedPolyhedrons = write + + def setNumProc( self: Self, numProc: int ) -> None: + """Set the number of processes for multiprocessing. + + Args: + numProc (int): Number of processes. + """ + self.numProc = numProc + + def setChunkSize( self: Self, chunkSize: int ) -> None: + """Set the chunk size for multiprocessing. + + Args: + chunkSize (int): Chunk size. + """ + self.chunkSize = chunkSize + + def _addUnsupportedElementTypesArray( self: Self ) -> None: + """Add an array marking elements with unsupported types on the mesh.""" + self.logger.info( "Adding CellData array marking elements with unsupported types." ) + + numCells: int = self.mesh.GetNumberOfCells() + unsupportedTypesArray = np.zeros( numCells, dtype=np.int32 ) + + # Get unsupported type IDs from the string descriptions + unsupportedTypeIds = set() + for description in self.unsupportedElementTypes: + # Extract type ID from description like "Type 42: vtkSomeElementType" + if description.startswith( "Type " ): + typeId = int( description.split( ":" )[ 0 ].replace( "Type ", "" ) ) + unsupportedTypeIds.add( typeId ) + + # Mark cells with unsupported types + for i in range( numCells ): + cellType: int = self.mesh.GetCellType( i ) + if cellType in unsupportedTypeIds: + unsupportedTypesArray[ i ] = 1 + + vtkArray: vtkDataArray = numpy_to_vtk( unsupportedTypesArray ) + vtkArray.SetName( "HasUnsupportedType" ) + self.mesh.GetCellData().AddArray( vtkArray ) + + def _addUnsupportedPolyhedronsArray( self: Self ) -> None: + """Add an array marking unsupported polyhedron elements on the mesh.""" + self.logger.info( "Adding CellData array marking unsupported polyhedron elements." ) + + numCells: int = self.mesh.GetNumberOfCells() + unsupportedPolyhedronsArray = np.zeros( numCells, dtype=np.int32 ) + unsupportedPolyhedronsArray[ self.unsupportedPolyhedronElements ] = 1 + + vtkArray: vtkDataArray = numpy_to_vtk( unsupportedPolyhedronsArray ) + vtkArray.SetName( "IsUnsupportedPolyhedron" ) + self.mesh.GetCellData().AddArray( vtkArray ) + + +# Main function for standalone use +def supportedElements( + mesh: vtkUnstructuredGrid, + outputPath: str, + numProc: int = 1, + chunkSize: int = 1, + writeUnsupportedElementTypes: bool = False, + writeUnsupportedPolyhedrons: bool = False, +) -> tuple[ vtkUnstructuredGrid, list[ str ], list[ int ] ]: + """Apply supported elements analysis to a mesh. + + Args: + mesh (vtkUnstructuredGrid): The input mesh to analyze. + outputPath (str): Output file path for writing the mesh. + numProc (int): Number of processes for multiprocessing. Defaults to 1. + chunkSize (int): Chunk size for multiprocessing. Defaults to 1. + writeUnsupportedElementTypes (bool): Whether to write unsupported element types. Defaults to False. + writeUnsupportedPolyhedrons (bool): Whether to write unsupported polyhedrons. Defaults to False. + + Returns: + tuple[vtkUnstructuredGrid, list[ str ], list[ int ]]: + Processed mesh, list of unsupported element types, list of unsupported polyhedron indices. + """ + filterInstance = SupportedElements( mesh, numProc, chunkSize, writeUnsupportedElementTypes, + writeUnsupportedPolyhedrons ) + success = filterInstance.applyFilter() + if not success: + raise RuntimeError( "Supported elements identification failed." ) + + filterInstance.writeGrid( outputPath ) + + return ( + filterInstance.getMesh(), + filterInstance.getUnsupportedElementTypes(), + filterInstance.getUnsupportedPolyhedronElements(), + ) diff --git a/geos-mesh/src/geos/mesh/doctor/filters/__init__.py b/geos-mesh/src/geos/mesh/doctor/filters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/collocated_nodes_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/collocated_nodes_parsing.py index ac93feb85..958a91b38 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/collocated_nodes_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/collocated_nodes_parsing.py @@ -25,24 +25,39 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): setup_logger.results( get_options_used_message( options ) ) + logger_results( setup_logger, result.nodes_buckets, result.wrong_support_elements ) + + +def logger_results( logger, nodes_buckets: list[ tuple[ int ] ], wrong_support_elements: list[ int ] ): + """Log the results of the collocated nodes check. + + Args: + logger: Logger instance for output. + nodes_buckets (list[ tuple[ int ] ]): List of collocated nodes buckets. + wrong_support_elements (list[ int ]): List of elements with wrong support nodes. + """ + # Accounts for external logging object that would not contain 'results' attribute + log_method = logger.info + if hasattr( logger, 'results' ): + log_method = logger.results + all_collocated_nodes: list[ int ] = [] - for bucket in result.nodes_buckets: + for bucket in nodes_buckets: for node in bucket: all_collocated_nodes.append( node ) - all_collocated_nodes: frozenset[ int ] = frozenset( all_collocated_nodes ) # Surely useless + all_collocated_nodes = list( set( all_collocated_nodes ) ) if all_collocated_nodes: - setup_logger.results( f"You have {len( all_collocated_nodes )} collocated nodes." ) - setup_logger.results( "Here are all the buckets of collocated nodes." ) + log_method( f"You have {len( all_collocated_nodes )} collocated nodes." ) + log_method( "Here are all the buckets of collocated nodes." ) tmp: list[ str ] = [] - for bucket in result.nodes_buckets: + for bucket in nodes_buckets: tmp.append( f"({', '.join(map(str, bucket))})" ) - setup_logger.results( f"({', '.join(tmp)})" ) + log_method( f"({', '.join(tmp)})" ) else: - setup_logger.results( "You have no collocated node." ) + log_method( "You have no collocated node." ) - if result.wrong_support_elements: - tmp: str = ", ".join( map( str, result.wrong_support_elements ) ) - setup_logger.results( - f"You have {len(result.wrong_support_elements)} elements with duplicated support nodes.\n" + tmp ) + if wrong_support_elements: + tmp: str = ", ".join( map( str, wrong_support_elements ) ) + log_method( f"You have {len(wrong_support_elements)} elements with duplicated support nodes.\n" + tmp ) else: - setup_logger.results( "You have no element with duplicated support nodes." ) + log_method( "You have no element with duplicated support nodes." ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/element_volumes_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/element_volumes_parsing.py index 233157851..f52ee1f5a 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/element_volumes_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/element_volumes_parsing.py @@ -31,11 +31,23 @@ def convert( parsed_options ) -> Options: def display_results( options: Options, result: Result ): setup_logger.results( get_options_used_message( options ) ) - setup_logger.results( - f"You have {len(result.element_volumes)} elements with volumes smaller than {options.min_volume}." ) - if result.element_volumes: - setup_logger.results( "Elements index | Volumes calculated" ) - setup_logger.results( "-----------------------------------" ) - max_length: int = len( "Elements index " ) - for ( ind, volume ) in result.element_volumes: - setup_logger.results( f"{ind:<{max_length}}" + "| " + str( volume ) ) + logger_results( setup_logger, result.element_volumes ) + + +def logger_results( logger, element_volumes: list[ tuple[ int, float ] ] ) -> None: + """Show the results of the element volumes check. + + Args: + logger: Logger instance for output. + element_volumes (list[ tuple[ int, float ] ]): List of element volumes. + """ + # Accounts for external logging object that would not contain 'results' attribute + log_method = logger.info + if hasattr( logger, 'results' ): + log_method = logger.results + + log_method( "Elements index | Volumes calculated" ) + log_method( "-----------------------------------" ) + max_length: int = len( "Elements index " ) + for ( ind, volume ) in element_volumes: + log_method( f"{ind:<{max_length}}" + "| " + str( volume ) ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/non_conformal_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/non_conformal_parsing.py index 801e04f4b..a2784ee4f 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/non_conformal_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/non_conformal_parsing.py @@ -47,9 +47,24 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): setup_logger.results( get_options_used_message( options ) ) - non_conformal_cells: list[ int ] = [] - for i, j in result.non_conformal_cells: - non_conformal_cells += i, j - non_conformal_cells: frozenset[ int ] = frozenset( non_conformal_cells ) - setup_logger.results( f"You have {len( non_conformal_cells )} non conformal cells." ) - setup_logger.results( f"{', '.join( map( str, sorted( non_conformal_cells ) ) )}" ) + logger_results( setup_logger, result.non_conformal_cells ) + + +def logger_results( logger, non_conformal_cells: list[ tuple[ int, int ] ] ) -> None: + """Log the results of the non-conformal cells check. + + Args: + logger: Logger instance for output. + non_conformal_cells (list[ tuple[ int, int ] ]): List of non-conformal cells. + """ + # Accounts for external logging object that would not contain 'results' attribute + log_method = logger.info + if hasattr( logger, 'results' ): + log_method = logger.results + + unique_cells: list[ int ] = [] + for i, j in non_conformal_cells: + unique_cells += i, j + unique_cells: frozenset[ int ] = frozenset( unique_cells ) + log_method( f"You have {len( unique_cells )} non conformal cells." ) + log_method( f"{', '.join( map( str, sorted( unique_cells ) ) )}" ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/self_intersecting_elements_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/self_intersecting_elements_parsing.py index 430a25325..d7c55b704 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/self_intersecting_elements_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/self_intersecting_elements_parsing.py @@ -13,9 +13,8 @@ def convert( parsed_options ) -> Options: min_distance = parsed_options[ __MIN_DISTANCE ] if min_distance == 0: - setup_logger.warning( - "Having minimum distance set to 0 can induce lots of false positive results (adjacent faces may be considered intersecting)." - ) + setup_logger.warning( "Having minimum distance set to 0 can induce lots of false positive results" + " (adjacent faces may be considered intersecting)." ) elif min_distance < 0: raise ValueError( f"Negative minimum distance ({min_distance}) in the {SELF_INTERSECTING_ELEMENTS} check is not allowed." ) @@ -25,20 +24,47 @@ def convert( parsed_options ) -> Options: def fill_subparser( subparsers ) -> None: p = subparsers.add_parser( SELF_INTERSECTING_ELEMENTS, help="Checks if the faces of the elements are self intersecting." ) - p.add_argument( - '--' + __MIN_DISTANCE, - type=float, - required=False, - metavar=__MIN_DISTANCE_DEFAULT, - default=__MIN_DISTANCE_DEFAULT, - help= - f"[float]: The minimum distance in the computation. Defaults to your machine precision {__MIN_DISTANCE_DEFAULT}." - ) + p.add_argument( '--' + __MIN_DISTANCE, + type=float, + required=False, + metavar=__MIN_DISTANCE_DEFAULT, + default=__MIN_DISTANCE_DEFAULT, + help=( "[float]: The minimum distance in the computation." + f" Defaults to your machine precision {__MIN_DISTANCE_DEFAULT}." ) ) def display_results( options: Options, result: Result ): setup_logger.results( get_options_used_message( options ) ) - setup_logger.results( f"You have {len(result.intersecting_faces_elements)} elements with self intersecting faces." ) - if result.intersecting_faces_elements: - setup_logger.results( "The elements indices are:\n" + - ", ".join( map( str, result.intersecting_faces_elements ) ) ) + logger_results( setup_logger, result.invalid_cell_ids ) + + +def logger_results( logger, invalid_cell_ids ) -> None: + """Log the results of the self-intersecting elements check. + + Args: + logger: Logger instance for output. + invalid_cell_ids: Dictionary of invalid cell IDs by error type. + """ + # Accounts for external logging object that would not contain 'results' attribute + log_method = logger.info + if hasattr( logger, 'results' ): + log_method = logger.results + + # Human-readable descriptions for each error type + error_descriptions = { + 'wrongNumberOfPointsElements': 'elements with wrong number of points', + 'intersectingEdgesElements': 'elements with intersecting edges', + 'intersectingFacesElements': 'elements with self intersecting faces', + 'nonContiguousEdgesElements': 'elements with non-contiguous edges', + 'nonConvexElements': 'non-convex elements', + 'facesOrientedIncorrectlyElements': 'elements with incorrectly oriented faces', + 'nonPlanarFacesElements': 'elements with non-planar faces', + 'degenerateFacesElements': 'elements with degenerate faces' + } + + # Log results for each error type that has invalid elements + for error_type, invalid_ids in invalid_cell_ids.items(): + if invalid_ids: + description = error_descriptions.get( error_type, f'elements with {error_type}' ) + log_method( f"You have {len(invalid_ids)} {description}." ) + log_method( "The elements indices are:\n" + ", ".join( map( str, invalid_ids ) ) ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/supported_elements_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/supported_elements_parsing.py index f9f8dd84a..567f8158a 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/supported_elements_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/supported_elements_parsing.py @@ -19,7 +19,7 @@ def convert( parsed_options ) -> Options: def fill_subparser( subparsers ) -> None: p = subparsers.add_parser( SUPPORTED_ELEMENTS, - help="Check that all the elements of the mesh are supported by GEOSX." ) + help="Check that all the elements of the mesh are supported by GEOS." ) p.add_argument( '--' + __CHUNK_SIZE, type=int, required=False, @@ -38,17 +38,32 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): setup_logger.results( get_options_used_message( options ) ) - if result.unsupported_polyhedron_elements: - setup_logger.results( - f"There is/are {len(result.unsupported_polyhedron_elements)} polyhedra that may not be converted to supported elements." - ) - setup_logger.results( - f"The list of the unsupported polyhedra is\n{tuple(sorted(result.unsupported_polyhedron_elements))}." ) + logger_results( setup_logger, result.unsupported_polyhedron_elements, result.unsupported_std_elements_types ) + + +def logger_results( logger, unsupported_polyhedron_elements: frozenset[ int ], + unsupported_std_elements_types: list[ str ] ) -> None: + """Log the results of the supported elements check. + + Args: + logger: Logger instance for output. + unsupported_polyhedron_elements (frozenset[ int ]): List of unsupported polyhedron elements. + unsupported_std_elements_types (list[ str ]): List of unsupported standard element types. + """ + # Accounts for external logging object that would not contain 'results' attribute + log_method = logger.info + if hasattr( logger, 'results' ): + log_method = logger.results + + if unsupported_polyhedron_elements: + log_method( f"There is/are {len(unsupported_polyhedron_elements)} polyhedra that may not be converted to" + " supported elements." ) + log_method( "The list of the unsupported polyhedra is\n" + f"{tuple(sorted(unsupported_polyhedron_elements))}." ) else: - setup_logger.results( "All the polyhedra (if any) can be converted to supported elements." ) - if result.unsupported_std_elements_types: - setup_logger.results( - f"There are unsupported vtk standard element types. The list of those vtk types is {tuple(sorted(result.unsupported_std_elements_types))}." - ) + log_method( "All the polyhedra (if any) can be converted to supported elements." ) + if unsupported_std_elements_types: + log_method( "There are unsupported vtk standard element types. The list of those vtk types is" + f" {tuple(sorted(unsupported_std_elements_types))}." ) else: - setup_logger.results( "All the standard vtk element types (if any) are supported." ) + log_method( "All the standard vtk element types (if any) are supported." ) diff --git a/geos-mesh/src/geos/mesh/io/vtkIO.py b/geos-mesh/src/geos/mesh/io/vtkIO.py index 1b93648a2..c325f110e 100644 --- a/geos-mesh/src/geos/mesh/io/vtkIO.py +++ b/geos-mesh/src/geos/mesh/io/vtkIO.py @@ -1,192 +1,250 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. # SPDX-FileContributor: Alexandre Benedicto - -import os.path -import logging from dataclasses import dataclass -from typing import Optional -from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, vtkStructuredGrid, vtkPointSet -from vtkmodules.vtkIOLegacy import vtkUnstructuredGridWriter, vtkUnstructuredGridReader -from vtkmodules.vtkIOXML import ( vtkXMLUnstructuredGridReader, vtkXMLUnstructuredGridWriter, - vtkXMLStructuredGridReader, vtkXMLPUnstructuredGridReader, +from enum import Enum +from pathlib import Path +from typing import Optional, Type, TypeAlias +from vtkmodules.vtkCommonDataModel import vtkPointSet, vtkUnstructuredGrid +from vtkmodules.vtkIOCore import vtkWriter +from vtkmodules.vtkIOLegacy import vtkDataReader, vtkUnstructuredGridWriter, vtkUnstructuredGridReader +from vtkmodules.vtkIOXML import ( vtkXMLDataReader, vtkXMLUnstructuredGridReader, vtkXMLUnstructuredGridWriter, + vtkXMLWriter, vtkXMLStructuredGridReader, vtkXMLPUnstructuredGridReader, vtkXMLPStructuredGridReader, vtkXMLStructuredGridWriter ) +from geos.utils.Logger import getLogger __doc__ = """ -Input and Ouput methods for VTK meshes: - - VTK, VTU, VTS, PVTU, PVTS readers - - VTK, VTS, VTU writers +Input and Output methods for various VTK mesh formats. +Supports reading: .vtk, .vtu, .vts, .pvtu, .pvts +Supports writing: .vtk, .vtu, .vts """ +io_logger = getLogger( "IO for geos-mesh" ) +io_logger.propagate = False + + +class VtkFormat( Enum ): + """Enumeration for supported VTK file formats and their extensions.""" + VTK = ".vtk" + VTS = ".vts" + VTU = ".vtu" + PVTU = ".pvtu" + PVTS = ".pvts" + + +# Improved: Use TypeAlias for cleaner and more readable type hints +VtkReaderClass: TypeAlias = Type[ vtkDataReader | vtkXMLDataReader ] +VtkWriterClass: TypeAlias = Type[ vtkWriter | vtkXMLWriter ] + +# Centralized mapping of formats to their corresponding reader classes +READER_MAP: dict[ VtkFormat, VtkReaderClass ] = { + VtkFormat.VTK: vtkUnstructuredGridReader, + VtkFormat.VTS: vtkXMLStructuredGridReader, + VtkFormat.VTU: vtkXMLUnstructuredGridReader, + VtkFormat.PVTU: vtkXMLPUnstructuredGridReader, + VtkFormat.PVTS: vtkXMLPStructuredGridReader +} + +# Centralized mapping of formats to their corresponding writer classes +WRITER_MAP: dict[ VtkFormat, VtkWriterClass ] = { + VtkFormat.VTK: vtkUnstructuredGridWriter, + VtkFormat.VTS: vtkXMLStructuredGridWriter, + VtkFormat.VTU: vtkXMLUnstructuredGridWriter, +} + @dataclass( frozen=True ) class VtkOutput: + """Configuration for writing a VTK file.""" output: str - is_data_mode_binary: bool - - -def __read_vtk( vtk_input_file: str ) -> Optional[ vtkUnstructuredGrid ]: - reader = vtkUnstructuredGridReader() - logging.info( f"Testing file format \"{vtk_input_file}\" using legacy format reader..." ) - reader.SetFileName( vtk_input_file ) - if reader.IsFileUnstructuredGrid(): - logging.info( f"Reader matches. Reading file \"{vtk_input_file}\" using legacy format reader." ) - reader.Update() - return reader.GetOutput() - else: - logging.info( "Reader did not match the input file format." ) - return None + is_data_mode_binary: bool = True -def __read_vts( vtk_input_file: str ) -> Optional[ vtkStructuredGrid ]: - reader = vtkXMLStructuredGridReader() - logging.info( f"Testing file format \"{vtk_input_file}\" using XML format reader..." ) - if reader.CanReadFile( vtk_input_file ): - reader.SetFileName( vtk_input_file ) - logging.info( f"Reader matches. Reading file \"{vtk_input_file}\" using XML format reader." ) - reader.Update() - return reader.GetOutput() - else: - logging.info( "Reader did not match the input file format." ) - return None +def _read_data( filepath: str, reader_class: VtkReaderClass ) -> Optional[ vtkPointSet ]: + """Generic helper to read a VTK file using a specific reader class. + Args: + filepath (str): Path to the VTK file. + reader_class (VtkReaderClass): The VTK reader class to use. -def __read_vtu( vtk_input_file: str ) -> Optional[ vtkUnstructuredGrid ]: - reader = vtkXMLUnstructuredGridReader() - logging.info( f"Testing file format \"{vtk_input_file}\" using XML format reader..." ) - if reader.CanReadFile( vtk_input_file ): - reader.SetFileName( vtk_input_file ) - logging.info( f"Reader matches. Reading file \"{vtk_input_file}\" using XML format reader." ) - reader.Update() - return reader.GetOutput() - else: - logging.info( "Reader did not match the input file format." ) + Returns: + Optional[ vtkPointSet ]: The read VTK point set, or None if reading failed. + """ + reader = reader_class() + io_logger.info( f"Attempting to read '{filepath}' with {reader_class.__name__}..." ) + + reader.SetFileName( str( filepath ) ) + + # For XML-based readers, CanReadFile is a reliable and fast pre-check. + if hasattr( reader, 'CanReadFile' ) and not reader.CanReadFile( filepath ): + io_logger.error( f"Reader {reader_class.__name__} reports it cannot read file '{filepath}'." ) return None + reader.Update() -def __read_pvts( vtk_input_file: str ) -> Optional[ vtkStructuredGrid ]: - reader = vtkXMLPStructuredGridReader() - logging.info( f"Testing file format \"{vtk_input_file}\" using XML format reader..." ) - if reader.CanReadFile( vtk_input_file ): - reader.SetFileName( vtk_input_file ) - logging.info( f"Reader matches. Reading file \"{vtk_input_file}\" using XML format reader." ) - reader.Update() - return reader.GetOutput() - else: - logging.info( "Reader did not match the input file format." ) + # FIX: Check the reader's error code. This is the most reliable way to + # detect a failed read, as GetOutput() can return a default empty object on failure. + if hasattr( reader, 'GetErrorCode' ) and reader.GetErrorCode() != 0: + io_logger.warning( + f"VTK reader {reader_class.__name__} reported an error code after attempting to read '{filepath}'." ) return None + output = reader.GetOutput() -def __read_pvtu( vtk_input_file: str ) -> Optional[ vtkUnstructuredGrid ]: - reader = vtkXMLPUnstructuredGridReader() - logging.info( f"Testing file format \"{vtk_input_file}\" using XML format reader..." ) - if reader.CanReadFile( vtk_input_file ): - reader.SetFileName( vtk_input_file ) - logging.info( f"Reader matches. Reading file \"{vtk_input_file}\" using XML format reader." ) - reader.Update() - return reader.GetOutput() - else: - logging.info( "Reader did not match the input file format." ) + if output is None: return None + io_logger.info( "Read successful." ) + return output + + +def _write_data( mesh: vtkPointSet, writer_class: VtkWriterClass, output: str, is_binary: bool ) -> int: + """Generic helper to write a VTK file using a specific writer class. + + Args: + mesh (vtkPointSet): The grid data to write. + writer_class (VtkWriterClass): The VTK writer class to use. + output (str): The output file path. + is_binary (bool): Whether to write the file in binary mode. + + Returns: + int: The result of the write operation. + """ + io_logger.info( f"Writing mesh to '{output}' using {writer_class.__name__}..." ) + writer = writer_class() + writer.SetFileName( output ) + writer.SetInputData( mesh ) + + # Set data mode only for XML writers that support it + if isinstance( writer, vtkXMLWriter ): + if is_binary: + writer.SetDataModeToBinary() + io_logger.info( "Data mode set to Binary." ) + else: + writer.SetDataModeToAscii() + io_logger.info( "Data mode set to ASCII." ) -def read_mesh( vtk_input_file: str ) -> vtkPointSet: - """Read vtk file and build either an unstructured grid or a structured grid from it. + return writer.Write() + + +def read_mesh( filepath: str ) -> vtkPointSet: + """ + Reads a VTK file, automatically detecting the format. + + It first tries the reader associated with the file extension, then falls + back to trying all other available readers if the first attempt fails. Args: - vtk_input_file (str): The file name. Extension will be used to guess file format\ - If first guess fails, other available readers will be tried. + filepath (str): The path to the VTK file. Raises: - ValueError: Invalid file path error - ValueError: No appropriate reader available for the file format + FileNotFoundError: If the input file does not exist. + ValueError: If no suitable reader can be found for the file. Returns: - vtkPointSet: Mesh read + vtkPointSet: The resulting mesh data. """ - if not os.path.exists( vtk_input_file ): - err_msg: str = f"Invalid file path. Could not read \"{vtk_input_file}\"." - logging.error( err_msg ) - raise ValueError( err_msg ) - file_extension = os.path.splitext( vtk_input_file )[ -1 ] - extension_to_reader = { - ".vtk": __read_vtk, - ".vts": __read_vts, - ".vtu": __read_vtu, - ".pvtu": __read_pvtu, - ".pvts": __read_pvts - } - # Testing first the reader that should match - if file_extension in extension_to_reader: - output_mesh = extension_to_reader.pop( file_extension )( vtk_input_file ) - if output_mesh: - return output_mesh - # If it does not match, then test all the others. - for reader in extension_to_reader.values(): - output_mesh = reader( vtk_input_file ) + filepath_path: Path = Path( filepath ) + if not filepath_path.exists(): + raise FileNotFoundError( f"Invalid file path: '{filepath}' does not exist." ) + + candidate_readers: list[ VtkReaderClass ] = [] + # 1. Prioritize the reader associated with the file extension + try: + file_format = VtkFormat( filepath_path.suffix ) + if file_format in READER_MAP: + candidate_readers.append( READER_MAP[ file_format ] ) + except ValueError: + io_logger.warning( f"Unknown file extension '{filepath_path.suffix}'. Trying all available readers." ) + + # 2. Add all other unique readers as fallbacks + for reader_cls in READER_MAP.values(): + if reader_cls not in candidate_readers: + candidate_readers.append( reader_cls ) + + # 3. Attempt to read with the candidates in order + for reader_class in candidate_readers: + output_mesh = _read_data( filepath, reader_class ) if output_mesh: return output_mesh - # No reader did work. - err_msg = f"Could not find the appropriate VTK reader for file \"{vtk_input_file}\"." - logging.error( err_msg ) - raise ValueError( err_msg ) + raise ValueError( f"Could not find a suitable reader for '{filepath}'." ) -def __write_vtk( mesh: vtkUnstructuredGrid, output: str ) -> int: - logging.info( f"Writing mesh into file \"{output}\" using legacy format." ) - writer = vtkUnstructuredGridWriter() - writer.SetFileName( output ) - writer.SetInputData( mesh ) - return writer.Write() +def read_unstructured_grid( filepath: str ) -> vtkUnstructuredGrid: + """ + Reads a VTK file and ensures it is a vtkUnstructuredGrid. -def __write_vts( mesh: vtkStructuredGrid, output: str, toBinary: bool = False ) -> int: - logging.info( f"Writing mesh into file \"{output}\" using XML format." ) - writer = vtkXMLStructuredGridWriter() - writer.SetFileName( output ) - writer.SetInputData( mesh ) - writer.SetDataModeToBinary() if toBinary else writer.SetDataModeToAscii() - return writer.Write() + This function uses the general `read_mesh` to load the data and then + validates its type. + Args: + filepath (str): The path to the VTK file. -def __write_vtu( mesh: vtkUnstructuredGrid, output: str, toBinary: bool = False ) -> int: - logging.info( f"Writing mesh into file \"{output}\" using XML format." ) - writer = vtkXMLUnstructuredGridWriter() - writer.SetFileName( output ) - writer.SetInputData( mesh ) - writer.SetDataModeToBinary() if toBinary else writer.SetDataModeToAscii() - return writer.Write() + Raises: + FileNotFoundError: If the input file does not exist. + ValueError: If no suitable reader can be found for the file. + TypeError: If the file is read successfully but is not a vtkUnstructuredGrid. + Returns: + vtkUnstructuredGrid: The resulting unstructured grid data. + """ + io_logger.info( f"Reading file '{filepath}' and expecting vtkUnstructuredGrid." ) + mesh = read_mesh( filepath ) + + if not isinstance( mesh, vtkUnstructuredGrid ): + error_msg = ( f"File '{filepath}' was read successfully, but it is of type " + f"'{type(mesh).__name__}', not the expected vtkUnstructuredGrid." ) + io_logger.error( error_msg ) + raise TypeError( error_msg ) -def write_mesh( mesh: vtkPointSet, vtk_output: VtkOutput, canOverwrite: bool = False ) -> int: - """Write mesh to disk. + io_logger.info( "Validation successful. Mesh is a vtkUnstructuredGrid." ) + return mesh + + +def write_mesh( mesh: vtkPointSet, vtk_output: VtkOutput, can_overwrite: bool = False ) -> int: + """ + Writes a vtkPointSet to a file. - Nothing is done if file already exists. + The format is determined by the file extension in `VtkOutput.output`. Args: - mesh (vtkPointSet): Grid to write - vtk_output (VtkOutput): File path. File extension will be used to select VTK file format - canOverwrite (bool, optional): Authorize overwriting the file. Defaults to False. + mesh (vtkPointSet): The grid data to write. + vtk_output (VtkOutput): Configuration for the output file. + can_overwrite (bool, optional): If False, raises an error if the file + already exists. Defaults to False. Raises: - ValueError: Invalid VTK format. + FileExistsError: If the output file exists and `can_overwrite` is False. + ValueError: If the file extension is not a supported write format. + RuntimeError: If the VTK writer fails to write the file. Returns: - int: 0 if success + int: Returns 1 on success, consistent with the VTK writer's return code. """ - if os.path.exists( vtk_output.output ) and canOverwrite: - logging.error( f"File \"{vtk_output.output}\" already exists, nothing done." ) - return 1 - file_extension = os.path.splitext( vtk_output.output )[ -1 ] - if file_extension == ".vtk": - success_code = __write_vtk( mesh, vtk_output.output ) - elif file_extension == ".vts": - success_code = __write_vts( mesh, vtk_output.output, vtk_output.is_data_mode_binary ) - elif file_extension == ".vtu": - success_code = __write_vtu( mesh, vtk_output.output, vtk_output.is_data_mode_binary ) - else: - # No writer found did work. Dying. - err_msg = f"Could not find the appropriate VTK writer for extension \"{file_extension}\"." - logging.error( err_msg ) - raise ValueError( err_msg ) - return 0 if success_code else 2 # the Write member function return 1 in case of success, 0 otherwise. + output_path = Path( vtk_output.output ) + if output_path.exists() and not can_overwrite: + raise FileExistsError( f"File '{output_path}' already exists. Set can_overwrite=True to replace it." ) + + try: + # Catch the ValueError from an invalid enum to provide a consistent error message. + try: + file_format = VtkFormat( output_path.suffix ) + except ValueError: + # Re-raise with the message expected by the test. + raise ValueError( f"Writing to extension '{output_path.suffix}' is not supported." ) + + writer_class = WRITER_MAP.get( file_format ) + if not writer_class: + raise ValueError( f"Writing to extension '{output_path.suffix}' is not supported." ) + + success_code = _write_data( mesh, writer_class, str( output_path ), vtk_output.is_data_mode_binary ) + if not success_code: + raise RuntimeError( f"VTK writer failed to write file '{output_path}'." ) + + io_logger.info( f"Successfully wrote mesh to '{output_path}'." ) + return success_code + + except ( ValueError, RuntimeError ) as e: + io_logger.error( e ) + raise diff --git a/geos-mesh/src/geos/mesh/utils/genericHelpers.py b/geos-mesh/src/geos/mesh/utils/genericHelpers.py index de0624fd9..6684d1252 100644 --- a/geos-mesh/src/geos/mesh/utils/genericHelpers.py +++ b/geos-mesh/src/geos/mesh/utils/genericHelpers.py @@ -6,8 +6,10 @@ from typing import Iterator, List, Sequence, Any, Union from vtkmodules.util.numpy_support import numpy_to_vtk from vtkmodules.vtkCommonCore import vtkIdList, vtkPoints, reference -from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, vtkMultiBlockDataSet, vtkPolyData, vtkDataSet, vtkDataObject, vtkPlane, vtkCellTypes, vtkIncrementalOctreePointLocator -from vtkmodules.vtkFiltersCore import vtk3DLinearGridPlaneCutter +from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkMultiBlockDataSet, vtkPolyData, vtkDataSet, + vtkDataObject, vtkPlane, vtkCellTypes, vtkIncrementalOctreePointLocator, + vtkStaticPointLocator ) +from vtkmodules.vtkFiltersCore import vtk3DLinearGridPlaneCutter, vtkCellCenters from geos.mesh.utils.multiblockHelpers import ( getBlockElementIndexesFlatten, getBlockFromFlatIndex ) __doc__ = """ @@ -85,6 +87,78 @@ def extractSurfaceFromElevation( mesh: vtkUnstructuredGrid, elevation: float ) - return cutter.GetOutputDataObject( 0 ) +def findUniqueCellCenterCellIds( grid1: vtkUnstructuredGrid, + grid2: vtkUnstructuredGrid, + tolerance: float = 1e-6 ) -> tuple[ list[ int ], list[ int ] ]: + """ + Compares two vtkUnstructuredGrids and finds the IDs of cells with unique centers. + + This function identifies cells whose centers exist in one grid but not the other, + within a specified floating-point tolerance. + + Args: + grid1 (vtk.vtkUnstructuredGrid): The first grid. + grid2 (vtk.vtkUnstructuredGrid): The second grid. + tolerance (float): The distance threshold to consider two points as the same. + + Returns: + tuple[list[int], list[int]]: A tuple containing two lists: + - The first list has the IDs of cells with centers unique to grid1. + - The second list has the IDs of cells with centers unique to grid2. + """ + if not grid1 or not grid2: + raise ValueError( "Input grids must be valid vtkUnstructuredGrid objects." ) + + # Generate cell centers for both grids using vtkCellCenters filter + centersFilter1 = vtkCellCenters() + centersFilter1.SetInputData( grid1 ) + centersFilter1.Update() + centers1 = centersFilter1.GetOutput().GetPoints() + + centersFilter2 = vtkCellCenters() + centersFilter2.SetInputData( grid2 ) + centersFilter2.Update() + centers2 = centersFilter2.GetOutput().GetPoints() + + # Find cells with centers that are unique to grid1 + uniqueIdsInGrid1: list[ int ] = [] + uniqueIdsCoordsInGrid1: list[ tuple[ float, float, float ] ] = [] + # Build a locator for the cell centers of grid2 for fast searching + locator2 = vtkStaticPointLocator() + locator2.SetDataSet( centersFilter2.GetOutput() ) + locator2.BuildLocator() + + for i in range( centers1.GetNumberOfPoints() ): + centerPt1 = centers1.GetPoint( i ) + # Find the closest point in grid2 to the current center from grid1 + result = vtkIdList() + locator2.FindPointsWithinRadius( tolerance, centerPt1, result ) + # If no point is found within the tolerance radius, the cell center is unique + if result.GetNumberOfIds() == 0: + uniqueIdsInGrid1.append( i ) + uniqueIdsCoordsInGrid1.append( centerPt1 ) + + # Find cells with centers that are unique to grid2 + uniqueIdsInGrid2: list[ int ] = [] + uniqueIdsCoordsInGrid2: list[ tuple[ float, float, float ] ] = [] + # Build a locator for the cell centers of grid1 for fast searching + locator1 = vtkStaticPointLocator() + locator1.SetDataSet( centersFilter1.GetOutput() ) + locator1.BuildLocator() + + for i in range( centers2.GetNumberOfPoints() ): + centerPt2 = centers2.GetPoint( i ) + # Find the closest point in grid1 to the current center from grid2 + result = vtkIdList() + locator1.FindPointsWithinRadius( tolerance, centerPt2, result ) + # If no point is found, it's unique to grid2 + if result.GetNumberOfIds() == 0: + uniqueIdsInGrid2.append( i ) + uniqueIdsCoordsInGrid2.append( centerPt2 ) + + return uniqueIdsInGrid1, uniqueIdsInGrid2, uniqueIdsCoordsInGrid1, uniqueIdsCoordsInGrid2 + + def getBounds( input: Union[ vtkUnstructuredGrid, vtkMultiBlockDataSet ] ) -> tuple[ float, float, float, float, float, float ]: diff --git a/geos-mesh/tests/test_Checks.py b/geos-mesh/tests/test_Checks.py new file mode 100644 index 000000000..458f496e2 --- /dev/null +++ b/geos-mesh/tests/test_Checks.py @@ -0,0 +1,514 @@ +import pytest +import numpy as np +from unittest.mock import patch, MagicMock +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_TETRA, VTK_HEXAHEDRON +from geos.mesh.utils.genericHelpers import createSingleCellMesh, createMultiCellMesh +from geos.mesh.doctor.filters.Checks import Checks, AllChecks, MainChecks, allChecks, mainChecks +from geos.mesh.doctor.actions.all_checks import Options + +__doc__ = """ +Test module for Checks filters. +Tests the functionality of AllChecks, MainChecks, and base Checks class for mesh validation. +""" + + +@pytest.fixture( scope="module" ) +def simple_hex_mesh() -> vtkUnstructuredGrid: + """Fixture for a simple hexahedron mesh.""" + return createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def simple_tetra_mesh() -> vtkUnstructuredGrid: + """Fixture for a simple tetrahedron mesh.""" + return createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def mixed_quality_mesh() -> vtkUnstructuredGrid: + """Fixture for a mesh with elements of varying quality.""" + return createMultiCellMesh( [ VTK_TETRA, VTK_TETRA ], [ + np.array( [ [ 0.0, 0.0, 0.0 ], [ 1.0, 0.0, 0.0 ], [ 0.5, 1.0, 0.0 ], [ 0.5, 0.5, 1.0 ] ] ), + np.array( [ [ 2.0, 0.0, 0.0 ], [ 5.0, 0.0, 0.0 ], [ 3.5, 0.1, 0.0 ], [ 3.5, 0.05, 0.05 ] ] ) + ] ) + + +@pytest.fixture( scope="module" ) +def mesh_with_collocated_nodes() -> vtkUnstructuredGrid: + """Fixture for a mesh with collocated (duplicate) nodes.""" + return createMultiCellMesh( [ VTK_TETRA, VTK_TETRA ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ), + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) + ] ) + + +class TestChecksBase: + """Test class for base Checks filter functionality.""" + + def test_base_checks_initialization( self, simple_hex_mesh ): + """Test basic initialization of Checks base class.""" + # Create mock configurations for testing + mock_check_features = { "test_check": MagicMock( default_params={ "param1": 1.0, "param2": "value" } ) } + mock_ordered_names = [ "test_check" ] + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "test_check" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=mock_ordered_names ) + + assert filter_instance.checksToPerform == [ "test_check" ] + assert filter_instance.checkParameters == {} + assert filter_instance.checkResults == {} + assert filter_instance.checkFeaturesConfig == mock_check_features + assert filter_instance.orderedCheckNames == mock_ordered_names + + # Test getMesh returns a copy + processed_mesh = filter_instance.getMesh() + assert processed_mesh is not simple_hex_mesh + assert processed_mesh.GetNumberOfPoints() == simple_hex_mesh.GetNumberOfPoints() + assert processed_mesh.GetNumberOfCells() == simple_hex_mesh.GetNumberOfCells() + + def test_parameter_setting( self, simple_hex_mesh ): + """Test setting parameters for checks.""" + mock_check_features = { + "check1": MagicMock( default_params={ + "tolerance": 1e-6, + "param2": "default" + } ), + "check2": MagicMock( default_params={ "minValue": 0.0 } ) + } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "check1", "check2" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "check1", "check2" ] ) + + # Test setting individual check parameter + filter_instance.setCheckParameter( "check1", "tolerance", 1e-8 ) + assert filter_instance.checkParameters[ "check1" ][ "tolerance" ] == 1e-8 + + # Test setting parameter for all checks + filter_instance.setAllChecksParameter( "tolerance", 1e-10 ) + assert filter_instance.checkParameters[ "check1" ][ "tolerance" ] == 1e-10 + # check2 doesn't have tolerance parameter, so it shouldn't be set + assert "check2" not in filter_instance.checkParameters or \ + "tolerance" not in filter_instance.checkParameters.get("check2", {}) + + def test_get_available_checks( self, simple_hex_mesh ): + """Test getting available checks.""" + mock_check_features = { "check1": MagicMock(), "check2": MagicMock() } + ordered_names = [ "check1", "check2" ] + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "check1" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=ordered_names ) + + available = filter_instance.getAvailableChecks() + assert available == ordered_names + + def test_get_default_parameters( self, simple_hex_mesh ): + """Test getting default parameters for a check.""" + default_params = { "tolerance": 1e-6, "minValue": 0.0 } + mock_check_features = { "test_check": MagicMock( default_params=default_params ) } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "test_check" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "test_check" ] ) + + params = filter_instance.getDefaultParameters( "test_check" ) + assert params == default_params + + # Test non-existent check + params = filter_instance.getDefaultParameters( "nonexistent" ) + assert params == {} + + def test_set_checks_to_perform( self, simple_hex_mesh ): + """Test changing which checks to perform.""" + mock_check_features = { "check1": MagicMock(), "check2": MagicMock(), "check3": MagicMock() } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "check1" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "check1", "check2", "check3" ] ) + + assert filter_instance.checksToPerform == [ "check1" ] + + filter_instance.setChecksToPerform( [ "check2", "check3" ] ) + assert filter_instance.checksToPerform == [ "check2", "check3" ] + + @patch( 'geos.mesh.doctor.filters.Checks.get_check_results' ) + @patch( 'geos.mesh.doctor.filters.Checks.display_results' ) + def test_apply_filter_success( self, mock_display, mock_get_results, simple_hex_mesh ): + """Test successful filter application.""" + # Mock the check results + mock_results = { "test_check": { "status": "passed", "details": "All good" } } + mock_get_results.return_value = mock_results + + mock_check_features = { + "test_check": + MagicMock( default_params={ "tolerance": 1e-6 }, options_cls=MagicMock( return_value=MagicMock() ) ) + } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "test_check" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "test_check" ] ) + + success = filter_instance.applyFilter() + assert success + assert filter_instance.getCheckResults() == mock_results + + # Verify that get_check_results was called with proper arguments + mock_get_results.assert_called_once() + args = mock_get_results.call_args[ 0 ] + assert isinstance( args[ 0 ], vtkUnstructuredGrid ) + assert args[ 0 ] is not simple_hex_mesh # mesh argument + assert isinstance( args[ 1 ], Options ) # options argument + + +class TestAllChecks: + """Test class for AllChecks filter functionality.""" + + def test_all_checks_initialization( self, simple_hex_mesh ): + """Test AllChecks filter initialization.""" + filter_instance = AllChecks( simple_hex_mesh ) + + # AllChecks should have all available checks configured + assert len( filter_instance.checksToPerform ) > 0 + assert len( filter_instance.checkFeaturesConfig ) > 0 + assert len( filter_instance.orderedCheckNames ) > 0 + + # Check that checksToPerform matches orderedCheckNames for AllChecks + assert filter_instance.checksToPerform == filter_instance.orderedCheckNames + + def test_all_checks_with_external_logger( self, simple_hex_mesh ): + """Test AllChecks initialization with external logger.""" + filter_instance = AllChecks( simple_hex_mesh, useExternalLogger=True ) + assert filter_instance is not None + + @patch( 'geos.mesh.doctor.filters.Checks.get_check_results' ) + @patch( 'geos.mesh.doctor.filters.Checks.display_results' ) + def test_all_checks_apply_filter( self, mock_display, mock_get_results, simple_hex_mesh ): + """Test applying AllChecks filter.""" + # Mock successful check results + mock_results = { + "element_volumes": { + "status": "passed" + }, + "collocated_nodes": { + "status": "passed" + }, + } + mock_get_results.return_value = mock_results + + filter_instance = AllChecks( simple_hex_mesh ) + success = filter_instance.applyFilter() + + assert success + results = filter_instance.getCheckResults() + assert results == mock_results + + +class TestMainChecks: + """Test class for MainChecks filter functionality.""" + + def test_main_checks_initialization( self, simple_hex_mesh ): + """Test MainChecks filter initialization.""" + filter_instance = MainChecks( simple_hex_mesh ) + + # MainChecks should have a subset of checks + assert len( filter_instance.checksToPerform ) > 0 + assert len( filter_instance.checkFeaturesConfig ) > 0 + assert len( filter_instance.orderedCheckNames ) > 0 + + # Check that checksToPerform matches orderedCheckNames for MainChecks + assert filter_instance.checksToPerform == filter_instance.orderedCheckNames + + def test_main_checks_with_external_logger( self, simple_hex_mesh ): + """Test MainChecks initialization with external logger.""" + filter_instance = MainChecks( simple_hex_mesh, useExternalLogger=True ) + assert filter_instance is not None + + @patch( 'geos.mesh.doctor.filters.Checks.get_check_results' ) + @patch( 'geos.mesh.doctor.filters.Checks.display_results' ) + def test_main_checks_apply_filter( self, mock_display, mock_get_results, simple_hex_mesh ): + """Test applying MainChecks filter.""" + # Mock successful check results + mock_results = { + "element_volumes": { + "status": "passed" + }, + "collocated_nodes": { + "status": "passed" + }, + } + mock_get_results.return_value = mock_results + + filter_instance = MainChecks( simple_hex_mesh ) + success = filter_instance.applyFilter() + + assert success + results = filter_instance.getCheckResults() + assert results == mock_results + + def test_main_vs_all_checks_difference( self, simple_hex_mesh ): + """Test that MainChecks and AllChecks have different check sets.""" + all_checks_filter = AllChecks( simple_hex_mesh ) + main_checks_filter = MainChecks( simple_hex_mesh ) + + # MainChecks should typically be a subset of AllChecks + all_checks_set = set( all_checks_filter.checksToPerform ) + main_checks_set = set( main_checks_filter.checksToPerform ) + + # Main checks should be a subset (or equal) to all checks + assert main_checks_set.issubset( all_checks_set ) or main_checks_set == all_checks_set + + +class TestStandaloneFunctions: + """Test class for standalone allChecks and mainChecks functions.""" + + @patch( 'geos.mesh.doctor.filters.Checks.AllChecks' ) + def test_all_checks_function( self, mock_all_checks_class, simple_hex_mesh ): + """Test standalone allChecks function.""" + # Mock the filter instance + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = True + mock_filter.getMesh.return_value = simple_hex_mesh + mock_filter.getCheckResults.return_value = { "test": "results" } + mock_all_checks_class.return_value = mock_filter + + # Test without custom parameters + result_mesh, results = allChecks( simple_hex_mesh ) + + assert result_mesh == simple_hex_mesh + assert results == { "test": "results" } + mock_all_checks_class.assert_called_once_with( simple_hex_mesh ) + mock_filter.applyFilter.assert_called_once() + + @patch( 'geos.mesh.doctor.filters.Checks.AllChecks' ) + def test_all_checks_function_with_parameters( self, mock_all_checks_class, simple_hex_mesh ): + """Test standalone allChecks function with custom parameters.""" + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = True + mock_filter.getMesh.return_value = simple_hex_mesh + mock_filter.getCheckResults.return_value = { "test": "results" } + mock_all_checks_class.return_value = mock_filter + + custom_params = { "collocated_nodes": { "tolerance": 1e-8 }, "element_volumes": { "minVolume": 0.1 } } + + result_mesh, results = allChecks( simple_hex_mesh, custom_params ) + + assert result_mesh == simple_hex_mesh + assert results == { "test": "results" } + + # Verify custom parameters were set + mock_filter.setCheckParameter.assert_any_call( "collocated_nodes", "tolerance", 1e-8 ) + mock_filter.setCheckParameter.assert_any_call( "element_volumes", "minVolume", 0.1 ) + + @patch( 'geos.mesh.doctor.filters.Checks.AllChecks' ) + def test_all_checks_function_failure( self, mock_all_checks_class, simple_hex_mesh ): + """Test standalone allChecks function with filter failure.""" + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = False + mock_all_checks_class.return_value = mock_filter + + with pytest.raises( RuntimeError, match="allChecks calculation failed" ): + allChecks( simple_hex_mesh ) + + @patch( 'geos.mesh.doctor.filters.Checks.MainChecks' ) + def test_main_checks_function( self, mock_main_checks_class, simple_hex_mesh ): + """Test standalone mainChecks function.""" + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = True + mock_filter.getMesh.return_value = simple_hex_mesh + mock_filter.getCheckResults.return_value = { "test": "results" } + mock_main_checks_class.return_value = mock_filter + + # Test without custom parameters + result_mesh, results = mainChecks( simple_hex_mesh ) + + assert result_mesh == simple_hex_mesh + assert results == { "test": "results" } + mock_main_checks_class.assert_called_once_with( simple_hex_mesh ) + mock_filter.applyFilter.assert_called_once() + + @patch( 'geos.mesh.doctor.filters.Checks.MainChecks' ) + def test_main_checks_function_with_parameters( self, mock_main_checks_class, simple_hex_mesh ): + """Test standalone mainChecks function with custom parameters.""" + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = True + mock_filter.getMesh.return_value = simple_hex_mesh + mock_filter.getCheckResults.return_value = { "test": "results" } + mock_main_checks_class.return_value = mock_filter + + custom_params = { "element_volumes": { "minVolume": 0.05 } } + + result_mesh, results = mainChecks( simple_hex_mesh, custom_params ) + + assert result_mesh == simple_hex_mesh + assert results == { "test": "results" } + + # Verify custom parameters were set + mock_filter.setCheckParameter.assert_called_with( "element_volumes", "minVolume", 0.05 ) + + @patch( 'geos.mesh.doctor.filters.Checks.MainChecks' ) + def test_main_checks_function_failure( self, mock_main_checks_class, simple_hex_mesh ): + """Test standalone mainChecks function with filter failure.""" + mock_filter = MagicMock() + mock_filter.applyFilter.return_value = False + mock_main_checks_class.return_value = mock_filter + + with pytest.raises( RuntimeError, match="mainChecks calculation failed" ): + mainChecks( simple_hex_mesh ) + + +class TestFileIO: + """Test class for file I/O operations with checks.""" + + def test_write_grid_functionality( self, simple_hex_mesh, tmp_path ): + """Test writing mesh to file using tmp_path.""" + filter_instance = AllChecks( simple_hex_mesh ) + + output_file = tmp_path / "test_output.vtu" + + # The writeGrid method should be inherited from MeshDoctorFilterBase + # We'll test that the method exists and can be called + assert hasattr( filter_instance, 'writeGrid' ) + + # Test calling writeGrid (it should not raise an exception) + try: + filter_instance.writeGrid( str( output_file ) ) + # If the file was created, verify it exists + if output_file.exists(): + assert output_file.stat().st_size > 0 + except Exception: + # Some implementations might require the filter to be applied first + filter_instance.applyFilter() + filter_instance.writeGrid( str( output_file ) ) + + +class TestOptionsBuilding: + """Test class for options building functionality.""" + + def test_build_options_with_defaults( self, simple_hex_mesh ): + """Test building options with default parameters.""" + mock_options_cls = MagicMock( return_value=MagicMock() ) + mock_check_features = { + "test_check": MagicMock( default_params={ + "param1": 1.0, + "param2": "default" + }, + options_cls=mock_options_cls ) + } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "test_check" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "test_check" ] ) + + options = filter_instance._buildOptions() + + assert isinstance( options, Options ) + assert "test_check" in options.checks_to_perform + # Verify options_cls was called with default parameters + mock_options_cls.assert_called_once_with( param1=1.0, param2="default" ) + + def test_build_options_with_failing_options_creation( self, simple_hex_mesh ): + """Test building options when options class instantiation fails.""" + mock_options_cls = MagicMock( side_effect=Exception( "Options creation failed" ) ) + mock_check_features = { + "failing_check": MagicMock( default_params={}, options_cls=mock_options_cls ), + "working_check": MagicMock( default_params={}, options_cls=MagicMock( return_value=MagicMock() ) ) + } + + filter_instance = Checks( simple_hex_mesh, + checksToPerform=[ "failing_check", "working_check" ], + checkFeaturesConfig=mock_check_features, + orderedCheckNames=[ "failing_check", "working_check" ] ) + + options = filter_instance._buildOptions() + + # Only working check should be in the options + assert options.checks_to_perform == [ "working_check" ] + assert "failing_check" not in options.checks_to_perform + + +class TestErrorHandling: + """Test class for error handling scenarios.""" + + def test_empty_checks_list( self, simple_hex_mesh ): + """Test handling of empty checks list.""" + filter_instance = Checks( simple_hex_mesh, checksToPerform=[], checkFeaturesConfig={}, orderedCheckNames=[] ) + + options = filter_instance._buildOptions() + assert options.checks_to_perform == [] + + def test_mesh_copy_integrity( self, simple_hex_mesh ): + """Test that mesh copy maintains integrity.""" + filter_instance = AllChecks( simple_hex_mesh ) + + copied_mesh = filter_instance.getMesh() + + # Verify the copy has the same structure + assert copied_mesh.GetNumberOfPoints() == simple_hex_mesh.GetNumberOfPoints() + assert copied_mesh.GetNumberOfCells() == simple_hex_mesh.GetNumberOfCells() + + # Verify it's actually a copy, not the same object + assert copied_mesh is not simple_hex_mesh + + # Verify point coordinates are the same + for i in range( simple_hex_mesh.GetNumberOfPoints() ): + original_point = simple_hex_mesh.GetPoint( i ) + copied_point = copied_mesh.GetPoint( i ) + np.testing.assert_array_equal( original_point, copied_point ) + + +class TestIntegrationScenarios: + """Test class for integration scenarios with different mesh types.""" + + @patch( 'geos.mesh.doctor.filters.Checks.get_check_results' ) + @patch( 'geos.mesh.doctor.filters.Checks.display_results' ) + def test_complex_parameter_workflow( self, mock_display, mock_get_results, mixed_quality_mesh ): + """Test complex parameter setting workflow.""" + mock_results = { "element_volumes": { "status": "warning", "issues": 1 } } + mock_get_results.return_value = mock_results + + filter_instance = AllChecks( mixed_quality_mesh ) + + # Set various parameters + filter_instance.setCheckParameter( "element_volumes", "minVolume", 0.01 ) + filter_instance.setAllChecksParameter( "tolerance", 1e-10 ) + filter_instance.setCheckParameter( "collocated_nodes", "tolerance", 1e-12 ) + + success = filter_instance.applyFilter() + assert success + + results = filter_instance.getCheckResults() + assert results == mock_results + + def test_multiple_filter_instances( self, simple_hex_mesh, simple_tetra_mesh ): + """Test using multiple filter instances simultaneously.""" + all_checks_filter = AllChecks( simple_hex_mesh ) + main_checks_filter = MainChecks( simple_tetra_mesh ) + + # Both should be independent + assert all_checks_filter.getMesh() is not main_checks_filter.getMesh() + assert all_checks_filter.checksToPerform != main_checks_filter.checksToPerform or \ + len(all_checks_filter.checksToPerform) >= len(main_checks_filter.checksToPerform) + + def test_parameter_isolation( self, simple_hex_mesh ): + """Test that parameters are isolated between filter instances.""" + filter1 = AllChecks( simple_hex_mesh ) + filter2 = AllChecks( simple_hex_mesh ) + + filter1.setCheckParameter( "element_volumes", "minVolume", 0.1 ) + filter2.setCheckParameter( "element_volumes", "minVolume", 0.2 ) + + # Parameters should be independent + assert filter1.checkParameters[ "element_volumes" ][ "minVolume" ] == 0.1 + assert filter2.checkParameters[ "element_volumes" ][ "minVolume" ] == 0.2 diff --git a/geos-mesh/tests/test_CollocatedNodes.py b/geos-mesh/tests/test_CollocatedNodes.py new file mode 100644 index 000000000..f19fdee91 --- /dev/null +++ b/geos-mesh/tests/test_CollocatedNodes.py @@ -0,0 +1,165 @@ +import pytest +import numpy as np +import os +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_TRIANGLE, VTK_HEXAHEDRON +from geos.mesh.doctor.filters.CollocatedNodes import CollocatedNodes, collocatedNodes +from geos.mesh.utils.genericHelpers import createMultiCellMesh + +__doc__ = """ +Test module for CollocatedNodes filter. +Tests the functionality of detecting and handling collocated/duplicated nodes in meshes. +""" + + +@pytest.fixture( scope="module" ) +def mesh_with_collocated_nodes(): + """Fixture for a mesh with exactly duplicated and nearly collocated nodes.""" + mesh = createMultiCellMesh( [ VTK_HEXAHEDRON, VTK_HEXAHEDRON ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ), + np.array( [ [ 1, 0, 0 ], [ 2, 0, 0 ], [ 2, 1, 0 ], [ 1, 1, 0 ], [ 1, 0, 1 ], [ 2, 0, 1 ], [ 2, 1, 1 ], + [ 1, 1, 1 ] ] ) + ] ) + points = mesh.GetPoints() + + # Add nodes to create collocated situations: + # 1. Exact duplicate of point 0 + points.InsertNextPoint( 0.0, 0.0, 0.0 ) + # 2. Exact duplicate of point 1 + points.InsertNextPoint( 1.0, 0.0, 0.0 ) + # 3. A point very close to an existing point (2, 0, 0) + points.InsertNextPoint( 2.0, 0.0, 1e-8 ) + + return mesh + + +@pytest.fixture( scope="module" ) +def mesh_with_wrong_support() -> vtkUnstructuredGrid: + """Fixture for a mesh containing a cell with repeated node indices.""" + return createMultiCellMesh( [ VTK_TRIANGLE, VTK_TRIANGLE ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 0, 0 ] ] ), + np.array( [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 1, 1, 0 ] ] ) + ] ) + + +@pytest.fixture( scope="module" ) +def clean_mesh() -> vtkUnstructuredGrid: + """Fixture for a simple, valid mesh with no issues.""" + return createMultiCellMesh( [ VTK_HEXAHEDRON, VTK_HEXAHEDRON ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ), + np.array( [ [ 1, 0, 0 ], [ 2, 0, 0 ], [ 2, 1, 0 ], [ 1, 1, 0 ], [ 1, 0, 1 ], [ 2, 0, 1 ], [ 2, 1, 1 ], + [ 1, 1, 1 ] ] ) + ] ) + + +def test_filter_on_clean_mesh( clean_mesh ): + """Verify the filter runs correctly on a mesh with no issues.""" + filter_instance = CollocatedNodes( clean_mesh, writeWrongSupportElements=True ) + assert filter_instance.applyFilter() + + # Assert that no issues were found + assert not filter_instance.getCollocatedNodeBuckets() + assert not filter_instance.getWrongSupportElements() + + # Assert that no "WrongSupportElements" array was added if none were found + output_mesh = filter_instance.getMesh() + assert output_mesh.GetCellData().GetArray( "WrongSupportElements" ) is None + + +def test_filter_detection_and_bucket_structure( mesh_with_collocated_nodes ): + """Test basic detection and validate the structure of the result.""" + filter_instance = CollocatedNodes( mesh_with_collocated_nodes, tolerance=1e-7 ) + assert filter_instance.applyFilter() + + collocated_buckets = filter_instance.getCollocatedNodeBuckets() + + # Verify that collocated nodes were detected + assert len( collocated_buckets ) > 0 + + # Verify the structure of the output + for bucket in collocated_buckets: + assert isinstance( bucket, tuple ) + assert len( bucket ) >= 2 + for node_id in bucket: + assert isinstance( node_id, ( int, np.integer ) ) + assert 0 <= node_id < mesh_with_collocated_nodes.GetNumberOfPoints() + + +@pytest.mark.parametrize( + "tolerance, expected_min_buckets", + [ + ( 0.0, 2 ), # Zero tolerance should only find the 2 exact duplicates. + ( 1e-10, 2 ), # Strict tolerance should also only find exacts. + ( 1e-7, 3 ), # Looser tolerance should find the "nearby" node as well. + ( 10.0, 1 ) # Large tolerance should group many nodes together. + ] ) +def test_filter_tolerance_effects( mesh_with_collocated_nodes, tolerance, expected_min_buckets ): + """Test how different tolerance values affect detection.""" + filter_instance = CollocatedNodes( mesh_with_collocated_nodes, tolerance=tolerance ) + filter_instance.applyFilter() + collocated_buckets = filter_instance.getCollocatedNodeBuckets() + + # The number of buckets found should be consistent with the tolerance + assert len( collocated_buckets ) >= expected_min_buckets + + +def test_filter_wrong_support_elements( mesh_with_wrong_support ): + """Test the detection of cells with repeated nodes (wrong support).""" + filter_instance = CollocatedNodes( mesh_with_wrong_support, writeWrongSupportElements=True ) + assert filter_instance.applyFilter() + + # Verify the wrong support element was identified + wrong_support_elements = filter_instance.getWrongSupportElements() + assert len( wrong_support_elements ) == 1 + assert 0 in wrong_support_elements + + # Verify the corresponding data array was added to the mesh + output_mesh = filter_instance.getMesh() + wrong_support_array = output_mesh.GetCellData().GetArray( "WrongSupportElements" ) + assert wrong_support_array is not None + assert wrong_support_array.GetNumberOfTuples() == mesh_with_wrong_support.GetNumberOfCells() + + +def test_filter_mesh_integrity( mesh_with_collocated_nodes ): + """Ensure the filter only adds data arrays and doesn't alter mesh geometry/topology.""" + original_points = mesh_with_collocated_nodes.GetNumberOfPoints() + original_cells = mesh_with_collocated_nodes.GetNumberOfCells() + + filter_instance = CollocatedNodes( mesh_with_collocated_nodes ) + filter_instance.applyFilter() + output_mesh = filter_instance.getMesh() + + assert output_mesh.GetNumberOfPoints() == original_points + assert output_mesh.GetNumberOfCells() == original_cells + + +@pytest.mark.parametrize( + "mesh_fixture, write_support, check_collocated, check_wrong_support", + [ + # Scenario 1: Find collocated nodes + ( "mesh_with_collocated_nodes", False, True, False ), + # Scenario 2: Find wrong support elements + ( "mesh_with_wrong_support", True, False, True ), + ] ) +def test_standalone_function( tmpdir, request, mesh_fixture, write_support, check_collocated, check_wrong_support ): + """Test the standalone collocatedNodes wrapper function.""" + # Request the fixture by its name string passed from parametrize + input_mesh = request.getfixturevalue( mesh_fixture ) + output_path = os.path.join( str( tmpdir ), "output.vtu" ) + + output_mesh, buckets, wrong_support = collocatedNodes( input_mesh, + outputPath=output_path, + tolerance=1e-6, + writeWrongSupportElements=write_support ) + + # General assertions for all runs + assert output_mesh is not None + assert os.path.exists( output_path ) + + # Scenario-specific assertions + if check_collocated: + assert len( buckets ) > 0 + if check_wrong_support: + assert len( wrong_support ) > 0 + assert output_mesh.GetCellData().GetArray( "WrongSupportElements" ) is not None diff --git a/geos-mesh/tests/test_ElementVolumes.py b/geos-mesh/tests/test_ElementVolumes.py new file mode 100644 index 000000000..279896711 --- /dev/null +++ b/geos-mesh/tests/test_ElementVolumes.py @@ -0,0 +1,445 @@ +import pytest +import numpy as np +from vtkmodules.vtkCommonCore import vtkPoints +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_TETRA, VTK_TRIANGLE, VTK_HEXAHEDRON +from geos.mesh.utils.genericHelpers import to_vtk_id_list, createSingleCellMesh, createMultiCellMesh +from geos.mesh.doctor.filters.ElementVolumes import ElementVolumes, elementVolumes + +__doc__ = """ +Test module for ElementVolumes filter. +Tests the functionality of calculating element volumes and detecting problematic elements. +""" + + +@pytest.fixture( scope="module" ) +def simple_hex_mesh(): + """Fixture for a simple hexahedron mesh with known volumes.""" + return createMultiCellMesh( [ VTK_HEXAHEDRON, VTK_HEXAHEDRON ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ), + np.array( [ [ 1, 0, 0 ], [ 2, 0, 0 ], [ 2, 1, 0 ], [ 1, 1, 0 ], [ 1, 0, 1 ], [ 2, 0, 1 ], [ 2, 1, 1 ], + [ 1, 1, 1 ] ] ) + ] ) + + +@pytest.fixture( scope="module" ) +def mesh_with_negative_volume(): + """Fixture for a mesh containing an element with negative volume (inverted tetrahedron).""" + mesh = vtkUnstructuredGrid() + points = vtkPoints() + mesh.SetPoints( points ) + + # Create a normal tetrahedron + points.InsertNextPoint( 0.0, 0.0, 0.0 ) # Point 0 + points.InsertNextPoint( 1.0, 0.0, 0.0 ) # Point 1 + points.InsertNextPoint( 0.0, 1.0, 0.0 ) # Point 2 + points.InsertNextPoint( 0.0, 0.0, 1.0 ) # Point 3 + + # Inverted tetrahedron with wrong node ordering (creates negative volume) + points.InsertNextPoint( 2.0, 0.0, 0.0 ) # Point 4 + points.InsertNextPoint( 3.0, 0.0, 0.0 ) # Point 5 + points.InsertNextPoint( 2.0, 1.0, 0.0 ) # Point 6 + points.InsertNextPoint( 2.0, 0.0, 1.0 ) # Point 7 + + # Normal tetrahedron [0, 1, 2, 3] + mesh.InsertNextCell( VTK_TETRA, to_vtk_id_list( [ 0, 1, 2, 3 ] ) ) + # Inverted tetrahedron [4, 6, 5, 7] - wrong ordering + mesh.InsertNextCell( VTK_TETRA, to_vtk_id_list( [ 4, 6, 5, 7 ] ) ) + + return mesh + + +@pytest.fixture( scope="module" ) +def mesh_with_zero_volume(): + """Fixture for a mesh containing degenerate elements with zero volume.""" + mesh = vtkUnstructuredGrid() + points = vtkPoints() + mesh.SetPoints( points ) + + # Degenerate tetrahedron - all points in the same plane + points.InsertNextPoint( 0.0, 0.0, 0.0 ) # Point 0 + points.InsertNextPoint( 1.0, 0.0, 0.0 ) # Point 1 + points.InsertNextPoint( 0.5, 0.5, 0.0 ) # Point 2 + points.InsertNextPoint( 0.2, 0.3, 0.0 ) # Point 3 - all points are coplanar + + # Normal tetrahedron for comparison + points.InsertNextPoint( 2.0, 0.0, 0.0 ) # Point 4 + points.InsertNextPoint( 3.0, 0.0, 0.0 ) # Point 5 + points.InsertNextPoint( 2.0, 1.0, 0.0 ) # Point 6 + points.InsertNextPoint( 2.0, 0.0, 1.0 ) # Point 7 + + # Degenerate tetrahedron (should have zero or near-zero volume) + mesh.InsertNextCell( VTK_TETRA, to_vtk_id_list( [ 0, 1, 2, 3 ] ) ) + # Normal tetrahedron + mesh.InsertNextCell( VTK_TETRA, to_vtk_id_list( [ 4, 5, 6, 7 ] ) ) + + return mesh + + +@pytest.fixture( scope="module" ) +def mixed_cell_types_mesh(): + """Fixture for a mesh with different cell types.""" + mesh = vtkUnstructuredGrid() + points = vtkPoints() + mesh.SetPoints( points ) + + # Points for triangle + points.InsertNextPoint( 0.0, 0.0, 0.0 ) # Point 0 + points.InsertNextPoint( 1.0, 0.0, 0.0 ) # Point 1 + points.InsertNextPoint( 0.5, 1.0, 0.0 ) # Point 2 + + # Points for tetrahedron + points.InsertNextPoint( 2.0, 0.0, 0.0 ) # Point 3 + points.InsertNextPoint( 3.0, 0.0, 0.0 ) # Point 4 + points.InsertNextPoint( 2.5, 1.0, 0.0 ) # Point 5 + points.InsertNextPoint( 2.5, 0.5, 1.0 ) # Point 6 + + # Add triangle (2D element) + mesh.InsertNextCell( VTK_TRIANGLE, to_vtk_id_list( [ 0, 1, 2 ] ) ) + # Add tetrahedron (3D element) + mesh.InsertNextCell( VTK_TETRA, to_vtk_id_list( [ 3, 4, 5, 6 ] ) ) + + return mesh + + +class TestElementVolumesFilter: + """Test class for ElementVolumes filter functionality.""" + + def test_filter_initialization( self, simple_hex_mesh ): + """Test basic filter initialization with different parameters.""" + # Test default initialization + filter_instance = ElementVolumes( simple_hex_mesh ) + assert filter_instance.minVolume == 0.0 + assert not filter_instance.writeIsBelowVolume + + processed_mesh = filter_instance.getMesh() + assert processed_mesh is not simple_hex_mesh + assert processed_mesh.GetNumberOfPoints() == simple_hex_mesh.GetNumberOfPoints() + assert processed_mesh.GetNumberOfCells() == simple_hex_mesh.GetNumberOfCells() + + # Test initialization with custom parameters + filter_instance = ElementVolumes( simple_hex_mesh, + minVolume=0.5, + writeIsBelowVolume=True, + useExternalLogger=True ) + assert filter_instance.minVolume == 0.5 + assert filter_instance.writeIsBelowVolume + + def test_apply_filter_success( self, simple_hex_mesh ): + """Test successful filter application on a clean mesh.""" + filter_instance = ElementVolumes( simple_hex_mesh, minVolume=0.0 ) + success = filter_instance.applyFilter() + + assert success + # Should not have any volumes below threshold of 0.0 for a normal mesh + below_volumes = filter_instance.getBelowVolumes() + assert isinstance( below_volumes, list ) + + def test_negative_volume_detection( self, mesh_with_negative_volume ): + """Test detection of elements with negative volumes.""" + filter_instance = ElementVolumes( mesh_with_negative_volume, minVolume=0.0 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # Should detect at least one element with negative volume + assert len( below_volumes ) > 0 + + # Verify structure of results + for element_id, volume in below_volumes: + assert isinstance( element_id, int ) + assert isinstance( volume, float ) + assert element_id >= 0 + assert volume < 0.0 # Should be negative + + def test_zero_volume_detection( self, mesh_with_zero_volume ): + """Test detection of elements with zero or near-zero volumes.""" + filter_instance = ElementVolumes( mesh_with_zero_volume, minVolume=1e-10 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # Should detect the degenerate element + assert len( below_volumes ) > 0 + + # Check that at least one volume is very small + volumes = [ vol for _, vol in below_volumes ] + assert any( abs( vol ) < 1e-10 for vol in volumes ) + + def test_threshold_filtering( self, simple_hex_mesh ): + """Test filtering with different volume thresholds.""" + # Test with very high threshold - should catch all elements + filter_instance = ElementVolumes( simple_hex_mesh, minVolume=100.0 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # With a high threshold, normal unit cubes should be detected + assert len( below_volumes ) > 0 + + # Test with very low threshold - should catch nothing in a normal mesh + filter_instance = ElementVolumes( simple_hex_mesh, minVolume=-100.0 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + assert len( below_volumes ) == 0 + + def test_write_below_volume_array( self, mesh_with_negative_volume ): + """Test addition of below volume threshold array to mesh.""" + filter_instance = ElementVolumes( mesh_with_negative_volume, minVolume=0.0, writeIsBelowVolume=True ) + success = filter_instance.applyFilter() + + assert success + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + + # Should have added the below volume array + expected_array_name = "BelowVolumeThresholdOf0.0" + below_volume_array = cell_data.GetArray( expected_array_name ) + + assert below_volume_array is not None + assert below_volume_array.GetNumberOfTuples() == output_mesh.GetNumberOfCells() + + # Verify array contains 0s and 1s + for i in range( below_volume_array.GetNumberOfTuples() ): + value = below_volume_array.GetValue( i ) + assert value in [ 0, 1 ] + + def test_no_array_added_when_disabled( self, mesh_with_negative_volume ): + """Test that no array is added when writeIsBelowVolume is False.""" + # FIX: Create a deep copy to prevent tests from interfering with each other. + # This solves the state leakage issue from the module-scoped fixture. + mesh_copy = vtkUnstructuredGrid() + mesh_copy.DeepCopy( mesh_with_negative_volume ) + + filter_instance = ElementVolumes( mesh_copy, minVolume=0.0, writeIsBelowVolume=False ) + success = filter_instance.applyFilter() + assert success + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + + expected_array_name = "BelowVolumeThresholdOf0.0" + below_volume_array = cell_data.GetArray( expected_array_name ) + assert below_volume_array is None + + def test_mixed_cell_types( self, mixed_cell_types_mesh ): + """Test filter behavior with mixed cell types.""" + filter_instance = ElementVolumes( mixed_cell_types_mesh, minVolume=0.0 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # Should handle mixed cell types without crashing + assert isinstance( below_volumes, list ) + + def test_get_volumes_method( self, simple_hex_mesh ): + """Test the getVolumes method returns proper volume data.""" + filter_instance = ElementVolumes( simple_hex_mesh ) + + # Before applying filter, volumes should be None + assert filter_instance.getVolumes() is None + + success = filter_instance.applyFilter() + assert success + + # After applying filter, should still be None as this method + # is not implemented to return the actual volume array + volumes = filter_instance.getVolumes() + assert volumes is None # Current implementation returns None + + def test_set_write_below_volume( self, simple_hex_mesh ): + """Test the setWriteIsBelowVolume method.""" + filter_instance = ElementVolumes( simple_hex_mesh ) + + # Initially False + assert not filter_instance.writeIsBelowVolume + + # Set to True + filter_instance.setWriteIsBelowVolume( True ) + assert filter_instance.writeIsBelowVolume + + # Set back to False + filter_instance.setWriteIsBelowVolume( False ) + assert not filter_instance.writeIsBelowVolume + + def test_write_grid_functionality( self, simple_hex_mesh, tmp_path ): + """Test writing the output mesh to file.""" + filter_instance = ElementVolumes( simple_hex_mesh ) + success = filter_instance.applyFilter() + assert success + + # Write to temporary file + output_file = tmp_path / "test_output.vtu" + filter_instance.writeGrid( str( output_file ) ) + + # Verify file was created + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +class TestElementVolumesStandaloneFunction: + """Test class for the standalone elementVolumes function.""" + + def test_standalone_function_basic( self, simple_hex_mesh, tmp_path ): + """Test basic functionality of the standalone elementVolumes function.""" + output_file = tmp_path / "standalone_output.vtu" + + mesh, volumes, below_volumes = elementVolumes( simple_hex_mesh, + str( output_file ), + minVolume=0.0, + writeIsBelowVolume=False ) + + # Verify return values + assert mesh is not None + assert isinstance( mesh, vtkUnstructuredGrid ) + assert volumes is None # Current implementation returns None + assert isinstance( below_volumes, list ) + + # Verify file was written + assert output_file.exists() + + def test_standalone_function_with_below_volume_writing( self, mesh_with_negative_volume, tmp_path ): + """Test standalone function with below volume writing enabled.""" + output_file = tmp_path / "standalone_with_array.vtu" + + mesh, volumes, below_volumes = elementVolumes( mesh_with_negative_volume, + str( output_file ), + minVolume=0.0, + writeIsBelowVolume=True ) + + assert mesh is not None + assert len( below_volumes ) > 0 + + # Check that the array was added + expected_array_name = "BelowVolumeThresholdOf0.0" + below_volume_array = mesh.GetCellData().GetArray( expected_array_name ) + assert below_volume_array is not None + + def test_standalone_function_error_handling( self, tmp_path ): + """Test error handling in the standalone function.""" + empty_mesh = vtkUnstructuredGrid() + output_file = tmp_path / "error_test.vtu" + + # FIX: The test should now expect a ValueError during initialization, + # which is better "fail-fast" behavior. + with pytest.raises( ValueError, match="Input 'mesh' cannot be empty." ): + elementVolumes( empty_mesh, str( output_file ), minVolume=0.0, writeIsBelowVolume=False ) + + def test_standalone_function_with_threshold( self, simple_hex_mesh, tmp_path ): + """Test standalone function with different volume thresholds.""" + output_file = tmp_path / "threshold_test.vtu" + + # Test with high threshold + mesh, volumes, below_volumes = elementVolumes( + simple_hex_mesh, + str( output_file ), + minVolume=10.0, # High threshold + writeIsBelowVolume=True ) + + # Should detect elements below the high threshold + assert len( below_volumes ) > 0 + + # Check that the array reflects the threshold + expected_array_name = "BelowVolumeThresholdOf10.0" + below_volume_array = mesh.GetCellData().GetArray( expected_array_name ) + assert below_volume_array is not None + + +class TestElementVolumesEdgeCases: + """Test class for edge cases and error conditions.""" + + def test_empty_mesh( self ): + """Test behavior with an empty mesh.""" + empty_mesh = vtkUnstructuredGrid() + with pytest.raises( ValueError, match="Input 'mesh' cannot be empty." ): + ElementVolumes( empty_mesh ) + + def test_single_cell_mesh( self ): + """Test with a mesh containing only one cell.""" + mesh = createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + filter_instance = ElementVolumes( mesh, minVolume=0.0 ) + success = filter_instance.applyFilter() + assert success + + below_volumes = filter_instance.getBelowVolumes() + assert isinstance( below_volumes, list ) + + def test_very_small_volumes( self, mesh_with_zero_volume ): + """Test with extremely small volume thresholds.""" + filter_instance = ElementVolumes( mesh_with_zero_volume, minVolume=1e-15 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # Should still be able to detect volumes below extremely small thresholds + assert isinstance( below_volumes, list ) + + def test_very_large_volumes( self, simple_hex_mesh ): + """Test with very large volume thresholds.""" + filter_instance = ElementVolumes( simple_hex_mesh, minVolume=1e10 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # All elements should be below this huge threshold + assert len( below_volumes ) == simple_hex_mesh.GetNumberOfCells() + + def test_negative_threshold( self, mesh_with_negative_volume ): + """Test with negative volume threshold.""" + filter_instance = ElementVolumes( mesh_with_negative_volume, minVolume=-0.5 ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + # Only volumes below -0.5 should be detected + for _, volume in below_volumes: + assert volume < -0.5 + + +@pytest.mark.parametrize( "min_volume,expected_behavior", [ + ( 0.0, "detect_negative_and_zero" ), + ( 1.1, "detect_small_positive" ), + ( -1.0, "detect_very_negative" ), + ( 1e-10, "detect_near_zero" ), +] ) +def test_parametrized_volume_thresholds( simple_hex_mesh, min_volume, expected_behavior ): + """Parametrized test for different volume thresholds.""" + filter_instance = ElementVolumes( simple_hex_mesh, minVolume=min_volume ) + success = filter_instance.applyFilter() + + assert success + below_volumes = filter_instance.getBelowVolumes() + + if expected_behavior == "detect_small_positive": + # Unit cubes with volume 1.0 should be below a threshold of 1.1 + assert len( below_volumes ) > 0 + else: + # None of the other conditions should be met by the simple_hex_mesh + assert len( below_volumes ) == 0 + + +def test_logger_integration( simple_hex_mesh, caplog ): + """Test that the filter properly logs its operations.""" + filter_instance = ElementVolumes( simple_hex_mesh, useExternalLogger=False ) + + with caplog.at_level( "INFO" ): + success = filter_instance.applyFilter() + + assert success + # Check that some logging occurred + assert len( caplog.records ) > 0 + + # Check for expected log messages + log_messages = [ record.message for record in caplog.records ] + assert any( "Apply filter" in msg for msg in log_messages ) + assert any( "succeeded" in msg for msg in log_messages ) diff --git a/geos-mesh/tests/test_MeshDoctorFilterBase.py b/geos-mesh/tests/test_MeshDoctorFilterBase.py new file mode 100644 index 000000000..9fb1f16c3 --- /dev/null +++ b/geos-mesh/tests/test_MeshDoctorFilterBase.py @@ -0,0 +1,486 @@ +import pytest +import logging +import numpy as np +from unittest.mock import Mock +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_TETRA +from geos.mesh.utils.genericHelpers import createSingleCellMesh +from geos.mesh.doctor.filters.MeshDoctorFilterBase import MeshDoctorFilterBase, MeshDoctorGeneratorBase + +__doc__ = """ +Test module for MeshDoctorFilterBase classes. +Tests the functionality of base classes for mesh doctor filters and generators. +""" + + +@pytest.fixture( scope="module" ) +def single_tetrahedron_mesh() -> vtkUnstructuredGrid: + """Fixture for a single tetrahedron mesh.""" + return createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + + +class ConcreteFilterForTesting( MeshDoctorFilterBase ): + """Concrete implementation of MeshDoctorFilterBase for testing purposes.""" + + def __init__( self, mesh, filterName="TestFilter", useExternalLogger=False, shouldSucceed=True ): + super().__init__( mesh, filterName, useExternalLogger ) + self.shouldSucceed = shouldSucceed + self.applyFilterCalled = False + + def applyFilter( self ): + """Test implementation that can be configured to succeed or fail.""" + self.applyFilterCalled = True + if self.shouldSucceed: + self.logger.info( "Test filter applied successfully" ) + return True + else: + self.logger.error( "Test filter failed" ) + return False + + +class ConcreteGeneratorForTesting( MeshDoctorGeneratorBase ): + """Concrete implementation of MeshDoctorGeneratorBase for testing purposes.""" + + def __init__( self, filterName="TestGenerator", useExternalLogger=False, shouldSucceed=True ): + super().__init__( filterName, useExternalLogger ) + self.shouldSucceed = shouldSucceed + self.applyFilterCalled = False + + def applyFilter( self ): + """Test implementation that generates a simple mesh or fails.""" + self.applyFilterCalled = True + if self.shouldSucceed: + # Generate a simple single-cell mesh + self.mesh = createSingleCellMesh( VTK_TETRA, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + self.logger.info( "Test generator applied successfully" ) + return True + else: + self.logger.error( "Test generator failed" ) + return False + + +class TestMeshDoctorFilterBase: + """Test class for MeshDoctorFilterBase functionality.""" + + def test_initialization_valid_inputs( self, single_tetrahedron_mesh ): + """Test successful initialization with valid inputs.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", False ) + + assert filter_instance.filterName == "TestFilter" + assert filter_instance.mesh is not None + assert filter_instance.mesh.GetNumberOfCells() > 0 + assert filter_instance.logger is not None + + # Verify that mesh is a copy, not the original + assert filter_instance.mesh is not single_tetrahedron_mesh + + def test_initialization_with_external_logger( self, single_tetrahedron_mesh ): + """Test initialization with external logger.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", True ) + + assert filter_instance.filterName == "TestFilter" + assert isinstance( filter_instance.logger, logging.Logger ) + + def test_initialization_invalid_mesh_type( self ): + """Test initialization with invalid mesh type.""" + for error_obj in [ "not_a_mesh", 123, None ]: + with pytest.raises( TypeError, match="Input 'mesh' must be a vtkUnstructuredGrid" ): + ConcreteFilterForTesting( error_obj, "TestFilter" ) + + def test_initialization_empty_mesh( self ): + """Test initialization with empty mesh.""" + with pytest.raises( ValueError, match="Input 'mesh' cannot be empty" ): + ConcreteFilterForTesting( vtkUnstructuredGrid(), "TestFilter" ) + + def test_initialization_invalid_filter_name( self, single_tetrahedron_mesh ): + """Test initialization with invalid filter name.""" + for error_obj in [ 123, None ]: + with pytest.raises( TypeError, match="Input 'filterName' must be a string" ): + ConcreteFilterForTesting( single_tetrahedron_mesh, error_obj ) + + for error_obj in [ "", " " ]: + with pytest.raises( ValueError, match="Input 'filterName' cannot be an empty or whitespace-only string" ): + ConcreteFilterForTesting( single_tetrahedron_mesh, error_obj ) + + def test_initialization_invalid_external_logger_flag( self, single_tetrahedron_mesh ): + """Test initialization with invalid useExternalLogger flag.""" + for error_obj in [ "not_bool", 1 ]: + with pytest.raises( TypeError, match="Input 'useExternalLogger' must be a boolean" ): + ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", error_obj ) + + def test_get_mesh( self, single_tetrahedron_mesh ): + """Test getMesh method returns the correct mesh.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + returned_mesh = filter_instance.getMesh() + + assert returned_mesh is filter_instance.mesh + assert returned_mesh.GetNumberOfCells() == single_tetrahedron_mesh.GetNumberOfCells() + assert returned_mesh.GetNumberOfPoints() == single_tetrahedron_mesh.GetNumberOfPoints() + + def test_copy_mesh( self, single_tetrahedron_mesh ): + """Test copyMesh helper method.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + copied_mesh = filter_instance.copyMesh( single_tetrahedron_mesh ) + + assert copied_mesh is not single_tetrahedron_mesh + assert copied_mesh.GetNumberOfCells() == single_tetrahedron_mesh.GetNumberOfCells() + assert copied_mesh.GetNumberOfPoints() == single_tetrahedron_mesh.GetNumberOfPoints() + + def test_apply_filter_success( self, single_tetrahedron_mesh ): + """Test successful filter application.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", shouldSucceed=True ) + result = filter_instance.applyFilter() + + assert result is True + assert filter_instance.applyFilterCalled + + def test_apply_filter_failure( self, single_tetrahedron_mesh ): + """Test filter application failure.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", shouldSucceed=False ) + result = filter_instance.applyFilter() + + assert result is False + assert filter_instance.applyFilterCalled + + def test_write_grid_with_mesh( self, single_tetrahedron_mesh, tmp_path ): + """Test writing mesh to file when mesh is available.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + output_file = tmp_path / "test_output.vtu" + + filter_instance.writeGrid( str( output_file ) ) + + # Verify file was created + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_grid_with_different_options( self, single_tetrahedron_mesh, tmp_path ): + """Test writing mesh with different file options.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + + # Test ASCII mode + output_file_ascii = tmp_path / "test_ascii.vtu" + filter_instance.writeGrid( str( output_file_ascii ), isDataModeBinary=False ) + assert output_file_ascii.exists() + + # Test with overwrite enabled + output_file_overwrite = tmp_path / "test_overwrite.vtu" + filter_instance.writeGrid( str( output_file_overwrite ), canOverwrite=True ) + assert output_file_overwrite.exists() + + # Write again with overwrite enabled (should not raise error) + filter_instance.writeGrid( str( output_file_overwrite ), canOverwrite=True ) + + def test_write_grid_without_mesh( self, single_tetrahedron_mesh, tmp_path, caplog ): + """Test writing when no mesh is available.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + filter_instance.mesh = None # Remove the mesh + + output_file = tmp_path / "should_not_exist.vtu" + + with caplog.at_level( logging.ERROR ): + filter_instance.writeGrid( str( output_file ) ) + + # Should log error and not create file + assert "No mesh available" in caplog.text + assert not output_file.exists() + + def test_set_logger_handler_without_existing_handlers( self, single_tetrahedron_mesh ): + """Test setting logger handler when no handlers exist.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter", useExternalLogger=True ) + + # Clear any existing handlers + filter_instance.logger.handlers.clear() + + # Create a mock handler + mock_handler = Mock() + filter_instance.setLoggerHandler( mock_handler ) + + # Verify handler was added + assert mock_handler in filter_instance.logger.handlers + + def test_set_logger_handler_with_existing_handlers( self, single_tetrahedron_mesh, caplog ): + """Test setting logger handler when handlers already exist.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, + "TestFilter_with_handlers", + useExternalLogger=True ) + filter_instance.logger.addHandler( logging.NullHandler() ) + + mock_handler = Mock() + mock_handler.level = logging.WARNING + + with caplog.at_level( logging.WARNING ): + filter_instance.setLoggerHandler( mock_handler ) + + # Now caplog will capture the warning correctly + assert "already has a handler" in caplog.text + + def test_logger_functionality( self, single_tetrahedron_mesh, caplog ): + """Test that logging works correctly.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter_functionality" ) + + with caplog.at_level( logging.INFO ): + filter_instance.applyFilter() + + # Should have logged the success message + assert "Test filter applied successfully" in caplog.text + + def test_mesh_deep_copy_behavior( self, single_tetrahedron_mesh ): + """Test that the filter creates a deep copy of the input mesh.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "TestFilter" ) + + # Modify the original mesh + original_cell_count = single_tetrahedron_mesh.GetNumberOfCells() + + # The filter's mesh should be independent of the original + filter_mesh = filter_instance.getMesh() + assert filter_mesh.GetNumberOfCells() == original_cell_count + assert filter_mesh is not single_tetrahedron_mesh + + +class TestMeshDoctorGeneratorBase: + """Test class for MeshDoctorGeneratorBase functionality.""" + + def test_initialization_valid_inputs( self ): + """Test successful initialization with valid inputs.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", False ) + + assert generator_instance.filterName == "TestGenerator" + assert generator_instance.mesh is None # Should start with no mesh + assert generator_instance.logger is not None + + def test_initialization_with_external_logger( self ): + """Test initialization with external logger.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", True ) + + assert generator_instance.filterName == "TestGenerator" + assert isinstance( generator_instance.logger, logging.Logger ) + + def test_initialization_invalid_filter_name( self ): + """Test initialization with invalid filter name.""" + for error_obj in [ 123, None ]: + with pytest.raises( TypeError, match="Input 'filterName' must be a string" ): + ConcreteGeneratorForTesting( error_obj ) + + for error_obj in [ "", " " ]: + with pytest.raises( ValueError, match="Input 'filterName' cannot be an empty or whitespace-only string" ): + ConcreteGeneratorForTesting( error_obj ) + + def test_initialization_invalid_external_logger_flag( self ): + """Test initialization with invalid useExternalLogger flag.""" + for error_obj in [ "not_bool", 1 ]: + with pytest.raises( TypeError, match="Input 'useExternalLogger' must be a boolean" ): + ConcreteGeneratorForTesting( "TestGenerator", error_obj ) + + def test_get_mesh_before_generation( self ): + """Test getMesh method before mesh generation.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator" ) + returned_mesh = generator_instance.getMesh() + + assert returned_mesh is None + + def test_get_mesh_after_generation( self ): + """Test getMesh method after successful mesh generation.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", shouldSucceed=True ) + result = generator_instance.applyFilter() + + assert result is True + assert generator_instance.mesh is not None + + returned_mesh = generator_instance.getMesh() + assert returned_mesh is generator_instance.mesh + assert returned_mesh.GetNumberOfCells() > 0 + + def test_apply_filter_success( self ): + """Test successful mesh generation.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", shouldSucceed=True ) + result = generator_instance.applyFilter() + + assert result is True + assert generator_instance.applyFilterCalled + assert generator_instance.mesh is not None + + def test_apply_filter_failure( self ): + """Test mesh generation failure.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", shouldSucceed=False ) + result = generator_instance.applyFilter() + + assert result is False + assert generator_instance.applyFilterCalled + assert generator_instance.mesh is None + + def test_write_grid_with_generated_mesh( self, tmp_path ): + """Test writing generated mesh to file.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", shouldSucceed=True ) + generator_instance.applyFilter() + + output_file = tmp_path / "generated_mesh.vtu" + generator_instance.writeGrid( str( output_file ) ) + + # Verify file was created + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_grid_without_generated_mesh( self, tmp_path, caplog ): + """Test writing when no mesh has been generated.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator" ) + output_file = tmp_path / "should_not_exist.vtu" + + with caplog.at_level( logging.ERROR ): + generator_instance.writeGrid( str( output_file ) ) + + # Should log error and not create file + assert "No mesh generated" in caplog.text + assert not output_file.exists() + + def test_write_grid_with_different_options( self, tmp_path ): + """Test writing generated mesh with different file options.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", shouldSucceed=True ) + generator_instance.applyFilter() + + # Test ASCII mode + output_file_ascii = tmp_path / "generated_ascii.vtu" + generator_instance.writeGrid( str( output_file_ascii ), isDataModeBinary=False ) + assert output_file_ascii.exists() + + # Test with overwrite enabled + output_file_overwrite = tmp_path / "generated_overwrite.vtu" + generator_instance.writeGrid( str( output_file_overwrite ), canOverwrite=True ) + assert output_file_overwrite.exists() + + def test_set_logger_handler_without_existing_handlers( self ): + """Test setting logger handler when no handlers exist.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator", useExternalLogger=True ) + + # Clear any existing handlers + generator_instance.logger.handlers.clear() + + # Create a mock handler + mock_handler = Mock() + generator_instance.setLoggerHandler( mock_handler ) + + # Verify handler was added + assert mock_handler in generator_instance.logger.handlers + + def test_set_logger_handler_with_existing_handlers( self, caplog ): + """Test setting logger handler when handlers already exist.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator_with_handlers", useExternalLogger=True ) + generator_instance.logger.addHandler( logging.NullHandler() ) + + mock_handler = Mock() + mock_handler.level = logging.WARNING + + with caplog.at_level( logging.WARNING ): + generator_instance.setLoggerHandler( mock_handler ) + + # Now caplog will capture the warning correctly + assert "already has a handler" in caplog.text + + def test_logger_functionality( self, caplog ): + """Test that logging works correctly.""" + generator_instance = ConcreteGeneratorForTesting( "TestGenerator_functionality", shouldSucceed=True ) + + with caplog.at_level( logging.INFO ): + generator_instance.applyFilter() + + # Should have logged the success message + assert "Test generator applied successfully" in caplog.text + + +class TestMeshDoctorBaseEdgeCases: + """Test class for edge cases and integration scenarios.""" + + def test_filter_base_not_implemented_error( self, single_tetrahedron_mesh ): + """Test that base class raises NotImplementedError.""" + filter_instance = MeshDoctorFilterBase( single_tetrahedron_mesh, "BaseFilter" ) + + with pytest.raises( NotImplementedError, match="Subclasses must implement applyFilter method" ): + filter_instance.applyFilter() + + def test_generator_base_not_implemented_error( self ): + """Test that base generator class raises NotImplementedError.""" + generator_instance = MeshDoctorGeneratorBase( "BaseGenerator" ) + + with pytest.raises( NotImplementedError, match="Subclasses must implement applyFilter method" ): + generator_instance.applyFilter() + + def test_filter_with_single_cell_mesh( self, single_tetrahedron_mesh ): + """Test filter with a single cell mesh.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, "SingleCellTest" ) + result = filter_instance.applyFilter() + + assert result is True + assert filter_instance.getMesh().GetNumberOfCells() == 1 + + def test_filter_mesh_independence( self, single_tetrahedron_mesh ): + """Test that multiple filters are independent.""" + filter1 = ConcreteFilterForTesting( single_tetrahedron_mesh, "Filter1" ) + filter2 = ConcreteFilterForTesting( single_tetrahedron_mesh, "Filter2" ) + + mesh1 = filter1.getMesh() + mesh2 = filter2.getMesh() + + # Meshes should be independent copies + assert mesh1 is not mesh2 + assert mesh1 is not single_tetrahedron_mesh + assert mesh2 is not single_tetrahedron_mesh + + def test_generator_multiple_instances( self ): + """Test that multiple generator instances are independent.""" + gen1 = ConcreteGeneratorForTesting( "Gen1", shouldSucceed=True ) + gen2 = ConcreteGeneratorForTesting( "Gen2", shouldSucceed=True ) + + gen1.applyFilter() + gen2.applyFilter() + + assert gen1.getMesh() is not gen2.getMesh() + assert gen1.getMesh() is not None + assert gen2.getMesh() is not None + + def test_filter_logger_names( self, single_tetrahedron_mesh ): + """Test that different filters get different logger names.""" + filter1 = ConcreteFilterForTesting( single_tetrahedron_mesh, "Filter1" ) + filter2 = ConcreteFilterForTesting( single_tetrahedron_mesh, "Filter2" ) + + assert filter1.logger.name != filter2.logger.name + + def test_generator_logger_names( self ): + """Test that different generators get different logger names.""" + gen1 = ConcreteGeneratorForTesting( "Gen1" ) + gen2 = ConcreteGeneratorForTesting( "Gen2" ) + + assert gen1.logger.name != gen2.logger.name + + +@pytest.mark.parametrize( "filter_name,should_succeed", [ + ( "ParametrizedFilter1", True ), + ( "ParametrizedFilter2", False ), + ( "LongFilterNameForTesting", True ), + ( "UnicodeFilter", True ), +] ) +def test_parametrized_filter_behavior( single_tetrahedron_mesh, filter_name, should_succeed ): + """Parametrized test for different filter configurations.""" + filter_instance = ConcreteFilterForTesting( single_tetrahedron_mesh, filter_name, shouldSucceed=should_succeed ) + + result = filter_instance.applyFilter() + assert result == should_succeed + assert filter_instance.filterName == filter_name + + +@pytest.mark.parametrize( "generator_name,should_succeed", [ + ( "ParametrizedGen1", True ), + ( "ParametrizedGen2", False ), + ( "LongGeneratorNameForTesting", True ), + ( "UnicodeGenerator", True ), +] ) +def test_parametrized_generator_behavior( generator_name, should_succeed ): + """Parametrized test for different generator configurations.""" + generator_instance = ConcreteGeneratorForTesting( generator_name, shouldSucceed=should_succeed ) + + result = generator_instance.applyFilter() + assert result == should_succeed + assert generator_instance.filterName == generator_name + + if should_succeed: + assert generator_instance.getMesh() is not None + else: + assert generator_instance.getMesh() is None diff --git a/geos-mesh/tests/test_SelfIntersectingElements.py b/geos-mesh/tests/test_SelfIntersectingElements.py new file mode 100644 index 000000000..078c70ffa --- /dev/null +++ b/geos-mesh/tests/test_SelfIntersectingElements.py @@ -0,0 +1,457 @@ +import pytest +import logging +import numpy as np +from vtkmodules.vtkCommonDataModel import VTK_HEXAHEDRON, VTK_PYRAMID, VTK_TETRA +from geos.mesh.utils.genericHelpers import createSingleCellMesh, createMultiCellMesh +from geos.mesh.doctor.filters.SelfIntersectingElements import SelfIntersectingElements, selfIntersectingElements + +__doc__ = """ +Test module for SelfIntersectingElements filter. +Tests the functionality of detecting various types of invalid or problematic elements. +""" + + +@pytest.fixture( scope="module" ) +def single_hex_mesh(): + """Fixture for a single valid hexahedron mesh with no invalid elements.""" + return createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def single_tetra_mesh(): + """Fixture for a single valid tetrahedron.""" + return createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def degenerate_tetra_mesh(): + """Fixture for a tetrahedron with degenerate (coplanar) points.""" + return createSingleCellMesh( VTK_TETRA, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0.5, 0.5, 0.0 ], [ 0.2, 0.3, 0.0 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def inverted_pyramid_mesh(): + """Fixture for an inverted pyramid (wrong orientation).""" + return createSingleCellMesh( VTK_PYRAMID, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0.5, 0.5, + -1.0 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def wrong_point_count_mesh(): + """Fixture for elements with wrong number of points.""" + return createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0.5, 1.0, 0.0 ] ] ) ) + + +class TestSelfIntersectingElementsFilter: + """Test class for SelfIntersectingElements filter functionality.""" + + def test_filter_initialization_default( self, single_hex_mesh ): + """Test filter initialization with default parameters.""" + filter_instance = SelfIntersectingElements( single_hex_mesh ) + + assert filter_instance.getMinDistance() == 0.0 + assert not filter_instance.writeInvalidElements + assert filter_instance.getMesh() is not None + assert isinstance( filter_instance.getInvalidCellIds(), dict ) + + def test_filter_initialization_custom( self, single_hex_mesh ): + """Test filter initialization with custom parameters.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, + minDistance=1e-6, + writeInvalidElements=True, + useExternalLogger=True ) + + assert filter_instance.getMinDistance() == 1e-6 + assert filter_instance.writeInvalidElements + + def test_filter_on_clean_mesh( self, single_hex_mesh ): + """Test filter on a clean mesh with no invalid elements.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should be a dictionary with error type keys + assert isinstance( invalid_cells, dict ) + + # Check that all error types have empty lists (no invalid elements) + expected_error_types = [ + 'wrongNumberOfPointsElements', 'intersectingEdgesElements', 'intersectingFacesElements', + 'nonContiguousEdgesElements', 'nonConvexElements', 'facesOrientedIncorrectlyElements', + 'nonPlanarFacesElements', 'degenerateFacesElements' + ] + + for error_type in expected_error_types: + assert error_type in invalid_cells + assert isinstance( invalid_cells[ error_type ], list ) + + def test_filter_on_single_valid_element( self, single_tetra_mesh ): + """Test filter on a single valid element.""" + filter_instance = SelfIntersectingElements( single_tetra_mesh, minDistance=1e-8 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should have no invalid elements for a properly constructed single tetrahedron + total_invalid = sum( len( cells ) for cells in invalid_cells.values() ) + # Note: This might detect some issues depending on how the single cell is constructed + assert total_invalid >= 0 # Just ensure it doesn't crash + + def test_filter_detect_degenerate_elements( self, degenerate_tetra_mesh ): + """Test detection of degenerate elements.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should detect some type of invalidity in the degenerate tetrahedron + total_invalid = sum( len( cells ) for cells in invalid_cells.values() ) + assert total_invalid > 0 + + def test_filter_detect_orientation_issues( self, inverted_pyramid_mesh ): + """Test detection of orientation issues.""" + filter_instance = SelfIntersectingElements( inverted_pyramid_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should detect some type of invalidity in the inverted tetrahedron + total_invalid = sum( len( cells ) for cells in invalid_cells.values() ) + assert total_invalid > 0 + + def test_filter_detect_wrong_point_count( self, wrong_point_count_mesh ): + """Test detection of elements with wrong number of points.""" + filter_instance = SelfIntersectingElements( wrong_point_count_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should detect wrong number of points + assert len( invalid_cells[ 'wrongNumberOfPointsElements' ] ) > 0 + + def test_filter_write_invalid_elements_arrays( self, degenerate_tetra_mesh ): + """Test writing invalid elements arrays to mesh.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, minDistance=1e-6, writeInvalidElements=True ) + success = filter_instance.applyFilter() + + assert success + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + invalid_cells = filter_instance.getInvalidCellIds() + + # Check that arrays were added for error types that have invalid elements + for error_type, invalid_ids in invalid_cells.items(): + if invalid_ids: # Only check if there are actually invalid elements of this type + array_name = f"Is{error_type}" + array = cell_data.GetArray( array_name ) + assert array is not None + assert array.GetNumberOfTuples() == output_mesh.GetNumberOfCells() + + # Verify array contains proper values + for i in range( array.GetNumberOfTuples() ): + value = array.GetValue( i ) + assert value in [ 0, 1 ] + + def test_filter_no_arrays_when_disabled( self, degenerate_tetra_mesh ): + """Test that no arrays are added when writeInvalidElements is False.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, + minDistance=1e-6, + writeInvalidElements=False ) + success = filter_instance.applyFilter() + + assert success + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + + # Should not have added any "Is*" arrays + error_types = [ + 'wrongNumberOfPointsElements', 'intersectingEdgesElements', 'intersectingFacesElements', + 'nonContiguousEdgesElements', 'nonConvexElements', 'facesOrientedIncorrectlyElements', + 'nonPlanarFacesElements', 'degenerateFacesElements' + ] + + for error_type in error_types: + array_name = f"Is{error_type}" + array = cell_data.GetArray( array_name ) + assert array is None + + def test_filter_tolerance_setter_getter( self, single_hex_mesh ): + """Test setter and getter methods for minimum distance.""" + filter_instance = SelfIntersectingElements( single_hex_mesh ) + + # Test default value + assert filter_instance.getMinDistance() == 0.0 + + # Test setting new value + filter_instance.setMinDistance( 1e-5 ) + assert filter_instance.getMinDistance() == 1e-5 + + # Test setting another value + filter_instance.setMinDistance( 1e-10 ) + assert filter_instance.getMinDistance() == 1e-10 + + def test_filter_write_invalid_elements_setter( self, single_hex_mesh ): + """Test setter method for writeInvalidElements flag.""" + filter_instance = SelfIntersectingElements( single_hex_mesh ) + + # Test default value + assert not filter_instance.writeInvalidElements + + # Test setting to True + filter_instance.setWriteInvalidElements( True ) + assert filter_instance.writeInvalidElements + + # Test setting back to False + filter_instance.setWriteInvalidElements( False ) + assert not filter_instance.writeInvalidElements + + def test_filter_write_grid( self, single_hex_mesh, tmp_path ): + """Test writing the output mesh to file.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + assert success + + output_file = tmp_path / "self_intersecting_output.vtu" + filter_instance.writeGrid( str( output_file ) ) + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_filter_different_tolerance_values( self, degenerate_tetra_mesh ): + """Test filter behavior with different tolerance values.""" + # Test with very small tolerance + filter_small = SelfIntersectingElements( degenerate_tetra_mesh, minDistance=1e-15 ) + success_small = filter_small.applyFilter() + assert success_small + + # Test with larger tolerance + filter_large = SelfIntersectingElements( degenerate_tetra_mesh, minDistance=1e-3 ) + success_large = filter_large.applyFilter() + assert success_large + + # Both should succeed, but might detect different numbers of issues + invalid_small = filter_small.getInvalidCellIds() + invalid_large = filter_large.getInvalidCellIds() + + assert isinstance( invalid_small, dict ) + assert isinstance( invalid_large, dict ) + + +class TestSelfIntersectingElementsStandaloneFunction: + """Test class for the standalone selfIntersectingElements function.""" + + def test_standalone_function_basic( self, single_hex_mesh, tmp_path ): + """Test basic functionality of the standalone function.""" + output_file = tmp_path / "standalone_output.vtu" + + mesh, invalid_cells = selfIntersectingElements( single_hex_mesh, + str( output_file ), + minDistance=1e-6, + writeInvalidElements=False ) + + assert mesh is not None + assert isinstance( invalid_cells, dict ) + assert output_file.exists() + + def test_standalone_function_with_array_writing( self, degenerate_tetra_mesh, tmp_path ): + """Test standalone function with array writing enabled.""" + output_file = tmp_path / "standalone_with_arrays.vtu" + + mesh, invalid_cells = selfIntersectingElements( degenerate_tetra_mesh, + str( output_file ), + minDistance=1e-6, + writeInvalidElements=True ) + + assert mesh is not None + assert isinstance( invalid_cells, dict ) + + # Check if any arrays were added for detected invalid elements + cell_data = mesh.GetCellData() + arrays_found = False + for error_type, invalid_ids in invalid_cells.items(): + if invalid_ids: + array_name = f"Is{error_type}" + array = cell_data.GetArray( array_name ) + if array is not None: + arrays_found = True + break + + # If there were invalid elements detected, arrays should have been added + total_invalid = sum( len( cells ) for cells in invalid_cells.values() ) + if total_invalid > 0: + assert arrays_found + + def test_standalone_function_different_tolerances( self, degenerate_tetra_mesh, tmp_path ): + """Test standalone function with different tolerance settings.""" + output_file = tmp_path / "different_tolerance.vtu" + + mesh, invalid_cells = selfIntersectingElements( degenerate_tetra_mesh, + str( output_file ), + minDistance=1e-8, + writeInvalidElements=True ) + + assert mesh is not None + assert isinstance( invalid_cells, dict ) + assert output_file.exists() + + +class TestSelfIntersectingElementsEdgeCases: + """Test class for edge cases and specific scenarios.""" + + def test_filter_with_zero_tolerance( self, single_hex_mesh ): + """Test filter with zero tolerance.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=0.0 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + assert isinstance( invalid_cells, dict ) + + def test_filter_with_negative_tolerance( self, single_hex_mesh ): + """Test filter with negative tolerance (should still work).""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=-1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + assert isinstance( invalid_cells, dict ) + + def test_filter_with_very_large_tolerance( self, single_hex_mesh ): + """Test filter with very large tolerance.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=1e10 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + assert isinstance( invalid_cells, dict ) + + def test_filter_result_structure_validation( self, degenerate_tetra_mesh ): + """Test the structure of invalid cell results.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Validate dictionary structure + assert isinstance( invalid_cells, dict ) + + expected_keys = [ + 'wrongNumberOfPointsElements', 'intersectingEdgesElements', 'intersectingFacesElements', + 'nonContiguousEdgesElements', 'nonConvexElements', 'facesOrientedIncorrectlyElements', + 'nonPlanarFacesElements', 'degenerateFacesElements' + ] + + for key in expected_keys: + assert key in invalid_cells + assert isinstance( invalid_cells[ key ], list ) + + # Validate cell IDs are non-negative integers + for cell_id in invalid_cells[ key ]: + assert isinstance( cell_id, ( int, np.integer ) ) + assert cell_id >= 0 + + +class TestSelfIntersectingElementsIntegration: + """Test class for integration scenarios and complex cases.""" + + def test_filter_multiple_error_types( self ): + """Test a mesh that might have multiple types of invalid elements.""" + mesh = createMultiCellMesh( [ VTK_TETRA, VTK_TETRA ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0.5, 1.0, 0.0 ], [ 0.5, 0.5, 1.0 ] ] ), + np.array( [ [ 2, 0, 0 ], [ 3, 0, 0 ], [ 2.5, 0.5, 0.0 ], [ 2.3, 0.3, 0.0 ] ] ) + ] ) + filter_instance = SelfIntersectingElements( mesh, minDistance=1e-6 ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # Should detect some invalid elements + total_invalid = sum( len( cells ) for cells in invalid_cells.values() ) + assert total_invalid >= 0 # At least should not crash + + def test_filter_logger_integration( self, degenerate_tetra_mesh, caplog ): + """Test that the filter properly logs its operations.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, useExternalLogger=False ) + + with caplog.at_level( logging.INFO ): + success = filter_instance.applyFilter() + + assert success + assert len( caplog.records ) > 0 + + # Check for expected log messages + log_messages = [ record.message for record in caplog.records ] + assert any( "Apply filter" in msg for msg in log_messages ) + assert any( "succeeded" in msg for msg in log_messages ) + + +@pytest.mark.parametrize( "min_distance,expected_behavior", [ + ( 0.0, "zero_tolerance" ), + ( 1e-15, "very_small_tolerance" ), + ( 1e-6, "normal_tolerance" ), + ( 1e-3, "large_tolerance" ), + ( 1.0, "very_large_tolerance" ), +] ) +def test_parametrized_tolerance_values( single_hex_mesh, min_distance, expected_behavior ): + """Parametrized test for different tolerance values.""" + filter_instance = SelfIntersectingElements( single_hex_mesh, minDistance=min_distance ) + success = filter_instance.applyFilter() + + assert success + invalid_cells = filter_instance.getInvalidCellIds() + + # All should succeed and return proper structure + assert isinstance( invalid_cells, dict ) + + # Verify tolerance was set correctly + assert filter_instance.getMinDistance() == min_distance + + +@pytest.mark.parametrize( "write_arrays", [ True, False ] ) +def test_parametrized_array_writing( degenerate_tetra_mesh, write_arrays ): + """Parametrized test for array writing options.""" + filter_instance = SelfIntersectingElements( degenerate_tetra_mesh, + minDistance=1e-6, + writeInvalidElements=write_arrays ) + success = filter_instance.applyFilter() + + assert success + assert filter_instance.writeInvalidElements == write_arrays + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + invalid_cells = filter_instance.getInvalidCellIds() + + if write_arrays: + # If arrays should be written and there are invalid elements, check for arrays + for error_type, invalid_ids in invalid_cells.items(): + if invalid_ids: + array_name = f"Is{error_type}" + array = cell_data.GetArray( array_name ) + assert array is not None + else: + # No arrays should be written + error_types = [ + 'wrongNumberOfPointsElements', 'intersectingEdgesElements', 'intersectingFacesElements', + 'nonContiguousEdgesElements', 'nonConvexElements', 'facesOrientedIncorrectlyElements', + 'nonPlanarFacesElements', 'degenerateFacesElements' + ] + + for error_type in error_types: + array_name = f"Is{error_type}" + array = cell_data.GetArray( array_name ) + assert array is None diff --git a/geos-mesh/tests/test_SupportedElements.py b/geos-mesh/tests/test_SupportedElements.py new file mode 100644 index 000000000..01013f7d7 --- /dev/null +++ b/geos-mesh/tests/test_SupportedElements.py @@ -0,0 +1,219 @@ +import pytest +import numpy as np +from pathlib import Path +from vtkmodules.vtkCommonCore import vtkIdList +from vtkmodules.vtkCommonDataModel import ( + vtkUnstructuredGrid, + VTK_HEXAHEDRON, + VTK_POLYHEDRON, + VTK_QUADRATIC_TETRA, # An example of an unsupported standard type + vtkCellTypes ) +from vtkmodules.util.numpy_support import vtk_to_numpy +from geos.mesh.doctor.actions.supported_elements import supported_cell_types +from geos.mesh.doctor.filters.SupportedElements import SupportedElements, supportedElements +from geos.mesh.utils.genericHelpers import createSingleCellMesh, createMultiCellMesh + + +@pytest.fixture +def good_mesh() -> vtkUnstructuredGrid: + """Creates a mesh with only supported element types.""" + return createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + + +@pytest.fixture +def mesh_with_unsupported_std_type(): + """Creates a mesh containing an unsupported standard element type (VTK_QUADRATIC_TETRA).""" + # Check that our chosen unsupported type is actually not in the supported list + assert VTK_QUADRATIC_TETRA not in supported_cell_types + return createMultiCellMesh( [ VTK_HEXAHEDRON, VTK_QUADRATIC_TETRA ], [ + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ), + np.array( [ [ 2, 0, 0 ], [ 3, 0, 0 ], [ 2.5, 1, 0 ], [ 2.5, 0.5, 1 ], [ 2.5, 0, 0 ], [ 2.75, 0.5, 0 ], + [ 2.25, 0.5, 0 ], [ 2.75, 0.25, 0.5 ], [ 2.5, 0.75, 0.5 ], [ 2.25, 0.25, 0.5 ] ] ) + ] ) + + +@pytest.fixture +def mesh_with_unsupported_polyhedron() -> vtkUnstructuredGrid: + """ + Creates a mesh with a convertible polyhedron (Hex) and a non-convertible one. + The non-convertible one is a triangular bipyramid, which has 6 faces like a hex, + but a different face-connectivity graph. + """ + points = [ + [ 0, 0, 0 ], # points for the hex + [ 1, 0, 0 ], + [ 1, 1, 0 ], + [ 0, 1, 0 ], + [ 0, 0, 1 ], + [ 1, 0, 1 ], + [ 1, 1, 1 ], + [ 0, 1, 1 ], + [ 2, 0, 1 ], # other points to add for bipyramid + [ 3, -1, 0 ], + [ 3, 1, 0 ], + [ 1, 0, 0 ], + [ 2, 0, -1 ] + ] + + mesh: vtkUnstructuredGrid = createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( + [ points[ 0 ], points[ 1 ], points[ 2 ], points[ 3 ], points[ 4 ], points[ 5 ], points[ 6 ], + points[ 7 ] ] ) ) + + # Face stream for the triangular bipyramid (6 faces, 5 points) + # Format: [num_faces, num_pts_face1, p1, p2, ..., num_pts_face2, p1, p2, ...] + poly_faces = [ + 6, # Number of faces + 3, # Face 0 (top) + 8, + 9, + 10, + 3, # Face 1 (top) + 8, + 10, + 11, + 3, # Face 2 (top) + 8, + 11, + 9, + 3, # Face 3 (bottom) + 12, + 10, + 9, + 3, # Face 4 (bottom) + 12, + 11, + 10, + 3, # Face 5 (bottom) + 12, + 9, + 11 + ] + # Insert the polyhedron cell + face_stream = vtkIdList() + face_stream.SetNumberOfIds( len( poly_faces ) ) + for i, val in enumerate( poly_faces ): + face_stream.SetId( i, val ) + # Now, insert the polyhedron cell using the face stream + mesh.InsertNextCell( VTK_POLYHEDRON, face_stream ) + return mesh + + +class TestSupportedElements: + + def test_initialization( self, good_mesh ): + """Tests the constructor and default values.""" + filter_instance = SupportedElements( good_mesh ) + assert filter_instance.numProc == 1 + assert filter_instance.chunkSize == 1 + assert not filter_instance.writeUnsupportedElementTypes + assert not filter_instance.writeUnsupportedPolyhedrons + assert len( filter_instance.getUnsupportedElementTypes() ) == 0 + assert len( filter_instance.getUnsupportedPolyhedronElements() ) == 0 + + def test_setters( self, good_mesh ): + """Tests the various setter methods.""" + filt = SupportedElements( good_mesh ) + filt.setNumProc( 8 ) + assert filt.numProc == 8 + filt.setChunkSize( 2000 ) + assert filt.chunkSize == 2000 + filt.setWriteUnsupportedElementTypes( True ) + assert filt.writeUnsupportedElementTypes + filt.setWriteUnsupportedPolyhedrons( True ) + assert filt.writeUnsupportedPolyhedrons + + def test_apply_on_good_mesh( self, good_mesh ): + """Tests the filter on a mesh with no unsupported elements.""" + filt = SupportedElements( good_mesh ) + success = filt.applyFilter() + assert success + assert len( filt.getUnsupportedElementTypes() ) == 0 + assert len( filt.getUnsupportedPolyhedronElements() ) == 0 + + def test_find_unsupported_std_types( self, mesh_with_unsupported_std_type ): + """Tests detection of unsupported standard element types.""" + filt = SupportedElements( mesh_with_unsupported_std_type ) + success = filt.applyFilter() + assert success + + unsupported_types = filt.getUnsupportedElementTypes() + assert len( unsupported_types ) == 1 + + type_name = vtkCellTypes.GetClassNameFromTypeId( VTK_QUADRATIC_TETRA ) + expected_str = f"Type {VTK_QUADRATIC_TETRA}: {type_name}" + assert unsupported_types[ 0 ] == expected_str + + assert len( filt.getUnsupportedPolyhedronElements() ) == 0 + + def test_find_unsupported_polyhedrons( self, mesh_with_unsupported_polyhedron ): + """Tests detection of unsupported polyhedron elements.""" + filt = SupportedElements( mesh_with_unsupported_polyhedron, numProc=2, chunkSize=1 ) + success = filt.applyFilter() + assert success + + unsupported_polys = filt.getUnsupportedPolyhedronElements() + assert len( unsupported_polys ) == 1 + # The unsupported polyhedron is the second cell (index 1) + assert unsupported_polys[ 0 ] == 1 + + assert len( filt.getUnsupportedElementTypes() ) == 0 + + def test_array_writing( self, mesh_with_unsupported_std_type, mesh_with_unsupported_polyhedron ): + """Tests that CellData arrays are correctly added to the mesh.""" + # Test standard types array + filt_std = SupportedElements( mesh_with_unsupported_std_type, writeUnsupportedElementTypes=True ) + filt_std.applyFilter() + mesh_out_std = filt_std.getMesh() + + std_array = mesh_out_std.GetCellData().GetArray( "HasUnsupportedType" ) + assert std_array is not None + std_np = vtk_to_numpy( std_array ) + # Cell 0 (Hex) is supported, Cell 1 (QuadTetra) is not. + np.testing.assert_array_equal( std_np, [ 0, 1 ] ) + + # Test polyhedrons array + filt_poly = SupportedElements( mesh_with_unsupported_polyhedron, writeUnsupportedPolyhedrons=True ) + filt_poly.applyFilter() + mesh_out_poly = filt_poly.getMesh() + + poly_array = mesh_out_poly.GetCellData().GetArray( "IsUnsupportedPolyhedron" ) + assert poly_array is not None + poly_np = vtk_to_numpy( poly_array ) + # Cell 0 (Hex) is not an unsupported poly, Cell 1 is. + np.testing.assert_array_equal( poly_np, [ 0, 1 ] ) + + +def test_standalone_function( tmp_path, mesh_with_unsupported_polyhedron ): + """Tests the standalone `supportedElements` function.""" + output_dir = tmp_path / "output" + output_dir.mkdir() + output_path = output_dir / "test_output.vtu" + + # Run the standalone function + mesh, types, polys = supportedElements( + mesh_with_unsupported_polyhedron, + outputPath=str( output_path ), + numProc=1, + chunkSize=1, + writeUnsupportedPolyhedrons=True # Ensure array is written + ) + + # Verify returned values + assert len( types ) == 0 + assert len( polys ) == 1 + assert polys[ 0 ] == 1 + + # Verify file was written + assert Path( output_path ).is_file() + + # Verify the mesh object returned has the new array + array = mesh.GetCellData().GetArray( "IsUnsupportedPolyhedron" ) + assert array is not None + np_array = vtk_to_numpy( array ) + np.testing.assert_array_equal( np_array, [ 0, 1 ] ) diff --git a/geos-mesh/tests/test_collocated_nodes.py b/geos-mesh/tests/test_collocated_nodes.py deleted file mode 100644 index 86f798f73..000000000 --- a/geos-mesh/tests/test_collocated_nodes.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest -from typing import Iterator, Tuple -from vtkmodules.vtkCommonCore import vtkPoints -from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkTetra, vtkUnstructuredGrid, VTK_TETRA -from geos.mesh.doctor.actions.collocated_nodes import Options, __action - - -def get_points() -> Iterator[ Tuple[ vtkPoints, int ] ]: - """Generates the data for the cases. - One case has two nodes at the exact same position. - The other has two differente nodes - :return: Generator to (vtk points, number of expected duplicated locations) - """ - for p0, p1 in ( ( 0, 0, 0 ), ( 1, 1, 1 ) ), ( ( 0, 0, 0 ), ( 0, 0, 0 ) ): - points = vtkPoints() - points.SetNumberOfPoints( 2 ) - points.SetPoint( 0, p0 ) - points.SetPoint( 1, p1 ) - num_nodes_bucket = 1 if p0 == p1 else 0 - yield points, num_nodes_bucket - - -@pytest.mark.parametrize( "data", get_points() ) -def test_simple_collocated_points( data: Tuple[ vtkPoints, int ] ): - points, num_nodes_bucket = data - - mesh = vtkUnstructuredGrid() - mesh.SetPoints( points ) - - result = __action( mesh, Options( tolerance=1.e-12 ) ) - - assert len( result.wrong_support_elements ) == 0 - assert len( result.nodes_buckets ) == num_nodes_bucket - if num_nodes_bucket == 1: - assert len( result.nodes_buckets[ 0 ] ) == points.GetNumberOfPoints() - - -def test_wrong_support_elements(): - points = vtkPoints() - points.SetNumberOfPoints( 4 ) - points.SetPoint( 0, ( 0, 0, 0 ) ) - points.SetPoint( 1, ( 1, 0, 0 ) ) - points.SetPoint( 2, ( 0, 1, 0 ) ) - points.SetPoint( 3, ( 0, 0, 1 ) ) - - cell_types = [ VTK_TETRA ] - cells = vtkCellArray() - cells.AllocateExact( 1, 4 ) - - tet = vtkTetra() - tet.GetPointIds().SetId( 0, 0 ) - tet.GetPointIds().SetId( 1, 1 ) - tet.GetPointIds().SetId( 2, 2 ) - tet.GetPointIds().SetId( 3, 0 ) # Intentionally wrong - cells.InsertNextCell( tet ) - - mesh = vtkUnstructuredGrid() - mesh.SetPoints( points ) - mesh.SetCells( cell_types, cells ) - - result = __action( mesh, Options( tolerance=1.e-12 ) ) - - assert len( result.nodes_buckets ) == 0 - assert len( result.wrong_support_elements ) == 1 - assert result.wrong_support_elements[ 0 ] == 0 diff --git a/geos-mesh/tests/test_element_volumes.py b/geos-mesh/tests/test_element_volumes.py deleted file mode 100644 index dccbda936..000000000 --- a/geos-mesh/tests/test_element_volumes.py +++ /dev/null @@ -1,39 +0,0 @@ -import numpy -from vtkmodules.vtkCommonCore import vtkPoints -from vtkmodules.vtkCommonDataModel import VTK_TETRA, vtkCellArray, vtkTetra, vtkUnstructuredGrid -from geos.mesh.doctor.actions.element_volumes import Options, __action - - -def test_simple_tet(): - # creating a simple tetrahedron - points = vtkPoints() - points.SetNumberOfPoints( 4 ) - points.SetPoint( 0, ( 0, 0, 0 ) ) - points.SetPoint( 1, ( 1, 0, 0 ) ) - points.SetPoint( 2, ( 0, 1, 0 ) ) - points.SetPoint( 3, ( 0, 0, 1 ) ) - - cell_types = [ VTK_TETRA ] - cells = vtkCellArray() - cells.AllocateExact( 1, 4 ) - - tet = vtkTetra() - tet.GetPointIds().SetId( 0, 0 ) - tet.GetPointIds().SetId( 1, 1 ) - tet.GetPointIds().SetId( 2, 2 ) - tet.GetPointIds().SetId( 3, 3 ) - cells.InsertNextCell( tet ) - - mesh = vtkUnstructuredGrid() - mesh.SetPoints( points ) - mesh.SetCells( cell_types, cells ) - - result = __action( mesh, Options( min_volume=1. ) ) - - assert len( result.element_volumes ) == 1 - assert result.element_volumes[ 0 ][ 0 ] == 0 - assert abs( result.element_volumes[ 0 ][ 1 ] - 1. / 6. ) < 10 * numpy.finfo( float ).eps - - result = __action( mesh, Options( min_volume=0. ) ) - - assert len( result.element_volumes ) == 0 diff --git a/geos-mesh/tests/test_generate_cube.py b/geos-mesh/tests/test_generate_cube.py index d02ef68b1..5704e940a 100644 --- a/geos-mesh/tests/test_generate_cube.py +++ b/geos-mesh/tests/test_generate_cube.py @@ -1,4 +1,6 @@ -from geos.mesh.doctor.actions.generate_cube import __build, Options, FieldInfo +import pytest +from geos.mesh.doctor.actions.generate_cube import FieldInfo, Options, __build +from geos.mesh.doctor.filters.GenerateRectilinearGrid import GenerateRectilinearGrid, generateRectilinearGrid def test_generate_cube(): @@ -18,3 +20,113 @@ def test_generate_cube(): assert output.GetCellData().GetArray( "test" ).GetNumberOfComponents() == 2 assert output.GetCellData().GetGlobalIds() assert not output.GetPointData().GetGlobalIds() + + +def test_generate_rectilinear_grid_filter(): + """Test the GenerateRectilinearGrid filter class.""" + # Create filter instance + filter_instance = GenerateRectilinearGrid( generateCellsGlobalIds=True, generatePointsGlobalIds=True ) + + # Set coordinates and number of elements + filter_instance.setCoordinates( [ 0.0, 5.0, 10.0 ], [ 0.0, 5.0, 10.0 ], [ 0.0, 10.0 ] ) + filter_instance.setNumberElements( [ 5, 5 ], [ 5, 5 ], [ 10 ] ) + + # Add fields + cells_dim1 = FieldInfo( "cell1", 1, "CELLS" ) + cells_dim3 = FieldInfo( "cell3", 3, "CELLS" ) + points_dim1 = FieldInfo( "point1", 1, "POINTS" ) + points_dim3 = FieldInfo( "point3", 3, "POINTS" ) + filter_instance.setFields( [ cells_dim1, cells_dim3, points_dim1, points_dim3 ] ) + + # Apply filter + success = filter_instance.applyFilter() + assert success, "Filter should succeed" + + # Get the generated mesh + output_mesh = filter_instance.getMesh() + + # Verify mesh properties + assert output_mesh is not None, "Output mesh should not be None" + assert output_mesh.GetNumberOfCells() == 1000, "Should have 1000 cells (10x10x10)" + assert output_mesh.GetNumberOfPoints() == 1331, "Should have 1331 points (11x11x11)" + + # Verify global IDs + assert output_mesh.GetCellData().GetGlobalIds() is not None, "Should have cell global IDs" + assert output_mesh.GetPointData().GetGlobalIds() is not None, "Should have point global IDs" + + # Verify fields + assert output_mesh.GetCellData().GetArray( "cell1" ) is not None, "Should have cell1 array" + assert output_mesh.GetCellData().GetArray( "cell1" ).GetNumberOfComponents() == 1, "cell1 should have 1 component" + assert output_mesh.GetCellData().GetArray( "cell3" ) is not None, "Should have cell3 array" + assert output_mesh.GetCellData().GetArray( "cell3" ).GetNumberOfComponents() == 3, "cell3 should have 3 components" + + assert output_mesh.GetPointData().GetArray( "point1" ) is not None, "Should have point1 array" + assert output_mesh.GetPointData().GetArray( + "point1" ).GetNumberOfComponents() == 1, "point1 should have 1 component" + assert output_mesh.GetPointData().GetArray( "point3" ) is not None, "Should have point3 array" + assert output_mesh.GetPointData().GetArray( + "point3" ).GetNumberOfComponents() == 3, "point3 should have 3 components" + + +def test_generate_rectilinear_grid_filter_no_global_ids(): + """Test the GenerateRectilinearGrid filter without global IDs.""" + filter_instance = GenerateRectilinearGrid( generateCellsGlobalIds=False, generatePointsGlobalIds=False ) + + filter_instance.setCoordinates( [ 0.0, 2.0 ], [ 0.0, 3.0 ], [ 0.0, 1.0 ] ) + filter_instance.setNumberElements( [ 2 ], [ 3 ], [ 1 ] ) + + success = filter_instance.applyFilter() + assert success, "Filter should succeed" + + output_mesh = filter_instance.getMesh() + + # Verify no global IDs + assert output_mesh.GetCellData().GetGlobalIds() is None, "Should not have cell global IDs" + assert output_mesh.GetPointData().GetGlobalIds() is None, "Should not have point global IDs" + + # Verify basic mesh properties + assert output_mesh.GetNumberOfCells() == 6, "Should have 6 cells (2x3x1)" + assert output_mesh.GetNumberOfPoints() == 24, "Should have 24 points (3x4x2)" + + +def test_generate_rectilinear_grid_filter_missing_parameters(): + """Test that filter fails gracefully when required parameters are missing.""" + filter_instance = GenerateRectilinearGrid() + + # Try to apply filter without setting coordinates + success = filter_instance.applyFilter() + assert not success, "Filter should fail when coordinates are not set" + + +def test_generate_rectilinear_grid_standalone( tmp_path ): + """Test the standalone generateRectilinearGrid function.""" + output_path = tmp_path / "test_grid.vtu" + + # Create a simple rectilinear grid + output_mesh = generateRectilinearGrid( coordsX=[ 0.0, 1.0, 2.0 ], + coordsY=[ 0.0, 1.0 ], + coordsZ=[ 0.0, 1.0 ], + numberElementsX=[ 2, 3 ], + numberElementsY=[ 2 ], + numberElementsZ=[ 2 ], + outputPath=output_path, + fields=[ FieldInfo( "test_field", 2, "CELLS" ) ], + generateCellsGlobalIds=True, + generatePointsGlobalIds=False ) + + # Verify mesh properties + assert output_mesh is not None, "Output mesh should not be None" + assert output_mesh.GetNumberOfCells() == 20, "Should have 20 cells (5x2x2)" + assert output_mesh.GetNumberOfPoints() == 54, "Should have 54 points (6x3x3)" + + # Verify field + assert output_mesh.GetCellData().GetArray( "test_field" ) is not None, "Should have test_field array" + test_field_array = output_mesh.GetCellData().GetArray( "test_field" ) + assert test_field_array.GetNumberOfComponents() == 2, "test_field should have 2 components" + + # Verify global IDs + assert output_mesh.GetCellData().GetGlobalIds() is not None, "Should have cell global IDs" + assert output_mesh.GetPointData().GetGlobalIds() is None, "Should not have point global IDs" + + # Verify output file was written + assert output_path.exists(), "Output file should exist" diff --git a/geos-mesh/tests/test_generate_fractures.py b/geos-mesh/tests/test_generate_fractures.py index 66c9496fd..cd0516274 100644 --- a/geos-mesh/tests/test_generate_fractures.py +++ b/geos-mesh/tests/test_generate_fractures.py @@ -1,14 +1,19 @@ from dataclasses import dataclass -import numpy +import numpy as np import pytest from typing import Iterable, Iterator, Sequence from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkQuad, VTK_HEXAHEDRON, VTK_POLYHEDRON, VTK_QUAD ) from vtkmodules.util.numpy_support import numpy_to_vtk, vtk_to_numpy from geos.mesh.doctor.actions.check_fractures import format_collocated_nodes from geos.mesh.doctor.actions.generate_cube import build_rectilinear_blocks_mesh, XYZ -from geos.mesh.doctor.actions.generate_fractures import ( __split_mesh_on_fractures, Options, FracturePolicy, +from geos.mesh.doctor.actions.generate_fractures import ( split_mesh_on_fractures, Options, FracturePolicy, Coordinates3D, IDMapping ) -from geos.mesh.utils.genericHelpers import to_vtk_id_list +from geos.mesh.doctor.filters.GenerateFractures import GenerateFractures, generateFractures +from geos.mesh.doctor.filters.GenerateFractures import ( FIELD_NAME, FIELD_VALUES, FRACTURES_DATA_MODE, + FRACTURES_OUTPUT_DIR, OUTPUT_BINARY_MODE, + OUTPUT_BINARY_MODE_VALUES, POLICY ) +from geos.mesh.utils.arrayModifiers import createConstantAttributeDataSet +from geos.mesh.utils.genericHelpers import to_vtk_id_list, createSingleCellMesh FaceNodesCoords = tuple[ tuple[ float ] ] IDMatrix = Sequence[ Sequence[ int ] ] @@ -32,7 +37,7 @@ class TestCase: result: TestResult -def __build_test_case( xs: tuple[ numpy.ndarray, numpy.ndarray, numpy.ndarray ], +def __build_test_case( xs: tuple[ np.ndarray, np.ndarray, np.ndarray ], attribute: Iterable[ int ], field_values: Iterable[ int ] = None, policy: FracturePolicy = FracturePolicy.FIELD ): @@ -40,7 +45,7 @@ def __build_test_case( xs: tuple[ numpy.ndarray, numpy.ndarray, numpy.ndarray ], mesh: vtkUnstructuredGrid = build_rectilinear_blocks_mesh( ( xyz, ) ) - ref = numpy.array( attribute, dtype=int ) + ref = np.array( attribute, dtype=int ) if policy == FracturePolicy.FIELD: assert len( ref ) == mesh.GetNumberOfCells() attr = numpy_to_vtk( ref ) @@ -73,9 +78,9 @@ def next( self, num: int ) -> Iterable[ int ]: def __generate_test_data() -> Iterator[ TestCase ]: - two_nodes = numpy.arange( 2, dtype=float ) - three_nodes = numpy.arange( 3, dtype=float ) - four_nodes = numpy.arange( 4, dtype=float ) + two_nodes = np.arange( 2, dtype=float ) + three_nodes = np.arange( 3, dtype=float ) + four_nodes = np.arange( 4, dtype=float ) # Split in 2 mesh, options = __build_test_case( ( three_nodes, three_nodes, three_nodes ), ( 0, 1, 0, 1, 0, 1, 0, 1 ) ) @@ -202,7 +207,7 @@ def __generate_test_data() -> Iterator[ TestCase ]: @pytest.mark.parametrize( "test_case", __generate_test_data() ) def test_generate_fracture( test_case: TestCase ): - main_mesh, fracture_meshes = __split_mesh_on_fractures( test_case.input_mesh, test_case.options ) + main_mesh, fracture_meshes = split_mesh_on_fractures( test_case.input_mesh, test_case.options ) fracture_mesh: vtkUnstructuredGrid = fracture_meshes[ 0 ] assert main_mesh.GetNumberOfPoints() == test_case.result.main_mesh_num_points assert main_mesh.GetNumberOfCells() == test_case.result.main_mesh_num_cells @@ -225,8 +230,8 @@ def add_simplified_field_for_cells( mesh: vtkUnstructuredGrid, field_name: str, """ data = mesh.GetCellData() n = mesh.GetNumberOfCells() - array = numpy.ones( ( n, field_dimension ), dtype=float ) - array = numpy.arange( 1, n * field_dimension + 1 ).reshape( n, field_dimension ) + array = np.ones( ( n, field_dimension ), dtype=float ) + array = np.arange( 1, n * field_dimension + 1 ).reshape( n, field_dimension ) vtk_array = numpy_to_vtk( array ) vtk_array.SetName( field_name ) data.AddArray( vtk_array ) @@ -299,12 +304,12 @@ def add_quad( mesh: vtkUnstructuredGrid, face: FaceNodesCoords ): @pytest.mark.skip( "Test to be fixed" ) def test_copy_fields_when_splitting_mesh(): """This test is designed to check the __copy_fields method from generate_fractures, - that will be called when using __split_mesh_on_fractures method from generate_fractures. + that will be called when using split_mesh_on_fractures method from generate_fractures. """ # Generating the rectilinear grid and its quads on all borders - x: numpy.array = numpy.array( [ 0, 1, 2 ] ) - y: numpy.array = numpy.array( [ 0, 1 ] ) - z: numpy.array = numpy.array( [ 0, 1 ] ) + x: np.array = np.array( [ 0, 1, 2 ] ) + y: np.array = np.array( [ 0, 1 ] ) + z: np.array = np.array( [ 0, 1 ] ) xyzs: XYZ = XYZ( x, y, z ) mesh: vtkUnstructuredGrid = build_rectilinear_blocks_mesh( [ xyzs ] ) assert mesh.GetCells().GetNumberOfCells() == 2 @@ -330,7 +335,7 @@ def test_copy_fields_when_splitting_mesh(): field_values_per_fracture=[ frozenset( map( int, [ "9" ] ) ) ], mesh_VtkOutput=None, all_fractures_VtkOutput=None ) - main_mesh, fracture_meshes = __split_mesh_on_fractures( mesh, options ) + main_mesh, fracture_meshes = split_mesh_on_fractures( mesh, options ) fracture_mesh: vtkUnstructuredGrid = fracture_meshes[ 0 ] assert main_mesh.GetCellData().GetNumberOfArrays() == 1 assert fracture_mesh.GetCellData().GetNumberOfArrays() == 1 @@ -344,7 +349,7 @@ def test_copy_fields_when_splitting_mesh(): # Test for invalid point field name add_simplified_field_for_cells( mesh, "GLOBAL_IDS_POINTS", 1 ) with pytest.raises( ValueError ) as pytest_wrapped_e: - main_mesh, fracture_meshes = __split_mesh_on_fractures( mesh, options ) + main_mesh, fracture_meshes = split_mesh_on_fractures( mesh, options ) assert pytest_wrapped_e.type == ValueError # Test for invalid cell field name mesh: vtkUnstructuredGrid = build_rectilinear_blocks_mesh( [ xyzs ] ) @@ -356,5 +361,141 @@ def test_copy_fields_when_splitting_mesh(): add_simplified_field_for_cells( mesh, "GLOBAL_IDS_CELLS", 1 ) assert mesh.GetCellData().GetNumberOfArrays() == 2 with pytest.raises( ValueError ) as pytest_wrapped_e: - main_mesh, fracture_meshes = __split_mesh_on_fractures( mesh, options ) + main_mesh, fracture_meshes = split_mesh_on_fractures( mesh, options ) assert pytest_wrapped_e.type == ValueError + + +""" +Tests for GenerateFractures.py +""" + + +@pytest.mark.parametrize( "test_case", __generate_test_data() ) +def test_generate_fracture_filters_basic( test_case: TestCase, tmp_path ): + fracturesDir = tmp_path / "fractures" + fracturesDir.mkdir( exist_ok=True ) # Create the directory + policy = 1 if test_case.options.policy == FracturePolicy.INTERNAL_SURFACES else 0 + fieldValues = ','.join( map( str, test_case.options.field_values_combined ) ) + # Create filter instance + filterInstance = GenerateFractures( test_case.input_mesh, + policy=policy, + fieldName=test_case.options.field, + fieldValues=fieldValues, + fracturesOutputDir=fracturesDir ) + # Apply filter + success = filterInstance.applyFilter() + assert success, "Filter should succeed" + # Get results + splitMesh = filterInstance.getMesh() + fractureMeshes = filterInstance.getFractureMeshes() + allGrids = filterInstance.getAllGrids() + # Verify results + assert splitMesh is not None, "Split mesh should not be None" + assert isinstance( fractureMeshes, list ), "Fracture meshes should be a list" + assert allGrids[ 0 ] is splitMesh, "getAllGrids should return split mesh as first element" + assert allGrids[ 1 ] is fractureMeshes, "getAllGrids should return fracture meshes as second element" + + +def test_generate_fractures_filter_setters(): + """Test the setter methods of GenerateFractures filter.""" + mesh = createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + createConstantAttributeDataSet( dataSet=mesh, listValues=[ 1 ], attributeName="test_field", onPoints=False ) + # Create filter instance + filterInstance = GenerateFractures( mesh ) + + # Test valid setters + filterInstance.setFieldName( "test_field" ) + assert filterInstance.allOptions[ FIELD_NAME ] == "test_field" + + filterInstance.setFieldValues( "1,2" ) + assert filterInstance.allOptions[ FIELD_VALUES ] == "1,2" + + filterInstance.setFracturesOutputDirectory( "./output/" ) + assert filterInstance.allOptions[ FRACTURES_OUTPUT_DIR ] == "./output/" + + filterInstance.setPolicy( 1 ) + assert filterInstance.allOptions[ POLICY ] == FracturePolicy.INTERNAL_SURFACES + + filterInstance.setOutputDataMode( 1 ) + assert filterInstance.allOptions[ OUTPUT_BINARY_MODE ] == OUTPUT_BINARY_MODE_VALUES[ 1 ] + + filterInstance.setFracturesDataMode( 0 ) + assert filterInstance.allOptions[ FRACTURES_DATA_MODE ] == OUTPUT_BINARY_MODE_VALUES[ 0 ] + + # Test invalid setters + original_policy = filterInstance.allOptions[ POLICY ] + filterInstance.setPolicy( 5 ) # Invalid value + assert filterInstance.allOptions[ POLICY ] == original_policy + + # Test invalid data modes - should not change the values + original_output_mode = filterInstance.allOptions[ OUTPUT_BINARY_MODE ] + filterInstance.setOutputDataMode( 5 ) # Invalid value + assert filterInstance.allOptions[ OUTPUT_BINARY_MODE ] == original_output_mode + + original_fractures_mode = filterInstance.allOptions[ FRACTURES_DATA_MODE ] + filterInstance.setFracturesDataMode( 5 ) # Invalid value + assert filterInstance.allOptions[ FRACTURES_DATA_MODE ] == original_fractures_mode + + +def test_generate_fractures_filter_with_global_ids( tmp_path ): + """Test that filter fails when mesh contains global IDs.""" + # Create a simple test mesh + mesh = createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + createConstantAttributeDataSet( dataSet=mesh, listValues=[ 1 ], attributeName="GLOBAL_IDS_POINTS", onPoints=False ) + + fractures_dir = tmp_path / "fractures" + fractures_dir.mkdir( exist_ok=True ) # Create the directory + # Create filter instance + filterInstance = GenerateFractures( mesh ) + # Should fail due to global IDs + success = filterInstance.applyFilter() + assert not success, "Filter should fail when mesh contains global IDs" + + +@pytest.mark.parametrize( "test_case", __generate_test_data() ) +def test_generate_fractures_standalone( test_case: TestCase, tmp_path ): + outputPath = tmp_path / "split_mesh.vtu" + fractures_dir = tmp_path / "fractures" + fractures_dir.mkdir( exist_ok=True ) # Create the directory + policy = 1 if test_case.options.policy == FracturePolicy.INTERNAL_SURFACES else 0 + fieldValues = ','.join( map( str, test_case.options.field_values_combined ) ) + # Create filter instance + splitMesh, fractureMeshes = generateFractures( test_case.input_mesh, + outputPath=outputPath, + policy=policy, + fieldName=test_case.options.field, + fieldValues=fieldValues, + fracturesOutputDir=fractures_dir ) + # Verify results + assert splitMesh is not None, "Split mesh should not be None" + assert isinstance( fractureMeshes, list ), "Fracture meshes should be a list" + # Verify output file was written + assert outputPath.exists(), "Output file should exist" + + +@pytest.mark.parametrize( "test_case", __generate_test_data() ) +def test_generate_fractures_write_meshes( test_case: TestCase, tmp_path ): + outputPath = tmp_path / "split_mesh.vtu" + fracturesDir = tmp_path / "fractures" + fracturesDir.mkdir( exist_ok=True ) # Create the directory + policy = 1 if test_case.options.policy == FracturePolicy.INTERNAL_SURFACES else 0 + fieldValues = ','.join( map( str, test_case.options.field_values_combined ) ) + # Create filter instance + filterInstance = GenerateFractures( test_case.input_mesh, + policy=policy, + fieldName=test_case.options.field, + fieldValues=fieldValues, + fracturesOutputDir=fracturesDir ) + # Apply filter + success = filterInstance.applyFilter() + assert success, "Filter should succeed" + # Test writing meshes + filterInstance.writeMeshes( outputPath, isDataModeBinary=False, canOverwrite=True ) + # Verify main mesh file was written + assert outputPath.exists(), "Main mesh file should exist" diff --git a/geos-mesh/tests/test_generate_global_ids.py b/geos-mesh/tests/test_generate_global_ids.py index 614f771c4..8f1ad25a6 100644 --- a/geos-mesh/tests/test_generate_global_ids.py +++ b/geos-mesh/tests/test_generate_global_ids.py @@ -1,6 +1,6 @@ from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkUnstructuredGrid, vtkVertex, VTK_VERTEX -from geos.mesh.doctor.actions.generate_global_ids import __build_global_ids +from geos.mesh.doctor.actions.generate_global_ids import build_global_ids def test_generate_global_ids(): @@ -17,7 +17,7 @@ def test_generate_global_ids(): mesh.SetPoints( points ) mesh.SetCells( [ VTK_VERTEX ], vertices ) - __build_global_ids( mesh, True, True ) + build_global_ids( mesh, True, True ) global_cell_ids = mesh.GetCellData().GetGlobalIds() global_point_ids = mesh.GetPointData().GetGlobalIds() diff --git a/geos-mesh/tests/test_genericHelpers.py b/geos-mesh/tests/test_genericHelpers.py index 85de45f5c..7132ca2c3 100644 --- a/geos-mesh/tests/test_genericHelpers.py +++ b/geos-mesh/tests/test_genericHelpers.py @@ -9,7 +9,8 @@ from typing import ( Iterator, ) -from geos.mesh.utils.genericHelpers import getBoundsFromPointCoords, createVertices, createMultiCellMesh +from geos.mesh.utils.genericHelpers import ( getBoundsFromPointCoords, createVertices, createMultiCellMesh, + findUniqueCellCenterCellIds ) from vtkmodules.util.numpy_support import vtk_to_numpy @@ -181,3 +182,56 @@ def test_getBoundsFromPointCoords() -> None: boundsExp: list[ float ] = [ 0., 5., 1., 8., 2., 9. ] boundsObs: list[ float ] = getBoundsFromPointCoords( cellPtsCoord ) assert boundsExp == boundsObs, f"Expected bounds are {boundsExp}." + + +def test_findUniqueCellCenterCellIds() -> None: + """Test of findUniqueCellCenterCellIds method.""" + # Create first mesh with two cells + cellTypes1: list[ int ] = [ VTK_TETRA, VTK_TETRA ] + cellPtsCoord1: list[ npt.NDArray[ np.float64 ] ] = [ + # First tetrahedron centered around (0.25, 0.25, 0.25) + np.array( [ [ 0.0, 0.0, 0.0 ], [ 1.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], [ 0.0, 0.0, 1.0 ] ], dtype=float ), + # Second tetrahedron centered around (2.25, 2.25, 2.25) + np.array( [ [ 2.0, 2.0, 2.0 ], [ 3.0, 2.0, 2.0 ], [ 2.0, 3.0, 2.0 ], [ 2.0, 2.0, 3.0 ] ], dtype=float ), + ] + + # Create second mesh with different cells, one overlapping with first mesh + cellTypes2: list[ int ] = [ VTK_TETRA, VTK_TETRA ] + cellPtsCoord2: list[ npt.NDArray[ np.float64 ] ] = [ + # First tetrahedron with same center as first cell in mesh1 (should overlap) + np.array( [ [ 0.0, 0.0, 0.0 ], [ 1.0, 0.0, 0.0 ], [ 0.0, 1.0, 0.0 ], [ 0.0, 0.0, 1.0 ] ], dtype=float ), + # Second tetrahedron with different center (unique to mesh2) + np.array( [ [ 4.0, 4.0, 4.0 ], [ 5.0, 4.0, 4.0 ], [ 4.0, 5.0, 4.0 ], [ 4.0, 4.0, 5.0 ] ], dtype=float ), + ] + + # Create meshes + mesh1: vtkUnstructuredGrid = createMultiCellMesh( cellTypes1, cellPtsCoord1, sharePoints=True ) + mesh2: vtkUnstructuredGrid = createMultiCellMesh( cellTypes2, cellPtsCoord2, sharePoints=True ) + + # Test the function + uniqueIds1, uniqueIds2, uniqueCoords1, uniqueCoords2 = findUniqueCellCenterCellIds( mesh1, mesh2 ) + + # Expected results: + # - Cell 0 in both meshes have the same center, so should not be in unique lists + # - Cell 1 in mesh1 (centered at ~(2.25, 2.25, 2.25)) is unique to mesh1 + # - Cell 1 in mesh2 (centered at ~(4.25, 4.25, 4.25)) is unique to mesh2 + assert len( uniqueIds1 ) == 1, f"Expected 1 unique cell in mesh1, got {len(uniqueIds1)}" + assert len( uniqueIds2 ) == 1, f"Expected 1 unique cell in mesh2, got {len(uniqueIds2)}" + assert uniqueIds1 == [ 1 ], f"Expected unique cell 1 in mesh1, got {uniqueIds1}" + assert uniqueIds2 == [ 1 ], f"Expected unique cell 1 in mesh2, got {uniqueIds2}" + + # Test coordinate lists + assert len( uniqueCoords1 ) == 1, f"Expected 1 unique coordinate in mesh1, got {len(uniqueCoords1)}" + assert len( uniqueCoords2 ) == 1, f"Expected 1 unique coordinate in mesh2, got {len(uniqueCoords2)}" + + # Test with tolerance + uniqueIds1_tight, uniqueIds2_tight, _, _ = findUniqueCellCenterCellIds( mesh1, mesh2, tolerance=1e-12 ) + assert len( uniqueIds1_tight ) == 1, "Tight tolerance should still find 1 unique cell in mesh1" + assert len( uniqueIds2_tight ) == 1, "Tight tolerance should still find 1 unique cell in mesh2" + + # Test error handling + with pytest.raises( ValueError, match="Input grids must be valid vtkUnstructuredGrid objects" ): + findUniqueCellCenterCellIds( None, mesh2 ) # type: ignore[arg-type] + + with pytest.raises( ValueError, match="Input grids must be valid vtkUnstructuredGrid objects" ): + findUniqueCellCenterCellIds( mesh1, None ) # type: ignore[arg-type] diff --git a/geos-mesh/tests/test_non_conformal.py b/geos-mesh/tests/test_non_conformal.py index 9f6da41a8..4e574e1e4 100644 --- a/geos-mesh/tests/test_non_conformal.py +++ b/geos-mesh/tests/test_non_conformal.py @@ -1,63 +1,461 @@ import numpy +import pytest +import logging from geos.mesh.doctor.actions.generate_cube import build_rectilinear_blocks_mesh, XYZ -from geos.mesh.doctor.actions.non_conformal import Options, __action +from geos.mesh.doctor.actions.non_conformal import Options, mesh_action +from geos.mesh.doctor.filters.NonConformal import NonConformal, nonConformal +__doc__ = """ +Test module for non-conformal mesh detection. +Tests both the actions-based functions and the NonConformal filter class. +""" -def test_two_close_hexs(): + +@pytest.fixture( scope="module" ) +def two_close_hexs_mesh(): + """Fixture for two hexahedra that are very close (non-conformal).""" delta = 1.e-6 tmp = numpy.arange( 2, dtype=float ) xyz0 = XYZ( tmp, tmp, tmp ) xyz1 = XYZ( tmp + 1 + delta, tmp, tmp ) - mesh = build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) + return build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) + + +@pytest.fixture( scope="module" ) +def two_distant_hexs_mesh(): + """Fixture for two hexahedra that are far apart (conformal).""" + delta = 1 + tmp = numpy.arange( 2, dtype=float ) + xyz0 = XYZ( tmp, tmp, tmp ) + xyz1 = XYZ( tmp + 1 + delta, tmp, tmp ) + return build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) + + +@pytest.fixture( scope="module" ) +def two_shifted_hexs_mesh(): + """Fixture for two hexahedra that are close but shifted (non-conformal).""" + delta_x, delta_y = 1.e-6, 0.5 + tmp = numpy.arange( 2, dtype=float ) + xyz0 = XYZ( tmp, tmp, tmp ) + xyz1 = XYZ( tmp + 1 + delta_x, tmp + delta_y, tmp + delta_y ) + return build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) + + +@pytest.fixture( scope="module" ) +def big_small_elements_mesh(): + """Fixture for big element next to small element (non-conformal).""" + delta = 1.e-6 + tmp = numpy.arange( 2, dtype=float ) + xyz0 = XYZ( tmp, tmp + 1, tmp + 1 ) + xyz1 = XYZ( 3 * tmp + 1 + delta, 3 * tmp, 3 * tmp ) + return build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) + +def test_two_close_hexs( two_close_hexs_mesh ): + delta = 1.e-6 # Close enough, but points tolerance is too strict to consider the faces matching. options = Options( angle_tolerance=1., point_tolerance=delta / 2, face_tolerance=delta * 2 ) - results = __action( mesh, options ) + results = mesh_action( two_close_hexs_mesh, options ) assert len( results.non_conformal_cells ) == 1 assert set( results.non_conformal_cells[ 0 ] ) == { 0, 1 } # Close enough, and points tolerance is loose enough to consider the faces matching. options = Options( angle_tolerance=1., point_tolerance=delta * 2, face_tolerance=delta * 2 ) - results = __action( mesh, options ) + results = mesh_action( two_close_hexs_mesh, options ) assert len( results.non_conformal_cells ) == 0 -def test_two_distant_hexs(): +def test_two_distant_hexs( two_distant_hexs_mesh ): delta = 1 - tmp = numpy.arange( 2, dtype=float ) - xyz0 = XYZ( tmp, tmp, tmp ) - xyz1 = XYZ( tmp + 1 + delta, tmp, tmp ) - mesh = build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) - options = Options( angle_tolerance=1., point_tolerance=delta / 2., face_tolerance=delta / 2. ) - - results = __action( mesh, options ) + results = mesh_action( two_distant_hexs_mesh, options ) assert len( results.non_conformal_cells ) == 0 -def test_two_close_shifted_hexs(): - delta_x, delta_y = 1.e-6, 0.5 - tmp = numpy.arange( 2, dtype=float ) - xyz0 = XYZ( tmp, tmp, tmp ) - xyz1 = XYZ( tmp + 1 + delta_x, tmp + delta_y, tmp + delta_y ) - mesh = build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) - +def test_two_close_shifted_hexs( two_shifted_hexs_mesh ): + delta_x = 1.e-6 options = Options( angle_tolerance=1., point_tolerance=delta_x * 2, face_tolerance=delta_x * 2 ) - - results = __action( mesh, options ) + results = mesh_action( two_shifted_hexs_mesh, options ) assert len( results.non_conformal_cells ) == 1 assert set( results.non_conformal_cells[ 0 ] ) == { 0, 1 } -def test_big_elem_next_to_small_elem(): +def test_big_elem_next_to_small_elem( big_small_elements_mesh ): delta = 1.e-6 - tmp = numpy.arange( 2, dtype=float ) - xyz0 = XYZ( tmp, tmp + 1, tmp + 1 ) - xyz1 = XYZ( 3 * tmp + 1 + delta, 3 * tmp, 3 * tmp ) - mesh = build_rectilinear_blocks_mesh( ( xyz0, xyz1 ) ) - options = Options( angle_tolerance=1., point_tolerance=delta * 2, face_tolerance=delta * 2 ) - - results = __action( mesh, options ) + results = mesh_action( big_small_elements_mesh, options ) assert len( results.non_conformal_cells ) == 1 assert set( results.non_conformal_cells[ 0 ] ) == { 0, 1 } + + +class TestNonConformalFilter: + """Test class for NonConformal filter functionality.""" + + def test_filter_initialization_default( self, two_close_hexs_mesh ): + """Test filter initialization with default parameters.""" + filter_instance = NonConformal( two_close_hexs_mesh ) + + assert filter_instance.getPointTolerance() == 0.0 + assert filter_instance.getFaceTolerance() == 0.0 + assert filter_instance.getAngleTolerance() == 10.0 + assert not filter_instance.writeNonConformalCells + assert filter_instance.getMesh() is not None + + def test_filter_initialization_custom( self, two_close_hexs_mesh ): + """Test filter initialization with custom parameters.""" + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=1e-6, + faceTolerance=1e-5, + angleTolerance=5.0, + writeNonConformalCells=True, + useExternalLogger=True ) + + assert filter_instance.getPointTolerance() == 1e-6 + assert filter_instance.getFaceTolerance() == 1e-5 + assert filter_instance.getAngleTolerance() == 5.0 + assert filter_instance.writeNonConformalCells + + def test_filter_detect_non_conformal_strict_tolerance( self, two_close_hexs_mesh ): + """Test detection of non-conformal cells with strict tolerance.""" + delta = 1.e-6 + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=delta / 2, + faceTolerance=delta * 2, + angleTolerance=1.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 1 + assert set( non_conformal_cells[ 0 ] ) == { 0, 1 } + + def test_filter_detect_conformal_loose_tolerance( self, two_close_hexs_mesh ): + """Test that close cells are considered conformal with loose tolerance.""" + delta = 1.e-6 + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=delta * 2, + faceTolerance=delta * 2, + angleTolerance=1.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 0 + + def test_filter_distant_cells_conformal( self, two_distant_hexs_mesh ): + """Test that distant cells are considered conformal.""" + filter_instance = NonConformal( two_distant_hexs_mesh, + pointTolerance=0.5, + faceTolerance=0.5, + angleTolerance=1.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 0 + + def test_filter_shifted_cells_non_conformal( self, two_shifted_hexs_mesh ): + """Test detection of shifted non-conformal cells.""" + delta_x = 1.e-6 + filter_instance = NonConformal( two_shifted_hexs_mesh, + pointTolerance=delta_x * 2, + faceTolerance=delta_x * 2, + angleTolerance=1.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 1 + assert set( non_conformal_cells[ 0 ] ) == { 0, 1 } + + def test_filter_big_small_elements( self, big_small_elements_mesh ): + """Test detection of non-conformal interface between different sized elements.""" + delta = 1.e-6 + filter_instance = NonConformal( big_small_elements_mesh, + pointTolerance=delta * 2, + faceTolerance=delta * 2, + angleTolerance=1.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 1 + assert set( non_conformal_cells[ 0 ] ) == { 0, 1 } + + def test_filter_write_non_conformal_array( self, two_close_hexs_mesh ): + """Test writing non-conformal cells array to mesh.""" + delta = 1.e-6 + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=delta / 2, + faceTolerance=delta * 2, + angleTolerance=1.0, + writeNonConformalCells=True ) + + success = filter_instance.applyFilter() + assert success + + # Check that the array was added to the mesh + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + + non_conformal_array = cell_data.GetArray( "IsNonConformal" ) + assert non_conformal_array is not None + assert non_conformal_array.GetNumberOfTuples() == output_mesh.GetNumberOfCells() + + # Check array values - should have 1s for non-conformal cells + has_non_conformal = False + for i in range( non_conformal_array.GetNumberOfTuples() ): + value = non_conformal_array.GetValue( i ) + assert value in [ 0, 1 ] + if value == 1: + has_non_conformal = True + + assert has_non_conformal # Should have detected some non-conformal cells + + def test_filter_no_array_when_disabled( self, two_close_hexs_mesh ): + """Test that no array is added when writeNonConformalCells is False.""" + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=1e-8, + faceTolerance=1e-6, + writeNonConformalCells=False ) + + success = filter_instance.applyFilter() + assert success + + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + + non_conformal_array = cell_data.GetArray( "IsNonConformal" ) + assert non_conformal_array is None + + def test_filter_no_array_when_no_non_conformal_cells( self, two_distant_hexs_mesh ): + """Test that no array is added when no non-conformal cells are found.""" + filter_instance = NonConformal( two_distant_hexs_mesh, + pointTolerance=0.1, + faceTolerance=0.1, + writeNonConformalCells=True ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert len( non_conformal_cells ) == 0 + + # Array should not be added when there are no non-conformal cells + output_mesh = filter_instance.getMesh() + cell_data = output_mesh.GetCellData() + non_conformal_array = cell_data.GetArray( "IsNonConformal" ) + assert non_conformal_array is None + + def test_filter_tolerance_setters_getters( self, two_close_hexs_mesh ): + """Test setter and getter methods for tolerances.""" + filter_instance = NonConformal( two_close_hexs_mesh ) + + # Test point tolerance + filter_instance.setPointTolerance( 1e-5 ) + assert filter_instance.getPointTolerance() == 1e-5 + + # Test face tolerance + filter_instance.setFaceTolerance( 2e-5 ) + assert filter_instance.getFaceTolerance() == 2e-5 + + # Test angle tolerance + filter_instance.setAngleTolerance( 15.0 ) + assert filter_instance.getAngleTolerance() == 15.0 + + # Test write flag + filter_instance.setWriteNonConformalCells( True ) + assert filter_instance.writeNonConformalCells + + def test_filter_write_grid( self, two_close_hexs_mesh, tmp_path ): + """Test writing the output mesh to file.""" + filter_instance = NonConformal( two_close_hexs_mesh ) + success = filter_instance.applyFilter() + assert success + + output_file = tmp_path / "non_conformal_output.vtu" + filter_instance.writeGrid( str( output_file ) ) + + assert output_file.exists() + assert output_file.stat().st_size > 0 + + +class TestNonConformalStandaloneFunction: + """Test class for the standalone nonConformal function.""" + + def test_standalone_function_basic( self, two_close_hexs_mesh, tmp_path ): + """Test basic functionality of the standalone function.""" + output_file = tmp_path / "standalone_output.vtu" + + mesh, non_conformal_cells = nonConformal( two_close_hexs_mesh, + str( output_file ), + pointTolerance=1e-8, + faceTolerance=1e-6, + angleTolerance=1.0, + writeNonConformalCells=False ) + + assert mesh is not None + assert isinstance( non_conformal_cells, list ) + assert len( non_conformal_cells ) > 0 # Should detect non-conformal cells + assert output_file.exists() + + def test_standalone_function_with_array_writing( self, two_close_hexs_mesh, tmp_path ): + """Test standalone function with array writing enabled.""" + output_file = tmp_path / "standalone_with_array.vtu" + + mesh, non_conformal_cells = nonConformal( two_close_hexs_mesh, + str( output_file ), + pointTolerance=1e-8, + faceTolerance=1e-6, + angleTolerance=1.0, + writeNonConformalCells=True ) + + assert mesh is not None + assert len( non_conformal_cells ) > 0 + + # Check that the array was added + non_conformal_array = mesh.GetCellData().GetArray( "IsNonConformal" ) + assert non_conformal_array is not None + + def test_standalone_function_no_non_conformal( self, two_distant_hexs_mesh, tmp_path ): + """Test standalone function when no non-conformal cells are found.""" + output_file = tmp_path / "no_non_conformal.vtu" + + mesh, non_conformal_cells = nonConformal( two_distant_hexs_mesh, + str( output_file ), + pointTolerance=0.1, + faceTolerance=0.1, + angleTolerance=1.0, + writeNonConformalCells=True ) + + assert mesh is not None + assert len( non_conformal_cells ) == 0 + assert output_file.exists() + + def test_standalone_function_different_tolerances( self, two_shifted_hexs_mesh, tmp_path ): + """Test standalone function with different tolerance settings.""" + output_file = tmp_path / "different_tolerances.vtu" + + mesh, non_conformal_cells = nonConformal( two_shifted_hexs_mesh, + str( output_file ), + pointTolerance=2e-6, + faceTolerance=2e-6, + angleTolerance=5.0, + writeNonConformalCells=True ) + + assert mesh is not None + assert len( non_conformal_cells ) == 1 + assert set( non_conformal_cells[ 0 ] ) == { 0, 1 } + + +class TestNonConformalEdgeCases: + """Test class for edge cases and specific scenarios.""" + + def test_filter_with_very_small_tolerances( self, two_close_hexs_mesh ): + """Test filter with extremely small tolerances.""" + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=1e-15, + faceTolerance=1e-15, + angleTolerance=0.001 ) + + success = filter_instance.applyFilter() + assert success + + # With extremely small tolerances, should detect non-conformal cells + non_conformal_cells = filter_instance.getNonConformalCells() + assert isinstance( non_conformal_cells, list ) + + def test_filter_with_large_tolerances( self, big_small_elements_mesh ): + """Test filter with very large tolerances.""" + filter_instance = NonConformal( big_small_elements_mesh, + pointTolerance=100.0, + faceTolerance=100.0, + angleTolerance=180.0 ) + + success = filter_instance.applyFilter() + assert success + + # With very large tolerances, should consider cells conformal + non_conformal_cells = filter_instance.getNonConformalCells() + # Depending on implementation, might still detect some non-conformal cases + assert isinstance( non_conformal_cells, list ) + + def test_filter_zero_tolerances( self, two_close_hexs_mesh ): + """Test filter with zero tolerances.""" + filter_instance = NonConformal( two_close_hexs_mesh, pointTolerance=0.0, faceTolerance=0.0, angleTolerance=0.0 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + assert isinstance( non_conformal_cells, list ) + + def test_filter_result_structure( self, two_close_hexs_mesh ): + """Test the structure of non-conformal cell results.""" + filter_instance = NonConformal( two_close_hexs_mesh, pointTolerance=1e-8, faceTolerance=1e-6 ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + + # Check result structure + for pair in non_conformal_cells: + assert isinstance( pair, tuple ) + assert len( pair ) == 2 + cell_id1, cell_id2 = pair + assert isinstance( cell_id1, int ) + assert isinstance( cell_id2, int ) + assert cell_id1 >= 0 + assert cell_id2 >= 0 + assert cell_id1 != cell_id2 # Should be different cells + + +@pytest.mark.parametrize( "point_tol,face_tol,angle_tol,expected_behavior", [ + ( 1e-8, 1e-6, 1.0, "detect_non_conformal" ), + ( 1e-4, 1e-4, 1.0, "consider_conformal" ), + ( 0.0, 0.0, 0.0, "strict_detection" ), + ( 1.0, 1.0, 180.0, "loose_detection" ), +] ) +def test_parametrized_tolerance_combinations( two_close_hexs_mesh, point_tol, face_tol, angle_tol, expected_behavior ): + """Parametrized test for different tolerance combinations.""" + filter_instance = NonConformal( two_close_hexs_mesh, + pointTolerance=point_tol, + faceTolerance=face_tol, + angleTolerance=angle_tol ) + + success = filter_instance.applyFilter() + assert success + + non_conformal_cells = filter_instance.getNonConformalCells() + + if expected_behavior == "detect_non_conformal": + # Should detect non-conformal cells with strict tolerances + assert len( non_conformal_cells ) > 0 + elif expected_behavior == "consider_conformal": + # Should consider cells conformal with loose tolerances + assert len( non_conformal_cells ) == 0 + elif expected_behavior in [ "strict_detection", "loose_detection" ]: + # Just verify it runs without errors + assert isinstance( non_conformal_cells, list ) + + +def test_filter_logger_integration( two_close_hexs_mesh, caplog ): + """Test that the filter properly logs its operations.""" + filter_instance = NonConformal( two_close_hexs_mesh, useExternalLogger=False ) + + with caplog.at_level( logging.INFO ): + success = filter_instance.applyFilter() + + assert success + assert len( caplog.records ) > 0 + + # Check for expected log messages + log_messages = [ record.message for record in caplog.records ] + assert any( "Apply filter" in msg for msg in log_messages ) + assert any( "succeeded" in msg for msg in log_messages ) diff --git a/geos-mesh/tests/test_self_intersecting_elements.py b/geos-mesh/tests/test_self_intersecting_elements.py deleted file mode 100644 index 45216f013..000000000 --- a/geos-mesh/tests/test_self_intersecting_elements.py +++ /dev/null @@ -1,41 +0,0 @@ -from vtkmodules.vtkCommonCore import vtkPoints -from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkHexahedron, vtkUnstructuredGrid, VTK_HEXAHEDRON -from geos.mesh.doctor.actions.self_intersecting_elements import Options, __action - - -def test_jumbled_hex(): - # creating a simple hexahedron - points = vtkPoints() - points.SetNumberOfPoints( 8 ) - points.SetPoint( 0, ( 0, 0, 0 ) ) - points.SetPoint( 1, ( 1, 0, 0 ) ) - points.SetPoint( 2, ( 1, 1, 0 ) ) - points.SetPoint( 3, ( 0, 1, 0 ) ) - points.SetPoint( 4, ( 0, 0, 1 ) ) - points.SetPoint( 5, ( 1, 0, 1 ) ) - points.SetPoint( 6, ( 1, 1, 1 ) ) - points.SetPoint( 7, ( 0, 1, 1 ) ) - - cell_types = [ VTK_HEXAHEDRON ] - cells = vtkCellArray() - cells.AllocateExact( 1, 8 ) - - hex = vtkHexahedron() - hex.GetPointIds().SetId( 0, 0 ) - hex.GetPointIds().SetId( 1, 1 ) - hex.GetPointIds().SetId( 2, 3 ) # Intentionally wrong - hex.GetPointIds().SetId( 3, 2 ) # Intentionally wrong - hex.GetPointIds().SetId( 4, 4 ) - hex.GetPointIds().SetId( 5, 5 ) - hex.GetPointIds().SetId( 6, 6 ) - hex.GetPointIds().SetId( 7, 7 ) - cells.InsertNextCell( hex ) - - mesh = vtkUnstructuredGrid() - mesh.SetPoints( points ) - mesh.SetCells( cell_types, cells ) - - result = __action( mesh, Options( min_distance=0. ) ) - - assert len( result.intersecting_faces_elements ) == 1 - assert result.intersecting_faces_elements[ 0 ] == 0 diff --git a/geos-mesh/tests/test_supported_elements.py b/geos-mesh/tests/test_supported_elements.py deleted file mode 100644 index 07321abc1..000000000 --- a/geos-mesh/tests/test_supported_elements.py +++ /dev/null @@ -1,118 +0,0 @@ -# import os -import pytest -from typing import Tuple -from vtkmodules.vtkCommonCore import vtkIdList, vtkPoints -from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_POLYHEDRON -# from geos.mesh.doctor.actions.supported_elements import Options, action, __action -from geos.mesh.doctor.actions.vtk_polyhedron import parse_face_stream, FaceStream -from geos.mesh.utils.genericHelpers import to_vtk_id_list - - -# TODO Update this test to have access to another meshTests file -@pytest.mark.parametrize( "base_name", ( "supportedElements.vtk", "supportedElementsAsVTKPolyhedra.vtk" ) ) -def test_supported_elements( base_name ) -> None: - """Testing that the supported elements are properly detected as supported! - :param base_name: Supported elements are provided as standard elements or polyhedron elements. - """ - ... - # directory = os.path.dirname( os.path.realpath( __file__ ) ) - # supported_elements_file_name = os.path.join( directory, "../../../../unitTests/meshTests", base_name ) - # options = Options( chunk_size=1, num_proc=4 ) - # result = check( supported_elements_file_name, options ) - # assert not result.unsupported_std_elements_types - # assert not result.unsupported_polyhedron_elements - - -def make_dodecahedron() -> Tuple[ vtkPoints, vtkIdList ]: - """Returns the points and faces for a dodecahedron. - This code was adapted from an official vtk example. - :return: The tuple of points and faces (as vtk instances). - """ - # yapf: disable - points = ( - (1.21412, 0, 1.58931), - (0.375185, 1.1547, 1.58931), - (-0.982247, 0.713644, 1.58931), - (-0.982247, -0.713644, 1.58931), - (0.375185, -1.1547, 1.58931), - (1.96449, 0, 0.375185), - (0.607062, 1.86835, 0.375185), - (-1.58931, 1.1547, 0.375185), - (-1.58931, -1.1547, 0.375185), - (0.607062, -1.86835, 0.375185), - (1.58931, 1.1547, -0.375185), - (-0.607062, 1.86835, -0.375185), - (-1.96449, 0, -0.375185), - (-0.607062, -1.86835, -0.375185), - (1.58931, -1.1547, -0.375185), - (0.982247, 0.713644, -1.58931), - (-0.375185, 1.1547, -1.58931), - (-1.21412, 0, -1.58931), - (-0.375185, -1.1547, -1.58931), - (0.982247, -0.713644, -1.58931) - ) - - faces = (12, # number of faces - 5, 0, 1, 2, 3, 4, # number of ids on face, ids - 5, 0, 5, 10, 6, 1, - 5, 1, 6, 11, 7, 2, - 5, 2, 7, 12, 8, 3, - 5, 3, 8, 13, 9, 4, - 5, 4, 9, 14, 5, 0, - 5, 15, 10, 5, 14, 19, - 5, 16, 11, 6, 10, 15, - 5, 17, 12, 7, 11, 16, - 5, 18, 13, 8, 12, 17, - 5, 19, 14, 9, 13, 18, - 5, 19, 18, 17, 16, 15) - # yapf: enable - - p = vtkPoints() - p.Allocate( len( points ) ) - for coords in points: - p.InsertNextPoint( coords ) - - f = to_vtk_id_list( faces ) - - return p, f - - -# TODO make this test work -def test_dodecahedron() -> None: - """Tests whether a dodecahedron is support by GEOS or not. - """ - points, faces = make_dodecahedron() - mesh = vtkUnstructuredGrid() - mesh.Allocate( 1 ) - mesh.SetPoints( points ) - mesh.InsertNextCell( VTK_POLYHEDRON, faces ) - - # TODO Why does __check triggers an assertion error with 'assert MESH is not None' ? - # result = __check( mesh, Options( num_proc=1, chunk_size=1 ) ) - # assert set( result.unsupported_polyhedron_elements ) == { 0 } - # assert not result.unsupported_std_elements_types - - -def test_parse_face_stream() -> None: - _, faces = make_dodecahedron() - result = parse_face_stream( faces ) - # yapf: disable - expected = ( - (0, 1, 2, 3, 4), - (0, 5, 10, 6, 1), - (1, 6, 11, 7, 2), - (2, 7, 12, 8, 3), - (3, 8, 13, 9, 4), - (4, 9, 14, 5, 0), - (15, 10, 5, 14, 19), - (16, 11, 6, 10, 15), - (17, 12, 7, 11, 16), - (18, 13, 8, 12, 17), - (19, 14, 9, 13, 18), - (19, 18, 17, 16, 15) - ) - # yapf: enable - assert result == expected - face_stream = FaceStream.build_from_vtk_id_list( faces ) - assert face_stream.num_faces == 12 - assert face_stream.num_support_points == 20 diff --git a/geos-mesh/tests/test_vtkIO.py b/geos-mesh/tests/test_vtkIO.py new file mode 100644 index 000000000..56ab678a0 --- /dev/null +++ b/geos-mesh/tests/test_vtkIO.py @@ -0,0 +1,441 @@ +import pytest +import numpy as np +from vtkmodules.vtkCommonCore import vtkPoints +from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, vtkStructuredGrid, VTK_TETRA, VTK_HEXAHEDRON +from geos.mesh.utils.genericHelpers import createSingleCellMesh +from geos.mesh.io.vtkIO import ( VtkFormat, VtkOutput, read_mesh, read_unstructured_grid, write_mesh, READER_MAP, + WRITER_MAP ) + +__doc__ = """ +Test module for vtkIO module. +Tests the functionality of reading and writing various VTK file formats. +""" + + +@pytest.fixture( scope="module" ) +def simple_unstructured_mesh(): + """Fixture for a simple unstructured mesh with tetrahedron.""" + return createSingleCellMesh( VTK_TETRA, np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def simple_hex_mesh(): + """Fixture for a simple hexahedron mesh.""" + return createSingleCellMesh( + VTK_HEXAHEDRON, + np.array( [ [ 0, 0, 0 ], [ 1, 0, 0 ], [ 1, 1, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 0, 1 ], [ 1, 1, 1 ], + [ 0, 1, 1 ] ] ) ) + + +@pytest.fixture( scope="module" ) +def structured_mesh(): + """Fixture for a simple structured grid.""" + mesh = vtkStructuredGrid() + mesh.SetDimensions( 2, 2, 2 ) + + points = vtkPoints() + for k in range( 2 ): + for j in range( 2 ): + for i in range( 2 ): + points.InsertNextPoint( i, j, k ) + + mesh.SetPoints( points ) + return mesh + + +class TestVtkFormat: + """Test class for VtkFormat enumeration.""" + + def test_vtk_format_values( self ): + """Test that VtkFormat enum has correct values.""" + assert VtkFormat.VTK.value == ".vtk" + assert VtkFormat.VTS.value == ".vts" + assert VtkFormat.VTU.value == ".vtu" + assert VtkFormat.PVTU.value == ".pvtu" + assert VtkFormat.PVTS.value == ".pvts" + + def test_vtk_format_from_string( self ): + """Test creating VtkFormat from string values.""" + assert VtkFormat( ".vtk" ) == VtkFormat.VTK + assert VtkFormat( ".vtu" ) == VtkFormat.VTU + assert VtkFormat( ".vts" ) == VtkFormat.VTS + assert VtkFormat( ".pvtu" ) == VtkFormat.PVTU + assert VtkFormat( ".pvts" ) == VtkFormat.PVTS + + def test_invalid_format( self ): + """Test that invalid format raises ValueError.""" + with pytest.raises( ValueError ): + VtkFormat( ".invalid" ) + + +class TestVtkOutput: + """Test class for VtkOutput dataclass.""" + + def test_vtk_output_creation( self ): + """Test VtkOutput creation with default parameters.""" + output = VtkOutput( "test.vtu" ) + assert output.output == "test.vtu" + assert output.is_data_mode_binary is True + + def test_vtk_output_creation_custom( self ): + """Test VtkOutput creation with custom parameters.""" + output = VtkOutput( "test.vtu", is_data_mode_binary=False ) + assert output.output == "test.vtu" + assert output.is_data_mode_binary is False + + def test_vtk_output_immutable( self ): + """Test that VtkOutput is immutable (frozen dataclass).""" + output = VtkOutput( "test.vtu" ) + with pytest.raises( AttributeError ): + output.output = "new_test.vtu" + + +class TestMappings: + """Test class for reader and writer mappings.""" + + def test_reader_map_completeness( self ): + """Test that READER_MAP contains all readable formats.""" + expected_formats = { VtkFormat.VTK, VtkFormat.VTS, VtkFormat.VTU, VtkFormat.PVTU, VtkFormat.PVTS } + assert set( READER_MAP.keys() ) == expected_formats + + def test_writer_map_completeness( self ): + """Test that WRITER_MAP contains all writable formats.""" + expected_formats = { VtkFormat.VTK, VtkFormat.VTS, VtkFormat.VTU } + assert set( WRITER_MAP.keys() ) == expected_formats + + def test_reader_map_classes( self ): + """Test that READER_MAP contains valid reader classes.""" + for format_type, reader_class in READER_MAP.items(): + assert hasattr( reader_class, '__name__' ) + # All readers should be classes + assert isinstance( reader_class, type ) + + def test_writer_map_classes( self ): + """Test that WRITER_MAP contains valid writer classes.""" + for format_type, writer_class in WRITER_MAP.items(): + assert hasattr( writer_class, '__name__' ) + # All writers should be classes + assert isinstance( writer_class, type ) + + +class TestWriteMesh: + """Test class for write_mesh functionality.""" + + def test_write_vtu_binary( self, simple_unstructured_mesh, tmp_path ): + """Test writing VTU file in binary mode.""" + output_file = tmp_path / "test_mesh.vtu" + vtk_output = VtkOutput( str( output_file ), is_data_mode_binary=True ) + + result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + assert result == 1 # VTK success code + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_vtu_ascii( self, simple_unstructured_mesh, tmp_path ): + """Test writing VTU file in ASCII mode.""" + output_file = tmp_path / "test_mesh_ascii.vtu" + vtk_output = VtkOutput( str( output_file ), is_data_mode_binary=False ) + + result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + assert result == 1 # VTK success code + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_vtk_format( self, simple_unstructured_mesh, tmp_path ): + """Test writing VTK legacy format.""" + output_file = tmp_path / "test_mesh.vtk" + vtk_output = VtkOutput( str( output_file ) ) + + result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + assert result == 1 # VTK success code + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_vts_format( self, structured_mesh, tmp_path ): + """Test writing VTS (structured grid) format.""" + output_file = tmp_path / "test_mesh.vts" + vtk_output = VtkOutput( str( output_file ) ) + + result = write_mesh( structured_mesh, vtk_output, can_overwrite=True ) + + assert result == 1 # VTK success code + assert output_file.exists() + assert output_file.stat().st_size > 0 + + def test_write_file_exists_error( self, simple_unstructured_mesh, tmp_path ): + """Test that writing to existing file raises error when can_overwrite=False.""" + output_file = tmp_path / "existing_file.vtu" + output_file.write_text( "dummy content" ) # Create existing file + + vtk_output = VtkOutput( str( output_file ) ) + + with pytest.raises( FileExistsError, match="already exists" ): + write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=False ) + + def test_write_unsupported_format( self, simple_unstructured_mesh, tmp_path ): + """Test that writing unsupported format raises ValueError.""" + output_file = tmp_path / "test_mesh.unsupported" + vtk_output = VtkOutput( str( output_file ) ) + + with pytest.raises( ValueError, match="not supported" ): + write_mesh( simple_unstructured_mesh, vtk_output ) + + def test_write_overwrite_allowed( self, simple_unstructured_mesh, tmp_path ): + """Test that overwriting is allowed when can_overwrite=True.""" + output_file = tmp_path / "overwrite_test.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + # First write + result1 = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert result1 == 1 + assert output_file.exists() + + # Second write (overwrite) + result2 = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert result2 == 1 + assert output_file.exists() + + +class TestReadMesh: + """Test class for read_mesh functionality.""" + + def test_read_nonexistent_file( self ): + """Test that reading nonexistent file raises FileNotFoundError.""" + with pytest.raises( FileNotFoundError, match="does not exist" ): + read_mesh( "nonexistent_file.vtu" ) + + def test_read_vtu_file( self, simple_unstructured_mesh, tmp_path ): + """Test reading VTU file.""" + output_file = tmp_path / "test_read.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + # First write the file + write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + # Then read it back + read_mesh_result = read_mesh( str( output_file ) ) + + assert read_mesh_result is not None + assert isinstance( read_mesh_result, vtkUnstructuredGrid ) + assert read_mesh_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert read_mesh_result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + def test_read_vtk_file( self, simple_unstructured_mesh, tmp_path ): + """Test reading VTK legacy file.""" + output_file = tmp_path / "test_read.vtk" + vtk_output = VtkOutput( str( output_file ) ) + + # First write the file + write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + # Then read it back + read_mesh_result = read_mesh( str( output_file ) ) + + assert read_mesh_result is not None + assert isinstance( read_mesh_result, vtkUnstructuredGrid ) + assert read_mesh_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert read_mesh_result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + def test_read_vts_file( self, structured_mesh, tmp_path ): + """Test reading VTS (structured grid) file.""" + output_file = tmp_path / "test_read.vts" + vtk_output = VtkOutput( str( output_file ) ) + + # First write the file + write_mesh( structured_mesh, vtk_output, can_overwrite=True ) + + # Then read it back + read_mesh_result = read_mesh( str( output_file ) ) + + assert read_mesh_result is not None + assert isinstance( read_mesh_result, vtkStructuredGrid ) + assert read_mesh_result.GetNumberOfPoints() == structured_mesh.GetNumberOfPoints() + + def test_read_unknown_extension( self, simple_unstructured_mesh, tmp_path ): + """Test reading file with unknown extension falls back to trying all readers.""" + # Create a VTU file but with unknown extension + vtu_file = tmp_path / "test.vtu" + unknown_file = tmp_path / "test.unknown" + + # Write as VTU first + vtk_output = VtkOutput( str( vtu_file ) ) + write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + # Copy to unknown extension + unknown_file.write_bytes( vtu_file.read_bytes() ) + + # Should still be able to read it + read_mesh_result = read_mesh( str( unknown_file ) ) + + assert read_mesh_result is not None + assert isinstance( read_mesh_result, vtkUnstructuredGrid ) + + def test_read_invalid_file_content( self, tmp_path ): + """Test that reading invalid file content raises ValueError.""" + invalid_file = tmp_path / "invalid.vtu" + invalid_file.write_text( "This is not a valid VTU file" ) + + with pytest.raises( ValueError, match="Could not find a suitable reader" ): + read_mesh( str( invalid_file ) ) + + +class TestReadUnstructuredGrid: + """Test class for read_unstructured_grid functionality.""" + + def test_read_unstructured_grid_success( self, simple_unstructured_mesh, tmp_path ): + """Test successfully reading an unstructured grid.""" + output_file = tmp_path / "test_ug.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + # Write unstructured grid + write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + + # Read back as unstructured grid + result = read_unstructured_grid( str( output_file ) ) + + assert isinstance( result, vtkUnstructuredGrid ) + assert result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + def test_read_unstructured_grid_wrong_type( self, structured_mesh, tmp_path ): + """Test that reading non-unstructured grid raises TypeError.""" + output_file = tmp_path / "test_sg.vts" + vtk_output = VtkOutput( str( output_file ) ) + + # Write structured grid + write_mesh( structured_mesh, vtk_output, can_overwrite=True ) + + # Try to read as unstructured grid - should fail + with pytest.raises( TypeError, match="not the expected vtkUnstructuredGrid" ): + read_unstructured_grid( str( output_file ) ) + + def test_read_unstructured_grid_nonexistent( self ): + """Test that reading nonexistent file raises FileNotFoundError.""" + with pytest.raises( FileNotFoundError, match="does not exist" ): + read_unstructured_grid( "nonexistent.vtu" ) + + +class TestRoundTripReadWrite: + """Test class for round-trip read/write operations.""" + + def test_vtu_round_trip_binary( self, simple_unstructured_mesh, tmp_path ): + """Test round-trip write and read for VTU binary format.""" + output_file = tmp_path / "roundtrip_binary.vtu" + vtk_output = VtkOutput( str( output_file ), is_data_mode_binary=True ) + + # Write + write_result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert write_result == 1 + + # Read back + read_result = read_unstructured_grid( str( output_file ) ) + + # Compare + assert read_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert read_result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + # Check point coordinates are preserved + for i in range( read_result.GetNumberOfPoints() ): + orig_point = simple_unstructured_mesh.GetPoint( i ) + read_point = read_result.GetPoint( i ) + np.testing.assert_array_almost_equal( orig_point, read_point, decimal=6 ) + + def test_vtu_round_trip_ascii( self, simple_unstructured_mesh, tmp_path ): + """Test round-trip write and read for VTU ASCII format.""" + output_file = tmp_path / "roundtrip_ascii.vtu" + vtk_output = VtkOutput( str( output_file ), is_data_mode_binary=False ) + + # Write + write_result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert write_result == 1 + + # Read back + read_result = read_unstructured_grid( str( output_file ) ) + + # Compare + assert read_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert read_result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + def test_vtk_round_trip( self, simple_unstructured_mesh, tmp_path ): + """Test round-trip write and read for VTK legacy format.""" + output_file = tmp_path / "roundtrip.vtk" + vtk_output = VtkOutput( str( output_file ) ) + + # Write + write_result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert write_result == 1 + + # Read back + read_result = read_unstructured_grid( str( output_file ) ) + + # Compare + assert read_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints() + assert read_result.GetNumberOfCells() == simple_unstructured_mesh.GetNumberOfCells() + + def test_vts_round_trip( self, structured_mesh, tmp_path ): + """Test round-trip write and read for VTS format.""" + output_file = tmp_path / "roundtrip.vts" + vtk_output = VtkOutput( str( output_file ) ) + + # Write + write_result = write_mesh( structured_mesh, vtk_output, can_overwrite=True ) + assert write_result == 1 + + # Read back + read_result = read_mesh( str( output_file ) ) + + # Compare + assert isinstance( read_result, vtkStructuredGrid ) + assert read_result.GetNumberOfPoints() == structured_mesh.GetNumberOfPoints() + + +class TestEdgeCases: + """Test class for edge cases and error conditions.""" + + def test_empty_mesh_write( self, tmp_path ): + """Test writing an empty mesh.""" + empty_mesh = vtkUnstructuredGrid() + output_file = tmp_path / "empty.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + result = write_mesh( empty_mesh, vtk_output, can_overwrite=True ) + assert result == 1 + assert output_file.exists() + + def test_empty_mesh_round_trip( self, tmp_path ): + """Test round-trip with empty mesh.""" + empty_mesh = vtkUnstructuredGrid() + output_file = tmp_path / "empty_roundtrip.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + # Write + write_result = write_mesh( empty_mesh, vtk_output, can_overwrite=True ) + assert write_result == 1 + + # Read back + read_result = read_unstructured_grid( str( output_file ) ) + assert read_result.GetNumberOfPoints() == 0 + assert read_result.GetNumberOfCells() == 0 + + def test_large_path_names( self, simple_unstructured_mesh, tmp_path ): + """Test handling of long file paths.""" + # Create a deep directory structure + deep_dir = tmp_path + for i in range( 5 ): + deep_dir = deep_dir / f"very_long_directory_name_level_{i}" + deep_dir.mkdir( parents=True ) + + output_file = deep_dir / "mesh_with_very_long_filename_that_should_still_work.vtu" + vtk_output = VtkOutput( str( output_file ) ) + + # Should work fine + result = write_mesh( simple_unstructured_mesh, vtk_output, can_overwrite=True ) + assert result == 1 + assert output_file.exists() + + # And read back + read_result = read_unstructured_grid( str( output_file ) ) + assert read_result.GetNumberOfPoints() == simple_unstructured_mesh.GetNumberOfPoints()