Skip to content

Commit 5b7f482

Browse files
bd713DENEL Bertrand
andauthored
feat: add a "check internal tag" action to mesh-doctor (#236)
* Apply suggestions from code review * fix: Fix VTKHandler managment in geos-pv (#234) * Changed tag to int, removed ascii option * style and lint --------- Co-authored-by: DENEL Bertrand <bertrand.denel@total.com>
1 parent 73b213d commit 5b7f482

File tree

5 files changed

+500
-16
lines changed

5 files changed

+500
-16
lines changed
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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 )

mesh-doctor/src/geos/mesh_doctor/parsing/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
SELF_INTERSECTING_ELEMENTS = "selfIntersectingElements"
1818
SUPPORTED_ELEMENTS = "supportedElements"
1919
ORPHAN_2D = "orphan2d"
20+
CHECK_INTERNAL_TAGS = "checkInternalTags"
2021

2122

2223
@dataclass( frozen=True )

0 commit comments

Comments
 (0)