|
| 1 | +# SPDX-License-Identifier: Apache-2.0 |
| 2 | +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. |
| 3 | +"""Check that tagged 2D elements are internal (have exactly 2 volume neighbors).""" |
| 4 | + |
| 5 | +from dataclasses import dataclass |
| 6 | +from typing import Optional, Dict, List, Union |
| 7 | +import vtk |
| 8 | +from tqdm import tqdm |
| 9 | + |
| 10 | +from geos.mesh_doctor.parsing.cliParsing import setupLogger |
| 11 | +from geos.mesh.io.vtkIO import readUnstructuredGrid, writeMesh, VtkOutput |
| 12 | + |
| 13 | + |
| 14 | +@dataclass( frozen=True ) |
| 15 | +class Options: |
| 16 | + """Options for the internal tags check. |
| 17 | +
|
| 18 | + Attributes: |
| 19 | + tagValues: List of tag values to check |
| 20 | + tagArrayName: Name of the cell data array containing tags |
| 21 | + outputCsv: Optional path to output CSV file with problematic elements |
| 22 | + nullTagValue: Optional tag value to assign to faulty cells (e.g., 9999) |
| 23 | + fixedVtkOutput: Optional VtkOutput for mesh with faulty cells retagged |
| 24 | + verbose: Enable detailed connectivity diagnostics for problematic cells |
| 25 | + """ |
| 26 | + tagValues: tuple[ int, ...] |
| 27 | + tagArrayName: str |
| 28 | + outputCsv: Optional[ str ] |
| 29 | + nullTagValue: Optional[ int ] |
| 30 | + fixedVtkOutput: Optional[ VtkOutput ] |
| 31 | + verbose: bool = False |
| 32 | + |
| 33 | + |
| 34 | +@dataclass( frozen=True ) |
| 35 | +class Result: |
| 36 | + """Result of the internal tags check. |
| 37 | +
|
| 38 | + Attributes: |
| 39 | + info: Summary information about the check |
| 40 | + passed: Whether all checked elements have exactly 2 neighbors |
| 41 | + """ |
| 42 | + info: str |
| 43 | + passed: bool |
| 44 | + |
| 45 | + |
| 46 | +@dataclass( frozen=True ) |
| 47 | +class ElementInfo: |
| 48 | + """Information about a tagged element. |
| 49 | +
|
| 50 | + Attributes: |
| 51 | + cellId: Cell ID in the mesh |
| 52 | + tag: Tag value |
| 53 | + numNeighbors: Number of volume neighbors |
| 54 | + neighbors: List of neighbor cell IDs |
| 55 | + """ |
| 56 | + cellId: int |
| 57 | + tag: int |
| 58 | + numNeighbors: int |
| 59 | + neighbors: list[ int ] |
| 60 | + |
| 61 | + |
| 62 | +def __getVolumeNeighbors( mesh: vtk.vtkUnstructuredGrid, cellId: int, volumeCells: set[ int ], |
| 63 | + surfaceCellTypes: list[ int ] ) -> list[ int ]: |
| 64 | + """Find all volume neighbors of a 2D cell. |
| 65 | +
|
| 66 | + Args: |
| 67 | + mesh: The unstructured grid |
| 68 | + cellId: ID of the 2D cell |
| 69 | + volumeCells: Set of volume cell IDs |
| 70 | + surfaceCellTypes: List of surface cell type IDs |
| 71 | +
|
| 72 | + Returns: |
| 73 | + List of volume cell IDs that share all points with the 2D cell |
| 74 | + """ |
| 75 | + surfaceCell = mesh.GetCell( cellId ) |
| 76 | + |
| 77 | + # Get all points of the surface cell |
| 78 | + surfacePoints = set() |
| 79 | + for i in range( surfaceCell.GetNumberOfPoints() ): |
| 80 | + surfacePoints.add( surfaceCell.GetPointId( i ) ) |
| 81 | + |
| 82 | + # Get cells connected to the first point |
| 83 | + firstPoint = surfaceCell.GetPointId( 0 ) |
| 84 | + neighborCells = vtk.vtkIdList() |
| 85 | + mesh.GetPointCells( firstPoint, neighborCells ) |
| 86 | + |
| 87 | + # Find volume cells that contain all surface points |
| 88 | + volumeNeighbors = [] |
| 89 | + for i in range( neighborCells.GetNumberOfIds() ): |
| 90 | + neighborId = neighborCells.GetId( i ) |
| 91 | + |
| 92 | + # Skip if not a volume cell |
| 93 | + if neighborId not in volumeCells: |
| 94 | + continue |
| 95 | + |
| 96 | + # Get points of the volume cell |
| 97 | + volumeCell = mesh.GetCell( neighborId ) |
| 98 | + volumePoints = set() |
| 99 | + for j in range( volumeCell.GetNumberOfPoints() ): |
| 100 | + volumePoints.add( volumeCell.GetPointId( j ) ) |
| 101 | + |
| 102 | + # Check if all surface points are in the volume cell |
| 103 | + if surfacePoints.issubset( volumePoints ): |
| 104 | + volumeNeighbors.append( neighborId ) |
| 105 | + |
| 106 | + return volumeNeighbors |
| 107 | + |
| 108 | + |
| 109 | +def __diagnose1NeighborCell( mesh: vtk.vtkUnstructuredGrid, elem: ElementInfo, volumeCells: set[ int ] ) -> None: |
| 110 | + """Diagnose a cell with only 1 neighbor by showing face-by-face neighbors. |
| 111 | +
|
| 112 | + Args: |
| 113 | + mesh: The unstructured grid |
| 114 | + elem: The problematic element info (must have exactly 1 neighbor) |
| 115 | + volumeCells: Set of all volume cell IDs |
| 116 | + """ |
| 117 | + if elem.numNeighbors != 1: |
| 118 | + return |
| 119 | + |
| 120 | + # Get the 2D surface cell we're trying to match |
| 121 | + surfaceCell = mesh.GetCell( elem.cellId ) |
| 122 | + surfacePointIds = surfaceCell.GetPointIds() |
| 123 | + surfaceNodes = [ surfacePointIds.GetId( i ) for i in range( surfacePointIds.GetNumberOfIds() ) ] |
| 124 | + |
| 125 | + neighborCellId = elem.neighbors[ 0 ] |
| 126 | + volumeCell = mesh.GetCell( neighborCellId ) |
| 127 | + cellTypeName = volumeCell.GetClassName() |
| 128 | + numFaces = volumeCell.GetNumberOfFaces() |
| 129 | + |
| 130 | + setupLogger.warning( f" Cell {elem.cellId} (tag={elem.tag}) has only 1 neighbor: cell {neighborCellId}" ) |
| 131 | + setupLogger.warning( f" 2D element nodes: {surfaceNodes}" ) |
| 132 | + setupLogger.warning( f" Cell type: {cellTypeName} ({numFaces} faces)" ) |
| 133 | + setupLogger.warning( " Face-by-face neighbors:" ) |
| 134 | + |
| 135 | + # For each face of the volume cell, find the neighbor |
| 136 | + for faceIdx in range( numFaces ): |
| 137 | + face = volumeCell.GetFace( faceIdx ) |
| 138 | + facePointIds = face.GetPointIds() |
| 139 | + |
| 140 | + # Find neighboring cells that share this face |
| 141 | + neighborCells = vtk.vtkIdList() |
| 142 | + mesh.GetCellNeighbors( neighborCellId, facePointIds, neighborCells ) |
| 143 | + |
| 144 | + # Filter to only volume cells |
| 145 | + volumeNeighbors = [] |
| 146 | + for i in range( neighborCells.GetNumberOfIds() ): |
| 147 | + nId = neighborCells.GetId( i ) |
| 148 | + if nId in volumeCells and nId != neighborCellId: |
| 149 | + volumeNeighbors.append( nId ) |
| 150 | + |
| 151 | + if volumeNeighbors: |
| 152 | + setupLogger.warning( f" Face {faceIdx}: {volumeNeighbors}" ) |
| 153 | + else: |
| 154 | + setupLogger.warning( f" Face {faceIdx}: <boundary or no 3D neighbor>" ) |
| 155 | + |
| 156 | + |
| 157 | +def checkInternalTags( mesh: vtk.vtkUnstructuredGrid, options: Options ) -> Result: |
| 158 | + """Check that all 2D elements with specified tags have exactly 2 volume neighbors. |
| 159 | +
|
| 160 | + Args: |
| 161 | + mesh: Input unstructured grid |
| 162 | + options: Check options |
| 163 | +
|
| 164 | + Returns: |
| 165 | + Result with summary information |
| 166 | +
|
| 167 | + Raises: |
| 168 | + ValueError: If tag array not found or other validation errors |
| 169 | + """ |
| 170 | + setupLogger.info( f"Mesh: {mesh.GetNumberOfPoints()} points, {mesh.GetNumberOfCells()} cells" ) |
| 171 | + |
| 172 | + # Get tags array |
| 173 | + cellData = mesh.GetCellData() |
| 174 | + if not cellData.HasArray( options.tagArrayName ): |
| 175 | + availableArrays = [ cellData.GetArrayName( i ) for i in range( cellData.GetNumberOfArrays() ) ] |
| 176 | + raise ValueError( f"Tag array '{options.tagArrayName}' not found. " |
| 177 | + f"Available cell data arrays: {availableArrays}" ) |
| 178 | + |
| 179 | + tags = cellData.GetArray( options.tagArrayName ) |
| 180 | + |
| 181 | + # Convert tag array to int if needed |
| 182 | + if not isinstance( tags, vtk.vtkIntArray ): |
| 183 | + setupLogger.info( f"Converting tag array '{options.tagArrayName}' to integer type..." ) |
| 184 | + intTagsArray = vtk.vtkIntArray() |
| 185 | + intTagsArray.SetName( tags.GetName() ) |
| 186 | + intTagsArray.SetNumberOfComponents( tags.GetNumberOfComponents() ) |
| 187 | + for i in range( tags.GetNumberOfTuples() ): |
| 188 | + intTagsArray.InsertNextValue( int( tags.GetValue( i ) ) ) |
| 189 | + mesh.GetCellData().RemoveArray( options.tagArrayName ) |
| 190 | + mesh.GetCellData().AddArray( intTagsArray ) |
| 191 | + tags = intTagsArray |
| 192 | + |
| 193 | + # Define cell types |
| 194 | + SURFACE_CELL_TYPES = [ vtk.VTK_TRIANGLE, vtk.VTK_QUAD, vtk.VTK_POLYGON, vtk.VTK_PIXEL ] |
| 195 | + VOLUME_CELL_TYPES = [ vtk.VTK_TETRA, vtk.VTK_HEXAHEDRON, vtk.VTK_WEDGE, vtk.VTK_PYRAMID, vtk.VTK_VOXEL ] |
| 196 | + |
| 197 | + # Build connectivity |
| 198 | + setupLogger.info( "Building cell connectivity..." ) |
| 199 | + mesh.BuildLinks() |
| 200 | + |
| 201 | + # Find volume cells and build tag mapping for 2D cells in one pass |
| 202 | + nCells = mesh.GetNumberOfCells() |
| 203 | + volumeCells = set() |
| 204 | + tagToCells: Dict[ int, List[ int ] ] = {} # Map tag values to list of 2D cell IDs |
| 205 | + |
| 206 | + for cellId in tqdm( range( nCells ), desc="Building cell mappings" ): |
| 207 | + cellType = mesh.GetCellType( cellId ) |
| 208 | + |
| 209 | + # Collect volume cells |
| 210 | + if cellType in VOLUME_CELL_TYPES: |
| 211 | + volumeCells.add( cellId ) |
| 212 | + |
| 213 | + # Collect 2D cells by tag (only for tags we're interested in) |
| 214 | + if cellType in SURFACE_CELL_TYPES: |
| 215 | + currentTag = tags.GetValue( cellId ) |
| 216 | + if currentTag in options.tagValues: |
| 217 | + if currentTag not in tagToCells: |
| 218 | + tagToCells[ currentTag ] = [] |
| 219 | + tagToCells[ currentTag ].append( cellId ) |
| 220 | + |
| 221 | + setupLogger.info( f"Found {len(volumeCells)} volume cells" ) |
| 222 | + |
| 223 | + # Store results by tag |
| 224 | + tagResults = {} |
| 225 | + allBadElements = [] |
| 226 | + |
| 227 | + # Process each tag value |
| 228 | + for tagValue in options.tagValues: |
| 229 | + setupLogger.info( f"{'='*60}" ) |
| 230 | + setupLogger.info( f"Checking tag = {tagValue}" ) |
| 231 | + setupLogger.info( f"{'='*60}" ) |
| 232 | + |
| 233 | + elementsByNeighbors: Dict[ Union[ int, str ], List[ ElementInfo ] ] = { 0: [], 1: [], 2: [], 'other': [] } |
| 234 | + |
| 235 | + # Get cells with this tag (pre-filtered) |
| 236 | + cellsWithTag = tagToCells.get( tagValue, [] ) |
| 237 | + setupLogger.info( f"Found {len(cellsWithTag)} cells with tag {tagValue}" ) |
| 238 | + |
| 239 | + # Process only the cells with this specific tag |
| 240 | + for cellId in tqdm( cellsWithTag, desc=f"Processing tag {tagValue}" ): |
| 241 | + # Count volume neighbors |
| 242 | + volumeNeighbors = __getVolumeNeighbors( mesh, cellId, volumeCells, SURFACE_CELL_TYPES ) |
| 243 | + numNeighbors = len( volumeNeighbors ) |
| 244 | + |
| 245 | + elemInfo = ElementInfo( cellId=cellId, tag=tagValue, numNeighbors=numNeighbors, neighbors=volumeNeighbors ) |
| 246 | + |
| 247 | + # Categorize by neighbor count |
| 248 | + if numNeighbors in { 0, 1, 2 }: |
| 249 | + elementsByNeighbors[ numNeighbors ].append( elemInfo ) |
| 250 | + else: |
| 251 | + elementsByNeighbors[ 'other' ].append( elemInfo ) |
| 252 | + |
| 253 | + # Calculate totals |
| 254 | + total = sum( len( v ) for v in elementsByNeighbors.values() ) |
| 255 | + |
| 256 | + # Print summary for this tag |
| 257 | + setupLogger.info( f"Summary for tag = {tagValue}:" ) |
| 258 | + setupLogger.info( f" Total 2D cells: {total}" ) |
| 259 | + setupLogger.info( f" With 0 neighbors: {len(elementsByNeighbors[0])} cells" ) |
| 260 | + setupLogger.info( f" With 1 neighbor: {len(elementsByNeighbors[1])} cells" ) |
| 261 | + setupLogger.info( f" With 2 neighbors: {len(elementsByNeighbors[2])} cells" ) |
| 262 | + setupLogger.info( f" With 3+ neighbors: {len(elementsByNeighbors['other'])} cells" ) |
| 263 | + |
| 264 | + # Collect bad elements |
| 265 | + allBadElements.extend( elementsByNeighbors[ 0 ] ) |
| 266 | + allBadElements.extend( elementsByNeighbors[ 1 ] ) |
| 267 | + allBadElements.extend( elementsByNeighbors[ 'other' ] ) |
| 268 | + |
| 269 | + tagResults[ tagValue ] = elementsByNeighbors |
| 270 | + |
| 271 | + # Print overall summary |
| 272 | + setupLogger.info( f"{'='*60}" ) |
| 273 | + setupLogger.info( "OVERALL SUMMARY" ) |
| 274 | + setupLogger.info( f"{'='*60}" ) |
| 275 | + |
| 276 | + totalBad = len( allBadElements ) |
| 277 | + totalChecked = sum( sum( len( v ) for v in neighbors.values() ) for neighbors in tagResults.values() ) |
| 278 | + |
| 279 | + setupLogger.info( f"Tags checked: {list(options.tagValues)}" ) |
| 280 | + setupLogger.info( f"Total 2D cells checked: {totalChecked}" ) |
| 281 | + setupLogger.info( f"Cells with exactly 2 neighbors: {totalChecked - totalBad}" ) |
| 282 | + setupLogger.info( f"Cells with other than 2 neighbors: {totalBad}" ) |
| 283 | + |
| 284 | + if totalBad > 0: |
| 285 | + setupLogger.warning( f"Found {totalBad} problematic cells across all tags!" ) |
| 286 | + # Group bad elements by tag for reporting |
| 287 | + badByTag = {} |
| 288 | + for elem in allBadElements: |
| 289 | + if elem.tag not in badByTag: |
| 290 | + badByTag[ elem.tag ] = 0 |
| 291 | + badByTag[ elem.tag ] += 1 |
| 292 | + for tag, count in sorted( badByTag.items() ): |
| 293 | + setupLogger.warning( f" Tag {tag}: {count} problematic cells" ) |
| 294 | + |
| 295 | + # Diagnose cells with only 1 neighbor (only in verbose mode) |
| 296 | + if options.verbose: |
| 297 | + oneNeighborCells = [ elem for elem in allBadElements if elem.numNeighbors == 1 ] |
| 298 | + if oneNeighborCells: |
| 299 | + setupLogger.warning( f"{'='*60}" ) |
| 300 | + setupLogger.warning( "CONNECTIVITY ANALYSIS FOR 1-NEIGHBOR CELLS" ) |
| 301 | + setupLogger.warning( f"{'='*60}" ) |
| 302 | + |
| 303 | + for elem in oneNeighborCells: |
| 304 | + __diagnose1NeighborCell( mesh, elem, volumeCells ) |
| 305 | + else: |
| 306 | + setupLogger.info( "All cells have exactly 2 neighbors!" ) |
| 307 | + |
| 308 | + # Write to CSV if requested |
| 309 | + if options.outputCsv and allBadElements: |
| 310 | + setupLogger.info( f"Writing bad elements to: {options.outputCsv}" ) |
| 311 | + with open( options.outputCsv, 'w' ) as f: |
| 312 | + f.write( "cell_id,tag,num_neighbors,neighbor_1,neighbor_2\n" ) |
| 313 | + for elem in allBadElements: |
| 314 | + neighbor1 = elem.neighbors[ 0 ] if len( elem.neighbors ) > 0 else -1 |
| 315 | + neighbor2 = elem.neighbors[ 1 ] if len( elem.neighbors ) > 1 else -1 |
| 316 | + f.write( f"{elem.cellId},{elem.tag},{elem.numNeighbors},{neighbor1},{neighbor2}\n" ) |
| 317 | + setupLogger.info( f"Written {len(allBadElements)} rows" ) |
| 318 | + elif options.outputCsv and not allBadElements: |
| 319 | + setupLogger.info( "No bad elements to write (all cells have 2 neighbors)" ) |
| 320 | + |
| 321 | + # Retag faulty cells and write fixed mesh if requested |
| 322 | + if options.fixedVtkOutput and options.nullTagValue is not None and allBadElements: |
| 323 | + setupLogger.info( f"Retagging {len(allBadElements)} faulty cells to tag {options.nullTagValue}..." ) |
| 324 | + |
| 325 | + # Get the tags array |
| 326 | + tagsArray = mesh.GetCellData().GetArray( options.tagArrayName ) |
| 327 | + |
| 328 | + # Create set of bad cell IDs for fast lookup |
| 329 | + badCellIds = { elem.cellId for elem in allBadElements } |
| 330 | + |
| 331 | + # Retag the problematic cells |
| 332 | + numRetagged = 0 |
| 333 | + for cellId in badCellIds: |
| 334 | + tagsArray.SetValue( cellId, options.nullTagValue ) |
| 335 | + numRetagged += 1 |
| 336 | + |
| 337 | + setupLogger.info( f"Retagged {numRetagged} cells" ) |
| 338 | + |
| 339 | + # Write the modified mesh (tag array already in int format) |
| 340 | + writeMesh( mesh, options.fixedVtkOutput ) |
| 341 | + setupLogger.info( f"Written fixed mesh to: {options.fixedVtkOutput.output}" ) |
| 342 | + elif options.fixedVtkOutput and not allBadElements: |
| 343 | + setupLogger.info( "No faulty cells to retag (all cells have 2 neighbors)" ) |
| 344 | + elif options.fixedVtkOutput and options.nullTagValue is None: |
| 345 | + setupLogger.warning( "Cannot write fixed mesh: --nullTagValue not provided (e.g., add --nullTagValue 9999)" ) |
| 346 | + |
| 347 | + # Create result message |
| 348 | + if totalBad > 0: |
| 349 | + resultMsg = f"FAILED: Found {totalBad} non-internal elements out of {totalChecked} checked" |
| 350 | + passed = False |
| 351 | + else: |
| 352 | + resultMsg = f"PASSED: All {totalChecked} tagged elements are internal (have exactly 2 volume neighbors)" |
| 353 | + passed = True |
| 354 | + |
| 355 | + return Result( info=resultMsg, passed=passed ) |
| 356 | + |
| 357 | + |
| 358 | +def action( vtuInputFile: str, options: Options ) -> Result: |
| 359 | + """Main action to check that tagged elements are internal. |
| 360 | +
|
| 361 | + Args: |
| 362 | + vtuInputFile: Path to input VTU file |
| 363 | + options: Check options |
| 364 | +
|
| 365 | + Returns: |
| 366 | + Result with summary information |
| 367 | + """ |
| 368 | + mesh = readUnstructuredGrid( vtuInputFile ) |
| 369 | + return checkInternalTags( mesh, options ) |
0 commit comments