diff --git a/docs/geos-mesh.rst b/docs/geos-mesh.rst index 81d82205..061f596d 100644 --- a/docs/geos-mesh.rst +++ b/docs/geos-mesh.rst @@ -1,10 +1,22 @@ GEOS Mesh tools ==================== +**geos-mesh** is a Python package that contains several tools and utilities to handle processing and quality checks of meshes. + .. toctree:: - :maxdepth: 5 + :maxdepth: 1 :caption: Contents: - ./geos_mesh_docs/home.rst + ./geos_mesh_docs/doctor + + ./geos_mesh_docs/converter + + ./geos_mesh_docs/io + + ./geos_mesh_docs/model + + ./geos_mesh_docs/processing + + ./geos_mesh_docs/stats - ./geos_mesh_docs/modules.rst \ No newline at end of file + ./geos_mesh_docs/utils diff --git a/docs/geos_mesh_docs/doctor.rst b/docs/geos_mesh_docs/doctor.rst index 0da26c1e..d1a1499e 100644 --- a/docs/geos_mesh_docs/doctor.rst +++ b/docs/geos_mesh_docs/doctor.rst @@ -5,6 +5,17 @@ Mesh Doctor ``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 +^^^^^^^^^^^^^ + +To use mesh-doctor, you first need to have installed the ``geos-mesh`` package using the following command: + +.. code-block:: bash + + python -m pip install --upgrade ./geos-mesh + +Once done, you can call ``mesh-doctor`` in your command line as presented in the rest of this documentation. + Modules ^^^^^^^ @@ -12,13 +23,11 @@ To list all the modules available through ``mesh-doctor``, you can simply use th .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py --help + $ mesh-doctor --help usage: mesh_doctor.py [-h] [-v] [-q] -i VTK_MESH_FILE {collocated_nodes,element_volumes,fix_elements_orderings,generate_cube,generate_fractures,generate_global_ids,non_conformal,self_intersecting_elements,supported_elements} ... - Inspects meshes for GEOSX. - positional arguments: {collocated_nodes,element_volumes,fix_elements_orderings,generate_cube,generate_fractures,generate_global_ids,non_conformal,self_intersecting_elements,supported_elements} Modules @@ -40,14 +49,12 @@ To list all the modules available through ``mesh-doctor``, you can simply use th Checks if the faces of the elements are self intersecting. supported_elements Check that all the elements of the mesh are supported by GEOSX. - options: -h, --help show this help message and exit -v Use -v 'INFO', -vv for 'DEBUG'. Defaults to 'WARNING'. -q Use -q to reduce the verbosity of the output. -i VTK_MESH_FILE, --vtk-input-file VTK_MESH_FILE - Note that checks are dynamically loaded. An option may be missing because of an unloaded module. Increase verbosity (-v, -vv) to get full information. @@ -57,9 +64,8 @@ For example .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py collocated_nodes --help + $ mesh-doctor collocated_nodes --help usage: mesh_doctor.py collocated_nodes [-h] --tolerance TOLERANCE - options: -h, --help show this help message and exit --tolerance TOLERANCE [float]: The absolute distance between two nodes for them to be considered collocated. @@ -78,6 +84,34 @@ You can solve this issue by installing the dependencies of ``mesh-doctor`` defin Here is a list and brief description of all the modules available. +``all_checks`` +"""""""""""""" + +``mesh-doctor`` modules are called ``actions`` and they can be splitted into 2 different categories: +``check actions`` that will give you a feedback on a .vtu mesh that you would like to use in GEOS. +``operate actions`` that will either create a new mesh or modify a mesh. + +``all_checks`` aims at applying every single ``check`` action in one single command. The list is the following: +``collocated_nodes``, ``element_volumes``, ``non_conformal``, ``self_intersecting_elements``, ``supported_elements``. + +.. code-block:: + + $ mesh-doctor all_checks --help + usage: mesh-doctor all_checks [-h] [--checks_to_perform CHECKS_TO_PERFORM] [--set_parameters SET_PARAMETERS] + + options: + -h, --help show this help message and exit + --checks_to_perform CHECKS_TO_PERFORM + Comma-separated list of mesh-doctor checks to perform. If no input was given, all of the following checks will be executed by default: + ['collocated_nodes', 'element_volumes', 'non_conformal', 'self_intersecting_elements', 'supported_elements']. + If you want to choose only certain of them, you can name them individually. + Example: --checks_to_perform collocated_nodes,element_volumes (default: ) + --set_parameters SET_PARAMETERS + Comma-separated list of parameters to set for the checks (e.g., 'param_name:value'). These parameters override the defaults. + Default parameters are: For collocated_nodes: tolerance:0.0. For element_volumes: min_volume:0.0. For non_conformal: angle_tolerance:10.0, point_tolerance:0.0, face_tolerance:0.0. + For self_intersecting_elements: min_distance:2.220446049250313e-16. For supported_elements: chunk_size:1, nproc:8. + Example: --set_parameters parameter_name:10.5,other_param:25 (default: ) + ``collocated_nodes`` """""""""""""""""""" @@ -86,9 +120,8 @@ It is not uncommon to define multiple nodes for the exact same position, which w .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py collocated_nodes --help + $ mesh-doctor collocated_nodes --help usage: mesh_doctor.py collocated_nodes [-h] --tolerance TOLERANCE - options: -h, --help show this help message and exit --tolerance TOLERANCE [float]: The absolute distance between two nodes for them to be considered collocated. @@ -101,9 +134,8 @@ Cells with negative volumes will typically be an issue for ``geos`` and should b .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py element_volumes --help + $ mesh-doctor element_volumes --help usage: mesh_doctor.py element_volumes [-h] --min 0.0 - options: -h, --help show this help message and exit --min 0.0 [float]: The minimum acceptable volume. Defaults to 0.0. @@ -117,12 +149,11 @@ This can be convenient if you cannot regenerate the mesh. .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py fix_elements_orderings --help + $ mesh-doctor fix_elements_orderings --help usage: mesh_doctor.py fix_elements_orderings [-h] [--Hexahedron 1,6,5,4,7,0,2,3] [--Prism5 8,2,0,7,6,9,5,1,4,3] [--Prism6 11,2,8,10,5,0,9,7,6,1,4,3] [--Pyramid 3,4,0,2,1] [--Tetrahedron 2,0,3,1] [--Voxel 1,6,5,4,7,0,2,3] [--Wedge 3,5,4,0,2,1] --output OUTPUT [--data-mode binary, ascii] - options: -h, --help show this help message and exit --Hexahedron 1,6,5,4,7,0,2,3 @@ -148,11 +179,10 @@ This tool can also be useful to generate a trial mesh that will later be refined .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py generate_cube --help + $ mesh-doctor generate_cube --help usage: mesh_doctor.py generate_cube [-h] [--x 0:1.5:3] [--y 0:5:10] [--z 0:1] [--nx 2:2] [--ny 1:1] [--nz 4] [--fields name:support:dim [name:support:dim ...]] [--cells] [--no-cells] [--points] [--no-points] --output OUTPUT [--data-mode binary, ascii] - options: -h, --help show this help message and exit --x 0:1.5:3 [list of floats]: X coordinates of the points. @@ -179,10 +209,9 @@ The ``generate_fractures`` module will split the mesh and generate the multi-blo .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py generate_fractures --help + $ mesh-doctor generate_fractures --help usage: mesh_doctor.py generate_fractures [-h] --policy field, internal_surfaces [--name NAME] [--values VALUES] --output OUTPUT [--data-mode binary, ascii] [--fractures_output_dir FRACTURES_OUTPUT_DIR] - options: -h, --help show this help message and exit --policy field, internal_surfaces @@ -210,10 +239,9 @@ The ``generate_global_ids`` can generate `global ids` for the imported ``vtk`` m .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py generate_global_ids --help + $ mesh-doctor generate_global_ids --help usage: mesh_doctor.py generate_global_ids [-h] [--cells] [--no-cells] [--points] [--no-points] --output OUTPUT [--data-mode binary, ascii] - options: -h, --help show this help message and exit --cells [bool]: Generate global ids for cells. Defaults to true. @@ -234,10 +262,9 @@ This module can be a bit time consuming. .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py non_conformal --help + $ mesh-doctor non_conformal --help usage: mesh_doctor.py non_conformal [-h] [--angle_tolerance 10.0] [--point_tolerance POINT_TOLERANCE] [--face_tolerance FACE_TOLERANCE] - options: -h, --help show this help message and exit --angle_tolerance 10.0 [float]: angle tolerance in degrees. Defaults to 10.0 @@ -254,9 +281,8 @@ This module will display the elements that have faces intersecting. .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py self_intersecting_elements --help + $ mesh-doctor self_intersecting_elements --help usage: mesh_doctor.py self_intersecting_elements [-h] [--min 2.220446049250313e-16] - options: -h, --help show this help message and exit --min 2.220446049250313e-16 @@ -275,9 +301,8 @@ It will also verify that the ``VTK_POLYHEDRON`` cells can effectively get conver .. code-block:: - $ python src/geos/mesh/doctor/mesh_doctor.py supported_elements --help + $ mesh-doctor supported_elements --help usage: mesh_doctor.py supported_elements [-h] [--chunck_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 diff --git a/docs/geos_mesh_docs/home.rst b/docs/geos_mesh_docs/home.rst deleted file mode 100644 index 78cffacb..00000000 --- a/docs/geos_mesh_docs/home.rst +++ /dev/null @@ -1,4 +0,0 @@ -Home -======== - -**geos-mesh** is a Python package that contains several tools and utilities to handle processing and quality checks of meshes. \ No newline at end of file diff --git a/docs/geos_mesh_docs/modules.rst b/docs/geos_mesh_docs/modules.rst deleted file mode 100644 index 4e13c711..00000000 --- a/docs/geos_mesh_docs/modules.rst +++ /dev/null @@ -1,20 +0,0 @@ -GEOS Mesh tools -=================== - - -.. toctree:: - :maxdepth: 5 - - doctor - - converter - - io - - model - - processing - - stats - - utils \ No newline at end of file diff --git a/geos-mesh/src/geos/mesh/doctor/checks/__init__.py b/geos-mesh/src/geos/mesh/doctor/actions/__init__.py similarity index 100% rename from geos-mesh/src/geos/mesh/doctor/checks/__init__.py rename to geos-mesh/src/geos/mesh/doctor/actions/__init__.py diff --git a/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py b/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py new file mode 100644 index 00000000..091e4395 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/actions/all_checks.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from geos.mesh.doctor.register import __load_module_action +from geos.utils.Logger import getLogger + +logger = getLogger( "All_checks" ) + + +@dataclass( frozen=True ) +class Options: + checks_to_perform: list[ str ] + checks_options: dict[ str, any ] + check_displays: dict[ str, any ] + + +@dataclass( frozen=True ) +class Result: + check_results: dict[ str, any ] + + +def action( vtk_input_file: str, options: Options ) -> list[ Result ]: + check_results: dict[ str, any ] = dict() + for check_name in options.checks_to_perform: + check_action = __load_module_action( check_name ) + logger.info( f"Performing check '{check_name}'." ) + option = options.checks_options[ check_name ] + check_result = check_action( vtk_input_file, option ) + check_results[ check_name ] = check_result + return Result( check_results=check_results ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/check_fractures.py b/geos-mesh/src/geos/mesh/doctor/actions/check_fractures.py similarity index 92% rename from geos-mesh/src/geos/mesh/doctor/checks/check_fractures.py rename to geos-mesh/src/geos/mesh/doctor/actions/check_fractures.py index 91375e47..ce6fcd6a 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/check_fractures.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/check_fractures.py @@ -1,4 +1,3 @@ -import logging import numpy from dataclasses import dataclass from tqdm import tqdm @@ -7,8 +6,11 @@ from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkIOXML import vtkXMLMultiBlockDataReader from vtkmodules.util.numpy_support import vtk_to_numpy -from geos.mesh.doctor.checks.generate_fractures import Coordinates3D +from geos.mesh.doctor.actions.generate_fractures import Coordinates3D from geos.mesh.utils.genericHelpers import vtk_iter +from geos.utils.Logger import getLogger + +logger = getLogger( "check_fractures" ) @dataclass( frozen=True ) @@ -113,11 +115,11 @@ def __check_neighbors( matrix: vtkUnstructuredGrid, fracture: vtkUnstructuredGri if f in fracture_faces: found += 1 if found != 2: - logging.warning( f"Something went wrong since we should have found 2 fractures faces (we found {found})" + - f" for collocated nodes {cns}." ) + logger.warning( f"Something went wrong since we should have found 2 fractures faces (we found {found})" + + f" for collocated nodes {cns}." ) -def __check( vtk_input_file: str, options: Options ) -> Result: +def __action( vtk_input_file: str, options: Options ) -> Result: matrix, fracture = __read_multiblock( vtk_input_file, options.matrix_name, options.fracture_name ) matrix_points: vtkPoints = matrix.GetPoints() fracture_points: vtkPoints = fracture.GetPoints() @@ -148,9 +150,9 @@ def __check( vtk_input_file: str, options: Options ) -> Result: return Result( errors=errors ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: try: - return __check( vtk_input_file, options ) + return __action( vtk_input_file, options ) except BaseException as e: - logging.error( e ) + logger.error( e ) return Result( errors=() ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/collocated_nodes.py b/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py similarity index 91% rename from geos-mesh/src/geos/mesh/doctor/checks/collocated_nodes.py rename to geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py index 74cbbe8c..5f63dbbf 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/collocated_nodes.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/collocated_nodes.py @@ -1,11 +1,13 @@ from collections import defaultdict from dataclasses import dataclass -import logging import numpy from typing import Collection, Iterable from vtkmodules.vtkCommonCore import reference, vtkPoints from vtkmodules.vtkCommonDataModel import vtkIncrementalOctreePointLocator from geos.mesh.io.vtkIO import read_mesh +from geos.utils.Logger import getLogger + +logger = getLogger( "collocated_nodes" ) @dataclass( frozen=True ) @@ -19,7 +21,7 @@ class Result: wrong_support_elements: Collection[ int ] # Element indices with support node indices appearing more than once. -def __check( mesh, options: Options ) -> Result: +def __action( mesh, options: Options ) -> Result: points = mesh.GetPoints() locator = vtkIncrementalOctreePointLocator() @@ -38,7 +40,7 @@ def __check( mesh, options: Options ) -> Result: # If it's not inserted, `point_id` contains the node that was already at that location. # But in that case, `point_id` is the new numbering in the destination points array. # It's more useful for the user to get the old index in the original mesh, so he can look for it in his data. - logging.debug( + logger.debug( f"Point {i} at {points.GetPoint(i)} has been rejected, point {filtered_to_original[point_id.get()]} is already inserted." ) rejected_points[ point_id.get() ].append( i ) @@ -63,6 +65,6 @@ def __check( mesh, options: Options ) -> Result: return Result( nodes_buckets=tmp, wrong_support_elements=wrong_support_elements ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/element_volumes.py b/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py similarity index 89% rename from geos-mesh/src/geos/mesh/doctor/checks/element_volumes.py rename to geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py index 3a37375f..20a16f27 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/element_volumes.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/element_volumes.py @@ -1,11 +1,13 @@ from dataclasses import dataclass -import logging from typing import List, Tuple import uuid from vtkmodules.vtkCommonDataModel import 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.io.vtkIO import read_mesh +from geos.utils.Logger import getLogger + +logger = getLogger( "element_volumes" ) @dataclass( frozen=True ) @@ -18,7 +20,7 @@ class Result: element_volumes: List[ Tuple[ int, float ] ] -def __check( mesh, options: Options ) -> Result: +def __action( mesh, options: Options ) -> Result: cs = vtkCellSizeFilter() cs.ComputeAreaOff() @@ -43,7 +45,7 @@ def __check( mesh, options: Options ) -> Result: mq.SetWedgeQualityMeasureToVolume() SUPPORTED_TYPES.append( VTK_WEDGE ) else: - logging.warning( + logger.warning( "Your \"pyvtk\" version does not bring pyramid nor wedge support with vtkMeshQuality. Using the fallback solution." ) @@ -66,6 +68,6 @@ def __check( mesh, options: Options ) -> Result: return Result( element_volumes=small_volumes ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/fix_elements_orderings.py b/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py similarity index 93% rename from geos-mesh/src/geos/mesh/doctor/checks/fix_elements_orderings.py rename to geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py index 26c958dc..3e00cf52 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/fix_elements_orderings.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/fix_elements_orderings.py @@ -17,7 +17,7 @@ class Result: unchanged_cell_types: FrozenSet[ int ] -def __check( mesh, options: Options ) -> Result: +def __action( mesh, 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 @@ -48,6 +48,6 @@ def __check( mesh, options: Options ) -> Result: unchanged_cell_types=frozenset( unchanged_cell_types ) ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/generate_cube.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py similarity index 93% rename from geos-mesh/src/geos/mesh/doctor/checks/generate_cube.py rename to geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py index 5abd17f1..2edca5fe 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/generate_cube.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_cube.py @@ -1,13 +1,15 @@ from dataclasses import dataclass -import logging import numpy 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.checks.generate_global_ids import __build_global_ids +from geos.mesh.doctor.actions.generate_global_ids import __build_global_ids from geos.mesh.io.vtkIO import VtkOutput, write_mesh +from geos.utils.Logger import getLogger + +logger = getLogger( "generate_cube" ) @dataclass( frozen=True ) @@ -132,15 +134,15 @@ def build_coordinates( positions, num_elements ): return cube -def __check( options: Options ) -> Result: +def __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}" ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: try: - return __check( options ) + return __action( options ) except BaseException as e: - logging.error( e ) + logger.error( e ) return Result( info="Something went wrong." ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/generate_fractures.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py similarity index 96% rename from geos-mesh/src/geos/mesh/doctor/checks/generate_fractures.py rename to geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py index 82e25b7b..a755b7e8 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/generate_fractures.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_fractures.py @@ -1,7 +1,6 @@ from collections import defaultdict from dataclasses import dataclass from enum import Enum -import logging import networkx from numpy import empty, ones, zeros from tqdm import tqdm @@ -12,15 +11,17 @@ VTK_POLYHEDRON ) from vtkmodules.util.numpy_support import numpy_to_vtk, vtk_to_numpy from vtkmodules.util.vtkConstants import VTK_ID_TYPE -from geos.mesh.doctor.checks.vtk_polyhedron import FaceStream +from geos.mesh.doctor.actions.vtk_polyhedron import FaceStream 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.utils.Logger import getLogger """ 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 """ +logger = getLogger( "generate_fractures" ) + IDMapping = Mapping[ int, int ] CellsPointsCoords = dict[ int, list[ tuple[ float ] ] ] Coordinates3D = tuple[ float ] @@ -254,7 +255,7 @@ def __copy_fields_splitted_mesh( old_mesh: vtkUnstructuredGrid, splitted_mesh: v input_cell_data = old_mesh.GetCellData() for i in range( input_cell_data.GetNumberOfArrays() ): input_array: vtkDataArray = input_cell_data.GetArray( i ) - logging.info( f"Copying cell field \"{input_array.GetName()}\"." ) + logger.info( f"Copying cell field \"{input_array.GetName()}\"." ) tmp = input_array.NewInstance() tmp.DeepCopy( input_array ) splitted_mesh.GetCellData().AddArray( input_array ) @@ -263,7 +264,7 @@ def __copy_fields_splitted_mesh( old_mesh: vtkUnstructuredGrid, splitted_mesh: v input_field_data = old_mesh.GetFieldData() for i in range( input_field_data.GetNumberOfArrays() ): input_array = input_field_data.GetArray( i ) - logging.info( f"Copying field data \"{input_array.GetName()}\"." ) + logger.info( f"Copying field data \"{input_array.GetName()}\"." ) tmp = input_array.NewInstance() tmp.DeepCopy( input_array ) splitted_mesh.GetFieldData().AddArray( input_array ) @@ -274,7 +275,7 @@ def __copy_fields_splitted_mesh( old_mesh: vtkUnstructuredGrid, splitted_mesh: v for i in range( input_point_data.GetNumberOfArrays() ): old_points_array = vtk_to_numpy( input_point_data.GetArray( i ) ) name: str = input_point_data.GetArrayName( i ) - logging.info( f"Copying point data \"{name}\"." ) + logger.info( f"Copying point data \"{name}\"." ) old_nrows: int = old_points_array.shape[ 0 ] old_ncols: int = 1 if len( old_points_array.shape ) == 1 else old_points_array.shape[ 1 ] # Reshape old_points_array if it is 1-dimensional @@ -313,7 +314,7 @@ def __copy_fields_fracture_mesh( old_mesh: vtkUnstructuredGrid, fracture_mesh: v if len( old_cells_array.shape ) == 1: old_cells_array = old_cells_array.reshape( ( old_nrows, 1 ) ) name: str = input_cell_data.GetArrayName( i ) - logging.info( f"Copying cell data \"{name}\"." ) + logger.info( f"Copying cell data \"{name}\"." ) new_array = old_cells_array[ face_cell_id, : ] # Reshape the VTK array to match the original dimensions old_ncols: int = 1 if len( old_cells_array.shape ) == 1 else old_cells_array.shape[ 1 ] @@ -334,7 +335,7 @@ def __copy_fields_fracture_mesh( old_mesh: vtkUnstructuredGrid, fracture_mesh: v if len( old_points_array.shape ) == 1: old_points_array = old_points_array.reshape( ( old_nrows, 1 ) ) name = input_point_data.GetArrayName( i ) - logging.info( f"Copying point data \"{name}\"." ) + logger.info( f"Copying point data \"{name}\"." ) new_array = old_points_array[ list( node_3d_to_node_2d.keys() ), : ] old_ncols = 1 if len( old_points_array.shape ) == 1 else old_points_array.shape[ 1 ] if old_ncols > 1: @@ -433,7 +434,7 @@ def __generate_fracture_mesh( old_mesh: vtkUnstructuredGrid, fracture_info: Frac :param cell_to_node_mapping: For each cell, gives the nodes that must be duplicated and their new index. :return: The fracture mesh. """ - logging.info( "Generating the meshes" ) + logger.info( "Generating the meshes" ) mesh_points: vtkPoints = old_mesh.GetPoints() is_node_duplicated = zeros( mesh_points.GetNumberOfPoints(), dtype=bool ) # defaults to False @@ -466,12 +467,12 @@ def __generate_fracture_mesh( old_mesh: vtkUnstructuredGrid, fracture_info: Frac # for dfns in discarded_face_nodes: # tmp.append(", ".join(map(str, dfns))) msg: str = "(" + '), ('.join( map( lambda dfns: ", ".join( map( str, dfns ) ), discarded_face_nodes ) ) + ")" - # logging.info(f"The {len(tmp)} faces made of nodes ({'), ('.join(tmp)}) were/was discarded" + # logger.info(f"The {len(tmp)} faces made of nodes ({'), ('.join(tmp)}) were/was discarded" # + "from the fracture mesh because none of their/its nodes were duplicated.") # print(f"The {len(tmp)} faces made of nodes ({'), ('.join(tmp)}) were/was discarded" # + "from the fracture mesh because none of their/its nodes were duplicated.") - logging.info( f"The faces made of nodes [{msg}] were/was discarded" + - "from the fracture mesh because none of their/its nodes were duplicated." ) + logger.info( f"The faces made of nodes [{msg}] were/was discarded" + + "from the fracture mesh because none of their/its nodes were duplicated." ) fracture_nodes_tmp = ones( mesh_points.GetNumberOfPoints(), dtype=int ) * -1 for ns in face_nodes: @@ -546,7 +547,7 @@ def __split_mesh_on_fractures( mesh: vtkUnstructuredGrid, return ( output_mesh, fracture_meshes ) -def __check( mesh, options: Options ) -> Result: +def __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 ): @@ -555,16 +556,16 @@ def __check( mesh, options: Options ) -> Result: return Result( info="OK" ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: try: mesh = read_mesh( 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." ) - logging.error( err_msg ) + logger.error( err_msg ) raise ValueError( err_msg ) - return __check( mesh, options ) + return __action( mesh, options ) except BaseException as e: - logging.error( e ) + logger.error( e ) return Result( info="Something went wrong" ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/generate_global_ids.py b/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py similarity index 82% rename from geos-mesh/src/geos/mesh/doctor/checks/generate_global_ids.py rename to geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py index 2fdcfe27..97b88339 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/generate_global_ids.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/generate_global_ids.py @@ -1,7 +1,9 @@ from dataclasses import dataclass -import logging from vtkmodules.vtkCommonCore import vtkIdTypeArray from geos.mesh.io.vtkIO import VtkOutput, read_mesh, write_mesh +from geos.utils.Logger import getLogger + +logger = getLogger( "generate_global_ids" ) @dataclass( frozen=True ) @@ -25,7 +27,7 @@ def __build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_g # Building GLOBAL_IDS for points and cells.g GLOBAL_IDS for points and cells. # First for points... if mesh.GetPointData().GetGlobalIds(): - logging.error( "Mesh already has globals ids for points; nothing done." ) + logger.error( "Mesh already has globals ids for points; nothing done." ) elif generate_points_global_ids: point_global_ids = vtkIdTypeArray() point_global_ids.SetName( "GLOBAL_IDS_POINTS" ) @@ -35,7 +37,7 @@ def __build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_g mesh.GetPointData().SetGlobalIds( point_global_ids ) # ... then for cells. if mesh.GetCellData().GetGlobalIds(): - logging.error( "Mesh already has globals ids for cells; nothing done." ) + logger.error( "Mesh already has globals ids for cells; nothing done." ) elif generate_cells_global_ids: cells_global_ids = vtkIdTypeArray() cells_global_ids.SetName( "GLOBAL_IDS_CELLS" ) @@ -45,16 +47,16 @@ def __build_global_ids( mesh, generate_cells_global_ids: bool, generate_points_g mesh.GetCellData().SetGlobalIds( cells_global_ids ) -def __check( mesh, options: Options ) -> Result: +def __action( mesh, 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 check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: try: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __action( mesh, options ) except BaseException as e: - logging.error( e ) + logger.error( e ) return Result( info="Something went wrong." ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/non_conformal.py b/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py similarity index 98% rename from geos-mesh/src/geos/mesh/doctor/checks/non_conformal.py rename to geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py index e4037dac..d1c83a37 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/non_conformal.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/non_conformal.py @@ -12,8 +12,7 @@ from vtkmodules.vtkFiltersCore import vtkPolyDataNormals from vtkmodules.vtkFiltersGeometry import vtkDataSetSurfaceFilter from vtkmodules.vtkFiltersModeling import vtkCollisionDetectionFilter, vtkLinearExtrusionFilter -from geos.mesh.doctor.checks import reorient_mesh -from geos.mesh.doctor.checks import triangle_distance +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 @@ -345,7 +344,7 @@ def build_numpy_triangles( points_ids ): return are_points_conformal( point_tolerance, cp_i, cp_j ) -def __check( mesh: vtkUnstructuredGrid, options: Options ) -> Result: +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 @@ -404,6 +403,6 @@ def __check( mesh: vtkUnstructuredGrid, options: Options ) -> Result: return Result( non_conformal_cells=tmp ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __action( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/reorient_mesh.py b/geos-mesh/src/geos/mesh/doctor/actions/reorient_mesh.py similarity index 96% rename from geos-mesh/src/geos/mesh/doctor/checks/reorient_mesh.py rename to geos-mesh/src/geos/mesh/doctor/actions/reorient_mesh.py index aca4c7ee..5f32c94c 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/reorient_mesh.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/reorient_mesh.py @@ -1,4 +1,3 @@ -import logging import networkx import numpy from tqdm import tqdm @@ -7,8 +6,11 @@ from vtkmodules.vtkCommonDataModel import ( VTK_POLYHEDRON, VTK_TRIANGLE, vtkCellArray, vtkPolyData, vtkPolygon, vtkUnstructuredGrid, vtkTetra ) from vtkmodules.vtkFiltersCore import vtkTriangleFilter -from geos.mesh.doctor.checks.vtk_polyhedron import FaceStream, build_face_to_face_connectivity_through_edges +from geos.mesh.doctor.actions.vtk_polyhedron import FaceStream, build_face_to_face_connectivity_through_edges from geos.mesh.utils.genericHelpers import to_vtk_id_list +from geos.utils.Logger import getLogger + +logger = getLogger( "reorient_mesh" ) def __compute_volume( mesh_points: vtkPoints, face_stream: FaceStream ) -> float: @@ -129,7 +131,7 @@ def reorient_mesh( mesh, cell_indices: Iterator[ int ] ) -> vtkUnstructuredGrid: # I did not manage to call `output_mesh.CopyStructure(mesh)` because I could not modify the polyhedron in place. # Therefore, I insert the cells one by one... output_mesh.SetPoints( mesh.GetPoints() ) - logging.info( "Reorienting the polyhedron cells to enforce normals directed outward." ) + logger.info( "Reorienting the polyhedron cells to enforce normals directed outward." ) with tqdm( total=needs_to_be_reoriented.sum(), desc="Reorienting polyhedra" ) as progress_bar: # For smoother progress, we only update on reoriented elements. for ic in range( num_cells ): diff --git a/geos-mesh/src/geos/mesh/doctor/checks/self_intersecting_elements.py b/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py similarity index 93% rename from geos-mesh/src/geos/mesh/doctor/checks/self_intersecting_elements.py rename to geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py index 0cad78b4..3b7d313a 100644 --- a/geos-mesh/src/geos/mesh/doctor/checks/self_intersecting_elements.py +++ b/geos-mesh/src/geos/mesh/doctor/actions/self_intersecting_elements.py @@ -8,7 +8,7 @@ @dataclass( frozen=True ) class Options: - tolerance: float + min_distance: float @dataclass( frozen=True ) @@ -21,7 +21,7 @@ class Result: faces_are_oriented_incorrectly_elements: Collection[ int ] -def __check( mesh, options: Options ) -> Result: +def __action( mesh, options: Options ) -> Result: err_out = vtkFileOutputWindow() err_out.SetFileName( "/dev/null" ) # vtkCellValidator outputs loads for each cell... vtk_std_err_out = vtkOutputWindow() @@ -43,7 +43,7 @@ def __check( mesh, options: Options ) -> Result: faces_are_oriented_incorrectly_elements: List[ int ] = [] f = vtkCellValidator() - f.SetTolerance( options.tolerance ) + f.SetTolerance( options.min_distance ) f.SetInputData( mesh ) f.Update() @@ -74,6 +74,6 @@ def __check( mesh, options: Options ) -> Result: faces_are_oriented_incorrectly_elements=faces_are_oriented_incorrectly_elements ) -def check( vtk_input_file: str, options: Options ) -> Result: +def action( vtk_input_file: str, options: Options ) -> Result: mesh = read_mesh( vtk_input_file ) - return __check( mesh, options ) + return __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 new file mode 100644 index 00000000..0fa8fc63 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/actions/supported_elements.py @@ -0,0 +1,179 @@ +from dataclasses import dataclass +import multiprocessing +import networkx +from tqdm import tqdm +from typing import FrozenSet, 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 ) +from geos.mesh.doctor.actions.vtk_polyhedron import build_face_to_face_connectivity_through_edges, FaceStream +from geos.mesh.utils.genericHelpers import vtk_iter +from geos.mesh.io.vtkIO import read_mesh +from geos.utils.Logger import getLogger + +logger = getLogger( "supported_elements" ) + + +@dataclass( frozen=True ) +class Options: + nproc: int + chunk_size: int + + +@dataclass( frozen=True ) +class Result: + unsupported_std_elements_types: FrozenSet[ int ] # 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. +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 + """ + global MESH + 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: + 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. + + +class IsPolyhedronConvertible: + + def __init__( self ): + + 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. + + Returns: + networkx.Graph: A graph instance. + """ + tmp = networkx.cycle_graph( n ) + for node in range( n ): + tmp.add_edge( node, n ) + tmp.add_edge( node, n + 1 ) + tmp.name = name + return tmp + + # Building the reference graphs + tet_graph = networkx.complete_graph( 4 ) + tet_graph.name = "Tetrahedron" + pyr_graph = build_prism_graph( 4, "Pyramid" ) + pyr_graph.remove_node( 5 ) # Removing a node also removes its associated edges. + self.__reference_graphs: Mapping[ int, Iterable[ networkx.Graph ] ] = { + 4: ( tet_graph, ), + 5: ( pyr_graph, build_prism_graph( 3, "Wedge" ) ), + 6: ( build_prism_graph( 4, "Hexahedron" ), ), + 7: ( build_prism_graph( 5, "Prism5" ), ), + 8: ( build_prism_graph( 6, "Prism6" ), ), + 9: ( build_prism_graph( 7, "Prism7" ), ), + 10: ( build_prism_graph( 8, "Prism8" ), ), + 11: ( build_prism_graph( 9, "Prism9" ), ), + 12: ( build_prism_graph( 10, "Prism10" ), ), + 13: ( build_prism_graph( 11, "Prism11" ), ), + } + + 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. + + 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. + + Args: + ic (int): The index element. + + Returns: + 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." + if MESH.GetCellType( ic ) != VTK_POLYHEDRON: + return -1 + pt_ids = vtkIdList() + MESH.GetFaceStream( ic, pt_ids ) + face_stream = FaceStream.build_from_vtk_id_list( pt_ids ) + converted_type_name = self.__is_polyhedron_supported( face_stream ) + if converted_type_name: + logger.debug( f"Polyhedron cell {ic} can be converted into \"{converted_type_name}\"" ) + return -1 + else: + logger.debug( + f"Polyhedron cell {ic} (in PID {multiprocessing.current_process().pid}) 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: + 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() ) + + if hasattr( mesh, "GetDistinctCellTypesArray" ): + cell_types_numpy = vtk_to_numpy( mesh.GetDistinctCellTypesArray() ) + cell_types = set( cell_types_numpy.tolist() ) + else: + vtk_cell_types_obj = vtkCellTypes() + mesh.GetCellTypes( vtk_cell_types_obj ) + cell_types = set( vtk_iter( vtk_cell_types_obj ) ) + + 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 + + # Dealing with polyhedron elements. + num_cells = mesh.GetNumberOfCells() + polyhedron_converter = IsPolyhedronConvertible() + + 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 action( vtk_input_file: str, options: Options ) -> Result: + return __action( vtk_input_file, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/checks/triangle_distance.py b/geos-mesh/src/geos/mesh/doctor/actions/triangle_distance.py similarity index 100% rename from geos-mesh/src/geos/mesh/doctor/checks/triangle_distance.py rename to geos-mesh/src/geos/mesh/doctor/actions/triangle_distance.py diff --git a/geos-mesh/src/geos/mesh/doctor/checks/vtk_polyhedron.py b/geos-mesh/src/geos/mesh/doctor/actions/vtk_polyhedron.py similarity index 100% rename from geos-mesh/src/geos/mesh/doctor/checks/vtk_polyhedron.py rename to geos-mesh/src/geos/mesh/doctor/actions/vtk_polyhedron.py diff --git a/geos-mesh/src/geos/mesh/doctor/checks/supported_elements.py b/geos-mesh/src/geos/mesh/doctor/checks/supported_elements.py deleted file mode 100644 index 2a1c8061..00000000 --- a/geos-mesh/src/geos/mesh/doctor/checks/supported_elements.py +++ /dev/null @@ -1,137 +0,0 @@ -from dataclasses import dataclass -import logging -import multiprocessing -import networkx -import numpy -from tqdm import tqdm -from typing import FrozenSet, 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 ) -from geos.mesh.doctor.checks.vtk_polyhedron import build_face_to_face_connectivity_through_edges, FaceStream -from geos.mesh.utils.genericHelpers import vtk_iter -from geos.mesh.io.vtkIO import read_mesh - - -@dataclass( frozen=True ) -class Options: - num_proc: int - chunk_size: int - - -@dataclass( frozen=True ) -class Result: - unsupported_std_elements_types: FrozenSet[ int ] # 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. -MESH: Optional[ vtkUnstructuredGrid ] = None - - -class IsPolyhedronConvertible: - - def __init__( self, mesh: vtkUnstructuredGrid ): - global MESH # for multiprocessing, vtkUnstructuredGrid cannot be pickled. Let's use a global variable instead. - MESH = mesh - - def build_prism_graph( n: int, name: str ) -> networkx.Graph: - """ - Builds the face to face connectivities (through edges) for prism graphs. - :param n: The number of nodes of the basis (i.e. the pentagonal prims gets n = 5) - :param name: A human-readable name for logging purpose. - :return: A graph instance. - """ - tmp = networkx.cycle_graph( n ) - for node in range( n ): - tmp.add_edge( node, n ) - tmp.add_edge( node, n + 1 ) - tmp.name = name - return tmp - - # Building the reference graphs - tet_graph = networkx.complete_graph( 4 ) - tet_graph.name = "Tetrahedron" - pyr_graph = build_prism_graph( 4, "Pyramid" ) - pyr_graph.remove_node( 5 ) # Removing a node also removes its associated edges. - self.__reference_graphs: Mapping[ int, Iterable[ networkx.Graph ] ] = { - 4: ( tet_graph, ), - 5: ( pyr_graph, build_prism_graph( 3, "Wedge" ) ), - 6: ( build_prism_graph( 4, "Hexahedron" ), ), - 7: ( build_prism_graph( 5, "Prism5" ), ), - 8: ( build_prism_graph( 6, "Prism6" ), ), - 9: ( build_prism_graph( 7, "Prism7" ), ), - 10: ( build_prism_graph( 8, "Prism8" ), ), - 11: ( build_prism_graph( 9, "Prism9" ), ), - 12: ( build_prism_graph( 10, "Prism10" ), ), - 13: ( build_prism_graph( 11, "Prism11" ), ), - } - - 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. - :param face_stream: The polyhedron. - :return: The name of the supported type or an empty string. - """ - cell_graph = build_face_to_face_connectivity_through_edges( face_stream, add_compatibility=True ) - 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. - :param ic: The index element. - :return: -1 if the polyhedron vtk element can be converted into a supported element type. The index otherwise. - """ - global MESH - assert MESH is not None - if MESH.GetCellType( ic ) != VTK_POLYHEDRON: - return -1 - pt_ids = vtkIdList() - MESH.GetFaceStream( ic, pt_ids ) - face_stream = FaceStream.build_from_vtk_id_list( pt_ids ) - converted_type_name = self.__is_polyhedron_supported( face_stream ) - if converted_type_name: - logging.debug( f"Polyhedron cell {ic} can be converted into \"{converted_type_name}\"" ) - return -1 - else: - logging.debug( f"Polyhedron cell {ic} cannot be converted into any supported element." ) - return ic - - -def __check( mesh: vtkUnstructuredGrid, options: Options ) -> Result: - if hasattr( mesh, "GetDistinctCellTypesArray" ): # For more recent versions of vtk. - cell_types = set( vtk_to_numpy( mesh.GetDistinctCellTypesArray() ) ) - else: - cell_types = vtkCellTypes() - mesh.GetCellTypes( cell_types ) - cell_types = set( vtk_iter( cell_types ) ) - 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 - - # Dealing with polyhedron elements. - num_cells = mesh.GetNumberOfCells() - result = numpy.ones( num_cells, dtype=int ) * -1 - with multiprocessing.Pool( processes=options.num_proc ) as pool: - generator = pool.imap_unordered( IsPolyhedronConvertible( mesh ), - 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 - unsupported_polyhedron_elements = [ i for i in result if i > -1 ] - return Result( unsupported_std_elements_types=frozenset( unsupported_std_elements_types ), - unsupported_polyhedron_elements=frozenset( unsupported_polyhedron_elements ) ) - - -def check( vtk_input_file: str, options: Options ) -> Result: - mesh: vtkUnstructuredGrid = read_mesh( vtk_input_file ) - return __check( mesh, options ) diff --git a/geos-mesh/src/geos/mesh/doctor/mesh_doctor.py b/geos-mesh/src/geos/mesh/doctor/mesh_doctor.py index ea1bfe8a..ec4218a9 100644 --- a/geos-mesh/src/geos/mesh/doctor/mesh_doctor.py +++ b/geos-mesh/src/geos/mesh/doctor/mesh_doctor.py @@ -1,34 +1,31 @@ import sys +min_python_version = ( 3, 7 ) try: - min_python_version = ( 3, 7 ) assert sys.version_info >= min_python_version -except AssertionError as e: +except AssertionError: print( f"Please update python to at least version {'.'.join(map(str, min_python_version))}." ) sys.exit( 1 ) -import logging - -from geos.mesh.doctor.parsing import CheckHelper -from geos.mesh.doctor.parsing.cli_parsing import parse_and_set_verbosity -import geos.mesh.doctor.register as register +from geos.mesh.doctor.parsing import ActionHelper +from geos.mesh.doctor.parsing.cli_parsing import parse_and_set_verbosity, setup_logger +from geos.mesh.doctor.register import register_parsing_actions def main(): - logging.basicConfig( format='[%(asctime)s][%(levelname)s] %(message)s' ) parse_and_set_verbosity( sys.argv ) - main_parser, all_checks, all_checks_helpers = register.register() + main_parser, all_actions, all_actions_helpers = register_parsing_actions() args = main_parser.parse_args( sys.argv[ 1: ] ) - logging.info( f"Checking mesh \"{args.vtk_input_file}\"." ) - check_options = all_checks_helpers[ args.subparsers ].convert( vars( args ) ) + setup_logger.info( f"Working on mesh \"{args.vtk_input_file}\"." ) + action_options = all_actions_helpers[ args.subparsers ].convert( vars( args ) ) try: - check = all_checks[ args.subparsers ] - except KeyError as e: - logging.critical( f"Check {args.subparsers} is not a valid check." ) + action = all_actions[ args.subparsers ] + except KeyError: + setup_logger.critical( f"Action {args.subparsers} is not a valid action." ) sys.exit( 1 ) - helper: CheckHelper = all_checks_helpers[ args.subparsers ] - result = check( args.vtk_input_file, check_options ) - helper.display_results( check_options, result ) + helper: ActionHelper = all_actions_helpers[ args.subparsers ] + result = action( args.vtk_input_file, action_options ) + helper.display_results( action_options, result ) if __name__ == '__main__': diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/__init__.py b/geos-mesh/src/geos/mesh/doctor/parsing/__init__.py index 679f880e..c37fa92b 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/__init__.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Callable, Any +ALL_CHECKS = "all_checks" COLLOCATES_NODES = "collocated_nodes" ELEMENT_VOLUMES = "element_volumes" FIX_ELEMENTS_ORDERINGS = "fix_elements_orderings" @@ -14,7 +15,7 @@ @dataclass( frozen=True ) -class CheckHelper: +class ActionHelper: fill_subparser: Callable[ [ Any ], argparse.ArgumentParser ] convert: Callable[ [ Any ], Any ] display_results: Callable[ [ Any, Any ], None ] diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/all_checks_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/all_checks_parsing.py new file mode 100644 index 00000000..57927080 --- /dev/null +++ b/geos-mesh/src/geos/mesh/doctor/parsing/all_checks_parsing.py @@ -0,0 +1,212 @@ +import argparse +from copy import deepcopy +from dataclasses import dataclass +from typing import Type +from geos.mesh.doctor.actions.all_checks import Options as AllChecksOptions +from geos.mesh.doctor.actions.all_checks import Result as AllChecksResult +# Import constants for check names +from geos.mesh.doctor.parsing import ( + ALL_CHECKS, # Name for the subparser + COLLOCATES_NODES, + ELEMENT_VOLUMES, + NON_CONFORMAL, + SELF_INTERSECTING_ELEMENTS, + SUPPORTED_ELEMENTS, +) +# Import module-specific Options, Result, and defaults +# Using module aliases for clarity +from geos.mesh.doctor.parsing import collocated_nodes_parsing as cn_parser +from geos.mesh.doctor.parsing import element_volumes_parsing as ev_parser +from geos.mesh.doctor.parsing import non_conformal_parsing as nc_parser +from geos.mesh.doctor.parsing import self_intersecting_elements_parsing as sie_parser +from geos.mesh.doctor.parsing import supported_elements_parsing as se_parser +from geos.mesh.doctor.parsing.cli_parsing import parse_comma_separated_string +from geos.utils.Logger import getLogger + +logger = getLogger( "All_checks_parsing" ) + +# --- Centralized Configuration for Check Features --- +# This structure makes it easier to manage checks and their properties. + + +@dataclass( frozen=True ) # Consider using dataclass if appropriate, or a simple dict +class CheckFeature: + name: str + options_cls: Type[ any ] # Specific Options class (e.g., cn_parser.Options) + result_cls: Type[ any ] # Specific Result class (e.g., cn_parser.Result) + default_params: dict[ str, any ] # Parser keywords with default values + display: Type[ any ] # Specific display function for results + + +# Deepcopy to prevent accidental modification of originals default parameters +CHECK_FEATURES_CONFIG = { + COLLOCATES_NODES: + CheckFeature( name=COLLOCATES_NODES, + options_cls=cn_parser.Options, + result_cls=cn_parser.Result, + default_params=deepcopy( cn_parser.__COLLOCATED_NODES_DEFAULT ), + display=cn_parser.display_results ), + ELEMENT_VOLUMES: + CheckFeature( name=ELEMENT_VOLUMES, + options_cls=ev_parser.Options, + result_cls=ev_parser.Result, + default_params=deepcopy( ev_parser.__ELEMENT_VOLUMES_DEFAULT ), + display=ev_parser.display_results ), + NON_CONFORMAL: + CheckFeature( name=NON_CONFORMAL, + options_cls=nc_parser.Options, + result_cls=nc_parser.Result, + default_params=deepcopy( nc_parser.__NON_CONFORMAL_DEFAULT ), + display=nc_parser.display_results ), + SELF_INTERSECTING_ELEMENTS: + CheckFeature( name=SELF_INTERSECTING_ELEMENTS, + options_cls=sie_parser.Options, + result_cls=sie_parser.Result, + default_params=deepcopy( sie_parser.__SELF_INTERSECTING_ELEMENTS_DEFAULT ), + display=sie_parser.display_results ), + SUPPORTED_ELEMENTS: + CheckFeature( name=SUPPORTED_ELEMENTS, + options_cls=se_parser.Options, + result_cls=se_parser.Result, + default_params=deepcopy( se_parser.__SUPPORTED_ELEMENTS_DEFAULT ), + display=se_parser.display_results ), +} + +# Ordered list of check names, defining the default order and for consistent help messages +ORDERED_CHECK_NAMES: list[ str ] = [ + COLLOCATES_NODES, + ELEMENT_VOLUMES, + NON_CONFORMAL, + SELF_INTERSECTING_ELEMENTS, + SUPPORTED_ELEMENTS, +] +DEFAULT_PARAMS: dict[ str, dict[ str, float ] ] = { + name: feature.default_params.copy() + for name, feature in CHECK_FEATURES_CONFIG.items() +} + +# --- Argument Parser Constants --- +CHECKS_TO_DO_ARG = "checks_to_perform" +PARAMETERS_ARG = "set_parameters" + +# Generate help text for set_parameters dynamically +PARAMETERS_ARG_HELP: str = "" +for check_name in ORDERED_CHECK_NAMES: + config = CHECK_FEATURES_CONFIG[ check_name ] + if config.default_params: + config_params: list[ str ] = list() + for name, value in config.default_params.items(): + config_params.append( f"{name}:{value}" ) + PARAMETERS_ARG_HELP += f"For {check_name}: {', '.join( config_params )}. " + + +# --- Argument Parser Setup --- +def fill_subparser( subparsers: argparse._SubParsersAction ) -> None: + """Fills the subparser for 'ALL_CHECKS' with its arguments.""" + parser = subparsers.add_parser( + ALL_CHECKS, + help="Perform one or multiple mesh-doctor check operations in one command line on the same mesh.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter # Shows defaults in help + ) + parser.add_argument( f"--{CHECKS_TO_DO_ARG}", + type=str, + default="", + required=False, + help=( "Comma-separated list of mesh-doctor checks to perform. If no input was given, all of" + f" the following checks will be executed by default: {ORDERED_CHECK_NAMES}. If you want" + " to choose only certain of them, you can name them individually." + f" Example: --{CHECKS_TO_DO_ARG} {ORDERED_CHECK_NAMES[0]},{ORDERED_CHECK_NAMES[1]}" ) ) + parser.add_argument( + f"--{PARAMETERS_ARG}", + type=str, + default="", + required=False, + help=( "Comma-separated list of parameters to set for the checks (e.g., 'param_name:value'). " + "These parameters override the defaults. Default parameters are:" + f" {PARAMETERS_ARG_HELP} Example: --{PARAMETERS_ARG} parameter_name:10.5,other_param:25" ) ) + + +def convert( parsed_args: argparse.Namespace ) -> AllChecksOptions: + """ + Converts parsed command-line arguments into an AllChecksOptions object. + """ + # 1. Determine which checks to perform + final_selected_check_names: list[ str ] = deepcopy( ORDERED_CHECK_NAMES ) + if not parsed_args[ CHECKS_TO_DO_ARG ]: # handles default and if user explicitly provides --checks_to_perform "" + logger.info( "All current available checks in mesh-doctor will be performed." ) + else: # the user specifically entered check names to perform + checks_to_do: list[ str ] = parse_comma_separated_string( parsed_args[ CHECKS_TO_DO_ARG ] ) + final_selected_check_names = list() + for name in checks_to_do: + if name not in CHECK_FEATURES_CONFIG: + logger.warning( f"The given check '{name}' does not exist. Cannot perform this check." + f" Choose from: {ORDERED_CHECK_NAMES}." ) + elif name not in final_selected_check_names: # Add if valid and not already added + final_selected_check_names.append( name ) + + # If after parsing, no valid checks are selected (e.g., all inputs were invalid) + if not final_selected_check_names: + logger.error( "No valid checks selected based on input. No operations will be configured." ) + raise ValueError( "No valid checks selected based on input. No operations will be configured." ) + + # 2. Prepare parameters of Options for every check feature that will be used + final_selected_check_params: dict[ str, dict[ str, float ] ] = deepcopy( DEFAULT_PARAMS ) + for name in list( final_selected_check_params.keys() ): + if name not in final_selected_check_names: + del final_selected_check_params[ name ] # Remove non-used check features + + if not parsed_args[ PARAMETERS_ARG ]: # handles default and if user explicitly provides --set_parameters "" + logger.info( "Default configuation of parameters adopted for every check to perform." ) + else: + set_parameters = parse_comma_separated_string( parsed_args[ PARAMETERS_ARG ] ) + for param in set_parameters: + if ':' not in param: + logger.warning( f"Parameter '{param}' in --{PARAMETERS_ARG} is not in 'name:value' format. Skipping." ) + continue + name, *value = param.split( ':', 1 ) + name = name.strip() + if value: # Check if there is anything after the first colon + value_str = value[ 0 ].strip() + else: + # Handle cases where there's nothing after the colon, if necessary + logger.warning( f"Parameter '{name}' has no value after the colon. Skipping or using default." ) + continue + try: + value_float = float( value_str ) + except ValueError: + logger.warning( + f"Invalid value for parameter '{name}': '{value_str}'. Must be a number. Skipping this override." ) + continue + + for check_name_key in final_selected_check_params.keys(): # Iterate through all possible checks + if name in final_selected_check_params[ check_name_key ]: + final_selected_check_params[ check_name_key ][ name ] = value_float + break + + # 3. Instantiate the Options objects for the selected checks using their effective parameters + individual_check_options: dict[ str, any ] = dict() + individual_check_display: dict[ str, any ] = dict() + for check_name in list( final_selected_check_params.keys() ): + options_constructor_params = final_selected_check_params[ check_name ] + feature_config = CHECK_FEATURES_CONFIG[ check_name ] + try: + individual_check_options[ check_name ] = feature_config.options_cls( **options_constructor_params ) + individual_check_display[ check_name ] = feature_config.display + except Exception as e: # Catch potential errors during options instantiation + logger.error( + f"Failed to create options for check '{check_name}' with params {options_constructor_params}: {e}." + f" Therefore the check '{check_name}' will not be performed." ) + final_selected_check_names.remove( check_name ) + + return AllChecksOptions( checks_to_perform=final_selected_check_names, + checks_options=individual_check_options, + check_displays=individual_check_display ) + + +# --- Display Results --- +def display_results( options: AllChecksOptions, result: AllChecksResult ) -> None: + """Displays the results of the checks.""" + # Implementation for displaying results based on the structured options and results. + logger.info( f"Displaying results for checks: {options.checks_to_perform}" ) + for name, res in result.check_results.items(): + options.check_displays[ name ]( options.checks_options[ name ], res ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/cli_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/cli_parsing.py index a34010ba..0f3d697a 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/cli_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/cli_parsing.py @@ -2,6 +2,7 @@ import logging import textwrap from typing import List +from geos.utils.Logger import getLogger as get_custom_logger # Alias for clarity __VERBOSE_KEY = "verbose" __QUIET_KEY = "quiet" @@ -9,34 +10,68 @@ __VERBOSITY_FLAG = "v" __QUIET_FLAG = "q" +# Get a logger for this setup module itself, using your custom logger +# This ensures its messages (like the "Logger level set to...") use your custom format. +setup_logger = get_custom_logger( "mesh-doctor" ) + + +# --- Conversion Logic --- +def parse_comma_separated_string( value: str ) -> list[ str ]: + """Helper to parse comma-separated strings, stripping whitespace and removing empty items.""" + if not value or not value.strip(): + return list() + return [ item.strip() for item in value.split( ',' ) if item.strip() ] + def parse_and_set_verbosity( cli_args: List[ str ] ) -> None: """ - Parse the verbosity flag only. And sets the logger's level accordingly. - :param cli_args: The list of arguments (as strings) + Parse the verbosity flag only and set the root logger's level accordingly. + Messages from loggers created with `get_custom_logger` will inherit this level + if their own level is set to NOTSET. + :param cli_args: The list of command-line arguments (e.g., sys.argv) :return: None """ dummy_verbosity_parser = argparse.ArgumentParser( add_help=False ) - dummy_verbosity_parser.add_argument( '-' + __VERBOSITY_FLAG, - '--' + __VERBOSE_KEY, - action='count', - default=2, - dest=__VERBOSE_KEY ) + # Add verbosity arguments to this dummy parser + dummy_verbosity_parser.add_argument( + '-' + __VERBOSITY_FLAG, + '--' + __VERBOSE_KEY, + action='count', + default=0, # Base default, actual interpretation depends on help text mapping + dest=__VERBOSE_KEY ) dummy_verbosity_parser.add_argument( '-' + __QUIET_FLAG, '--' + __QUIET_KEY, action='count', default=0, dest=__QUIET_KEY ) - args = dummy_verbosity_parser.parse_known_args( cli_args[ 1: ] )[ 0 ] - d = vars( args ) - v = d[ __VERBOSE_KEY ] - d[ __QUIET_KEY ] - verbosity = logging.CRITICAL - ( 10 * v ) - if verbosity < logging.DEBUG: - verbosity = logging.DEBUG - elif verbosity > logging.CRITICAL: - verbosity = logging.CRITICAL - logging.getLogger().setLevel( verbosity ) - logging.info( f"Logger level set to \"{logging.getLevelName(verbosity)}\"" ) + + # Parse only known args to extract verbosity/quiet flags + # cli_args[1:] is used assuming cli_args[0] is the script name (like sys.argv) + args, _ = dummy_verbosity_parser.parse_known_args( cli_args[ 1: ] ) + + verbose_count = args.verbose + quiet_count = args.quiet + + if verbose_count == 0 and quiet_count == 0: + # Default level (no -v or -q flags) + effective_level = logging.WARNING + elif verbose_count == 1: + effective_level = logging.INFO + elif verbose_count >= 2: + effective_level = logging.DEBUG + elif quiet_count == 1: + effective_level = logging.ERROR + elif quiet_count >= 2: + effective_level = logging.CRITICAL + else: # Should not happen with count logic but good to have a fallback + effective_level = logging.WARNING + + # Set the level on the ROOT logger. + # Loggers from get_custom_logger (with level NOTSET) will inherit this. + setup_logger.setLevel( effective_level ) + + # Use the setup_logger (which uses your custom formatter) for this message + setup_logger.info( f"Logger level set to \"{logging.getLevelName( effective_level )}\"" ) def init_parser() -> argparse.ArgumentParser: @@ -48,7 +83,7 @@ def init_parser() -> argparse.ArgumentParser: Increase verbosity (-{__VERBOSITY_FLAG}, -{__VERBOSITY_FLAG * 2}) to get full information. """ formatter = lambda prog: argparse.RawTextHelpFormatter( prog, max_help_position=8 ) - parser = argparse.ArgumentParser( description='Inspects meshes for GEOSX.', + parser = argparse.ArgumentParser( description='Inspects meshes for GEOS.', epilog=textwrap.dedent( epilog_msg ), formatter_class=formatter ) # Nothing will be done with this verbosity/quiet input. 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 7fb84420..17ddc6a2 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 @@ -1,15 +1,13 @@ -import logging +from geos.mesh.doctor.actions.collocated_nodes import Options, Result +from geos.mesh.doctor.parsing import COLLOCATES_NODES +from geos.utils.Logger import getLogger -from typing import ( - FrozenSet, - List, -) - -from geos.mesh.doctor.checks.collocated_nodes import Options, Result - -from . import COLLOCATES_NODES +logger = getLogger( "Collocated_nodes parsing" ) __TOLERANCE = "tolerance" +__TOLERANCE_DEFAULT = 0. + +__COLLOCATED_NODES_DEFAULT = { __TOLERANCE: __TOLERANCE_DEFAULT } def convert( parsed_options ) -> Options: @@ -20,30 +18,31 @@ def fill_subparser( subparsers ) -> None: p = subparsers.add_parser( COLLOCATES_NODES, help="Checks if nodes are collocated." ) p.add_argument( '--' + __TOLERANCE, type=float, + metavar=__TOLERANCE_DEFAULT, + default=__TOLERANCE_DEFAULT, required=True, help="[float]: The absolute distance between two nodes for them to be considered collocated." ) def display_results( options: Options, result: Result ): - all_collocated_nodes: List[ int ] = [] + all_collocated_nodes: list[ int ] = [] for bucket in result.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: frozenset[ int ] = frozenset( all_collocated_nodes ) # Surely useless if all_collocated_nodes: - logging.error( f"You have {len(all_collocated_nodes)} collocated nodes (tolerance = {options.tolerance})." ) + logger.error( f"You have {len(all_collocated_nodes)} collocated nodes (tolerance = {options.tolerance})." ) - logging.info( "Here are all the buckets of collocated nodes." ) - tmp: List[ str ] = [] + logger.info( "Here are all the buckets of collocated nodes." ) + tmp: list[ str ] = [] for bucket in result.nodes_buckets: tmp.append( f"({', '.join(map(str, bucket))})" ) - logging.info( f"({', '.join(tmp)})" ) + logger.info( f"({', '.join(tmp)})" ) else: - logging.error( f"You have no collocated node (tolerance = {options.tolerance})." ) + logger.error( f"You have no collocated node (tolerance = {options.tolerance})." ) if result.wrong_support_elements: tmp: str = ", ".join( map( str, result.wrong_support_elements ) ) - logging.error( f"You have {len(result.wrong_support_elements)} elements with duplicated support nodes.\n" + - tmp ) + logger.error( f"You have {len(result.wrong_support_elements)} elements with duplicated support nodes.\n" + tmp ) else: - logging.error( "You have no element with duplicated support nodes." ) + logger.error( "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 162b9d3c..2292226b 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 @@ -1,12 +1,14 @@ -import logging +from geos.mesh.doctor.actions.element_volumes import Options, Result +from geos.mesh.doctor.parsing import ELEMENT_VOLUMES +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.element_volumes import Options, Result +logger = getLogger( "element_volumes parsing" ) -from . import ELEMENT_VOLUMES - -__MIN_VOLUME = "min" +__MIN_VOLUME = "min_volume" __MIN_VOLUME_DEFAULT = 0. +__ELEMENT_VOLUMES_DEFAULT = { __MIN_VOLUME: __MIN_VOLUME_DEFAULT } + def fill_subparser( subparsers ) -> None: p = subparsers.add_parser( ELEMENT_VOLUMES, @@ -29,7 +31,6 @@ def convert( parsed_options ) -> Options: def display_results( options: Options, result: Result ): - logging.error( f"You have {len(result.element_volumes)} elements with volumes smaller than {options.min_volume}." ) + logger.error( f"You have {len(result.element_volumes)} elements with volumes smaller than {options.min_volume}." ) if result.element_volumes: - logging.error( "The elements indices and their volumes are:\n" + - "\n".join( map( str, result.element_volumes ) ) ) + logger.error( "The elements indices and their volumes are:\n\n".join( map( str, result.element_volumes ) ) ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/fix_elements_orderings_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/fix_elements_orderings_parsing.py index 71fb3a51..8bfa5fed 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/fix_elements_orderings_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/fix_elements_orderings_parsing.py @@ -1,6 +1,4 @@ -import logging import random - from vtkmodules.vtkCommonDataModel import ( VTK_HEXAGONAL_PRISM, VTK_HEXAHEDRON, @@ -10,10 +8,11 @@ VTK_VOXEL, VTK_WEDGE, ) +from geos.mesh.doctor.actions.fix_elements_orderings import Options, Result +from geos.mesh.doctor.parsing import vtk_output_parsing, FIX_ELEMENTS_ORDERINGS +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.fix_elements_orderings import Options, Result - -from . import vtk_output_parsing, FIX_ELEMENTS_ORDERINGS +logger = getLogger( "fix_elements_orderings parsing" ) __CELL_TYPE_MAPPING = { "Hexahedron": VTK_HEXAHEDRON, @@ -63,7 +62,7 @@ def convert( parsed_options ) -> Options: tmp = tuple( map( int, raw_mapping.split( "," ) ) ) if not set( tmp ) == set( range( __CELL_TYPE_SUPPORT_SIZE[ vtk_key ] ) ): err_msg = f"Permutation {raw_mapping} for type {key} is not valid." - logging.error( err_msg ) + logger.error( err_msg ) raise ValueError( err_msg ) cell_type_to_ordering[ vtk_key ] = tmp vtk_output = vtk_output_parsing.convert( parsed_options ) @@ -72,10 +71,10 @@ def convert( parsed_options ) -> Options: def display_results( options: Options, result: Result ): if result.output: - logging.info( f"New mesh was written to file '{result.output}'" ) + logger.info( f"New mesh was written to file '{result.output}'" ) if result.unchanged_cell_types: - logging.info( f"Those vtk types were not reordered: [{', '.join(map(str, result.unchanged_cell_types))}]." ) + logger.info( f"Those vtk types were not reordered: [{', '.join(map(str, result.unchanged_cell_types))}]." ) else: - logging.info( "All the cells of the mesh were reordered." ) + logger.info( "All the cells of the mesh were reordered." ) else: - logging.info( "No output file was written." ) + logger.info( "No output file was written." ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/generate_cube_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/generate_cube_parsing.py index 650adf04..b83b1f39 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/generate_cube_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/generate_cube_parsing.py @@ -1,9 +1,9 @@ -import logging +from geos.mesh.doctor.actions.generate_cube import Options, Result, FieldInfo +from geos.mesh.doctor.parsing import vtk_output_parsing, generate_global_ids_parsing, GENERATE_CUBE +from geos.mesh.doctor.parsing.generate_global_ids_parsing import GlobalIdsInfo +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.generate_cube import Options, Result, FieldInfo - -from . import vtk_output_parsing, generate_global_ids_parsing, GENERATE_CUBE -from .generate_global_ids_parsing import GlobalIdsInfo +logger = getLogger( "generate_cube parsing" ) __X, __Y, __Z, __NX, __NY, __NZ = "x", "y", "z", "nx", "ny", "nz" __FIELDS = "fields" @@ -84,4 +84,4 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): - logging.info( result.info ) + logger.info( result.info ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/generate_fractures_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/generate_fractures_parsing.py index 18206a4e..85dcb5d4 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/generate_fractures_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/generate_fractures_parsing.py @@ -1,5 +1,5 @@ import os -from geos.mesh.doctor.checks.generate_fractures import Options, Result, FracturePolicy +from geos.mesh.doctor.actions.generate_fractures import Options, Result, FracturePolicy from geos.mesh.doctor.parsing import vtk_output_parsing, GENERATE_FRACTURES from geos.mesh.io.vtkIO import VtkOutput diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/generate_global_ids_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/generate_global_ids_parsing.py index 43997c67..5902a403 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/generate_global_ids_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/generate_global_ids_parsing.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -import logging +from geos.mesh.doctor.actions.generate_global_ids import Options, Result +from geos.mesh.doctor.parsing import vtk_output_parsing, GENERATE_GLOBAL_IDS +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.generate_global_ids import Options, Result - -from . import vtk_output_parsing, GENERATE_GLOBAL_IDS +logger = getLogger( "generate_global_ids parsing" ) __CELLS, __POINTS = "cells", "points" @@ -51,4 +51,4 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): - logging.info( result.info ) + logger.info( result.info ) 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 d4aeb46a..bdc327a4 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 @@ -1,21 +1,22 @@ -import logging +from geos.mesh.doctor.actions.non_conformal import Options, Result +from geos.mesh.doctor.parsing import NON_CONFORMAL +from geos.utils.Logger import getLogger -from typing import ( - FrozenSet, - List, -) - -from geos.mesh.doctor.checks.non_conformal import Options, Result - -from . import NON_CONFORMAL +logger = getLogger( "non_conformal parsing" ) __ANGLE_TOLERANCE = "angle_tolerance" __POINT_TOLERANCE = "point_tolerance" __FACE_TOLERANCE = "face_tolerance" __ANGLE_TOLERANCE_DEFAULT = 10. +__POINT_TOLERANCE_DEFAULT = 0. +__FACE_TOLERANCE_DEFAULT = 0. -__ALL_KEYWORDS = { __ANGLE_TOLERANCE, __POINT_TOLERANCE, __FACE_TOLERANCE } +__NON_CONFORMAL_DEFAULT = { + __ANGLE_TOLERANCE: __ANGLE_TOLERANCE_DEFAULT, + __POINT_TOLERANCE: __POINT_TOLERANCE_DEFAULT, + __FACE_TOLERANCE: __FACE_TOLERANCE_DEFAULT +} def convert( parsed_options ) -> Options: @@ -31,19 +32,25 @@ def fill_subparser( subparsers ) -> None: metavar=__ANGLE_TOLERANCE_DEFAULT, default=__ANGLE_TOLERANCE_DEFAULT, help=f"[float]: angle tolerance in degrees. Defaults to {__ANGLE_TOLERANCE_DEFAULT}" ) - p.add_argument( '--' + __POINT_TOLERANCE, - type=float, - help=f"[float]: tolerance for two points to be considered collocated." ) - p.add_argument( '--' + __FACE_TOLERANCE, - type=float, - help=f"[float]: tolerance for two faces to be considered \"touching\"." ) + p.add_argument( + '--' + __POINT_TOLERANCE, + type=float, + metavar=__POINT_TOLERANCE_DEFAULT, + default=__POINT_TOLERANCE_DEFAULT, + help=f"[float]: tolerance for two points to be considered collocated. Defaults to {__POINT_TOLERANCE_DEFAULT}" ) + p.add_argument( + '--' + __FACE_TOLERANCE, + type=float, + metavar=__FACE_TOLERANCE_DEFAULT, + default=__FACE_TOLERANCE_DEFAULT, + help=f"[float]: tolerance for two faces to be considered \"touching\". Defaults to {__FACE_TOLERANCE_DEFAULT}" ) def display_results( options: Options, result: Result ): - non_conformal_cells: List[ int ] = [] + 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 ) - logging.error( + non_conformal_cells: frozenset[ int ] = frozenset( non_conformal_cells ) + logger.error( f"You have {len(non_conformal_cells)} non conformal cells.\n{', '.join(map(str, sorted(non_conformal_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 3f440d93..40ff7a56 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 @@ -1,40 +1,43 @@ -import logging - import numpy +from geos.mesh.doctor.actions.self_intersecting_elements import Options, Result +from geos.mesh.doctor.parsing import SELF_INTERSECTING_ELEMENTS +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.self_intersecting_elements import Options, Result +logger = getLogger( "self_intersecting_elements" ) -from . import SELF_INTERSECTING_ELEMENTS +__MIN_DISTANCE = "min_distance" +__MIN_DISTANCE_DEFAULT = numpy.finfo( float ).eps -__TOLERANCE = "min" -__TOLERANCE_DEFAULT = numpy.finfo( float ).eps +__SELF_INTERSECTING_ELEMENTS_DEFAULT = { __MIN_DISTANCE: __MIN_DISTANCE_DEFAULT } def convert( parsed_options ) -> Options: - tolerance = parsed_options[ __TOLERANCE ] - if tolerance == 0: - logging.warning( - "Having tolerance set to 0 can induce lots of false positive results (adjacent faces may be considered intersecting)." + min_distance = parsed_options[ __MIN_DISTANCE ] + if min_distance == 0: + logger.warning( + "Having minimum distance set to 0 can induce lots of false positive results (adjacent faces may be considered intersecting)." ) - elif tolerance < 0: + elif min_distance < 0: raise ValueError( - f"Negative tolerance ({tolerance}) in the {SELF_INTERSECTING_ELEMENTS} check is not allowed." ) - return Options( tolerance=tolerance ) + f"Negative minimum distance ({min_distance}) in the {SELF_INTERSECTING_ELEMENTS} check is not allowed." ) + return Options( min_distance=min_distance ) 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( - '--' + __TOLERANCE, + '--' + __MIN_DISTANCE, type=float, required=False, - metavar=__TOLERANCE_DEFAULT, - default=__TOLERANCE_DEFAULT, - help=f"[float]: The tolerance in the computation. Defaults to your machine precision {__TOLERANCE_DEFAULT}." ) + 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}." + ) def display_results( options: Options, result: Result ): - logging.error( f"You have {len(result.intersecting_faces_elements)} elements with self intersecting faces." ) + logger.error( f"You have {len(result.intersecting_faces_elements)} elements with self intersecting faces." ) if result.intersecting_faces_elements: - logging.error( "The elements indices are:\n" + ", ".join( map( str, result.intersecting_faces_elements ) ) ) + logger.error( "The elements indices are:\n" + ", ".join( map( str, result.intersecting_faces_elements ) ) ) 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 fea58f3c..e39032a7 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 @@ -1,21 +1,21 @@ -import logging import multiprocessing +from geos.mesh.doctor.actions.supported_elements import Options, Result +from geos.mesh.doctor.parsing import SUPPORTED_ELEMENTS +from geos.utils.Logger import getLogger -from geos.mesh.doctor.checks.supported_elements import Options, Result +logger = getLogger( "supported_elements" ) -from . import SUPPORTED_ELEMENTS - -__CHUNK_SIZE = "chunck_size" +__CHUNK_SIZE = "chunk_size" __NUM_PROC = "nproc" -__ALL_KEYWORDS = { __CHUNK_SIZE, __NUM_PROC } - __CHUNK_SIZE_DEFAULT = 1 __NUM_PROC_DEFAULT = multiprocessing.cpu_count() +__SUPPORTED_ELEMENTS_DEFAULT = { __CHUNK_SIZE: __CHUNK_SIZE_DEFAULT, __NUM_PROC: __NUM_PROC_DEFAULT } + def convert( parsed_options ) -> Options: - return Options( chunk_size=parsed_options[ __CHUNK_SIZE ], num_proc=parsed_options[ __NUM_PROC ] ) + return Options( chunk_size=parsed_options[ __CHUNK_SIZE ], nproc=parsed_options[ __NUM_PROC ] ) def fill_subparser( subparsers ) -> None: @@ -39,16 +39,16 @@ def fill_subparser( subparsers ) -> None: def display_results( options: Options, result: Result ): if result.unsupported_polyhedron_elements: - logging.error( + logger.error( f"There is/are {len(result.unsupported_polyhedron_elements)} polyhedra that may not be converted to supported elements." ) - logging.error( + logger.error( f"The list of the unsupported polyhedra is\n{tuple(sorted(result.unsupported_polyhedron_elements))}." ) else: - logging.info( "All the polyhedra (if any) can be converted to supported elements." ) + logger.info( "All the polyhedra (if any) can be converted to supported elements." ) if result.unsupported_std_elements_types: - logging.error( + logger.error( f"There are unsupported vtk standard element types. The list of those vtk types is {tuple(sorted(result.unsupported_std_elements_types))}." ) else: - logging.info( "All the standard vtk element types (if any) are supported." ) + logger.info( "All the standard vtk element types (if any) are supported." ) diff --git a/geos-mesh/src/geos/mesh/doctor/parsing/vtk_output_parsing.py b/geos-mesh/src/geos/mesh/doctor/parsing/vtk_output_parsing.py index d98d8bcf..5d5a9416 100644 --- a/geos-mesh/src/geos/mesh/doctor/parsing/vtk_output_parsing.py +++ b/geos-mesh/src/geos/mesh/doctor/parsing/vtk_output_parsing.py @@ -1,7 +1,9 @@ import os.path -import logging import textwrap from geos.mesh.io.vtkIO import VtkOutput +from geos.utils.Logger import getLogger + +logger = getLogger( "vtk_output_parsing" ) __OUTPUT_FILE = "output" __OUTPUT_BINARY_MODE = "data-mode" @@ -40,6 +42,6 @@ def convert( parsed_options, prefix="" ) -> VtkOutput: binary_mode_key = __build_arg( prefix, __OUTPUT_BINARY_MODE ).replace( "-", "_" ) output = parsed_options[ output_key ] if parsed_options[ binary_mode_key ] and os.path.splitext( output )[ -1 ] == ".vtk": - logging.info( "VTK data mode will be ignored for legacy file format \"vtk\"." ) + logger.info( "VTK data mode will be ignored for legacy file format \"vtk\"." ) is_data_mode_binary: bool = parsed_options[ binary_mode_key ] == __OUTPUT_BINARY_MODE_DEFAULT return VtkOutput( output=output, is_data_mode_binary=is_data_mode_binary ) diff --git a/geos-mesh/src/geos/mesh/doctor/register.py b/geos-mesh/src/geos/mesh/doctor/register.py index 75e8d486..31ac712f 100644 --- a/geos-mesh/src/geos/mesh/doctor/register.py +++ b/geos-mesh/src/geos/mesh/doctor/register.py @@ -1,67 +1,67 @@ import argparse import importlib -import logging from typing import Dict, Callable, Any, Tuple - import geos.mesh.doctor.parsing as parsing -from geos.mesh.doctor.parsing import CheckHelper, cli_parsing +from geos.mesh.doctor.parsing import ActionHelper, cli_parsing +from geos.mesh.doctor.parsing.cli_parsing import setup_logger -__HELPERS: Dict[ str, Callable[ [ None ], CheckHelper ] ] = dict() -__CHECKS: Dict[ str, Callable[ [ None ], Any ] ] = dict() +__HELPERS: Dict[ str, Callable[ [ None ], ActionHelper ] ] = dict() +__ACTIONS: Dict[ str, Callable[ [ None ], Any ] ] = dict() -def __load_module_check( module_name: str, check_fct="check" ): - module = importlib.import_module( "geos.mesh.doctor.checks." + module_name ) - return getattr( module, check_fct ) +def __load_module_action( module_name: str, action_fct="action" ): + module = importlib.import_module( "geos.mesh.doctor.actions." + module_name ) + return getattr( module, action_fct ) -def __load_module_check_helper( module_name: str, parsing_fct_suffix="_parsing" ): +def __load_module_action_helper( module_name: str, parsing_fct_suffix="_parsing" ): module = importlib.import_module( "geos.mesh.doctor.parsing." + module_name + parsing_fct_suffix ) - return CheckHelper( fill_subparser=module.fill_subparser, - convert=module.convert, - display_results=module.display_results ) + return ActionHelper( fill_subparser=module.fill_subparser, + convert=module.convert, + display_results=module.display_results ) -def __load_checks() -> Dict[ str, Callable[ [ str, Any ], Any ] ]: +def __load_actions() -> Dict[ str, Callable[ [ str, Any ], Any ] ]: """ - Loads all the checks. + Loads all the actions. This function acts like a protection layer if a module fails to load. - A check that fails to load won't stop the process. - :return: The checks. + A action that fails to load won't stop the process. + :return: The actions. """ - loaded_checks: Dict[ str, Callable[ [ str, Any ], Any ] ] = dict() - for check_name, check_provider in __CHECKS.items(): + loaded_actions: Dict[ str, Callable[ [ str, Any ], Any ] ] = dict() + for action_name, action_provider in __ACTIONS.items(): try: - loaded_checks[ check_name ] = check_provider() - logging.debug( f"Check \"{check_name}\" is loaded." ) + loaded_actions[ action_name ] = action_provider() + setup_logger.debug( f"Action \"{action_name}\" is loaded." ) except Exception as e: - logging.warning( f"Could not load module \"{check_name}\": {e}" ) - return loaded_checks + setup_logger.warning( f"Could not load module \"{action_name}\": {e}" ) + return loaded_actions -def register( -) -> Tuple[ argparse.ArgumentParser, Dict[ str, Callable[ [ str, Any ], Any ] ], Dict[ str, CheckHelper ] ]: +def register_parsing_actions( +) -> Tuple[ argparse.ArgumentParser, Dict[ str, Callable[ [ str, Any ], Any ] ], Dict[ str, ActionHelper ] ]: """ - Register all the parsing checks. Eventually initiate the registration of all the checks too. - :return: The checks and the checks helpers. + Register all the parsing actions. Eventually initiate the registration of all the actions too. + :return: The actions and the actions helpers. """ parser = cli_parsing.init_parser() subparsers = parser.add_subparsers( help="Modules", dest="subparsers" ) def closure_trick( cn: str ): - __HELPERS[ check_name ] = lambda: __load_module_check_helper( cn ) - __CHECKS[ check_name ] = lambda: __load_module_check( cn ) + __HELPERS[ action_name ] = lambda: __load_module_action_helper( cn ) + __ACTIONS[ action_name ] = lambda: __load_module_action( cn ) # Register the modules to load here. - for check_name in ( parsing.COLLOCATES_NODES, parsing.ELEMENT_VOLUMES, parsing.FIX_ELEMENTS_ORDERINGS, - parsing.GENERATE_CUBE, parsing.GENERATE_FRACTURES, parsing.GENERATE_GLOBAL_IDS, - parsing.NON_CONFORMAL, parsing.SELF_INTERSECTING_ELEMENTS, parsing.SUPPORTED_ELEMENTS ): - closure_trick( check_name ) - loaded_checks: Dict[ str, Callable[ [ str, Any ], Any ] ] = __load_checks() - loaded_checks_helpers: Dict[ str, CheckHelper ] = dict() - for check_name in loaded_checks.keys(): - h = __HELPERS[ check_name ]() + for action_name in ( parsing.ALL_CHECKS, parsing.COLLOCATES_NODES, parsing.ELEMENT_VOLUMES, + parsing.FIX_ELEMENTS_ORDERINGS, parsing.GENERATE_CUBE, parsing.GENERATE_FRACTURES, + parsing.GENERATE_GLOBAL_IDS, parsing.NON_CONFORMAL, parsing.SELF_INTERSECTING_ELEMENTS, + parsing.SUPPORTED_ELEMENTS ): + closure_trick( action_name ) + loaded_actions: Dict[ str, Callable[ [ str, Any ], Any ] ] = __load_actions() + loaded_actions_helpers: Dict[ str, ActionHelper ] = dict() + for action_name in loaded_actions.keys(): + h = __HELPERS[ action_name ]() h.fill_subparser( subparsers ) - loaded_checks_helpers[ check_name ] = h - logging.debug( f"Parsing for check \"{check_name}\" is loaded." ) - return parser, loaded_checks, loaded_checks_helpers + loaded_actions_helpers[ action_name ] = h + setup_logger.debug( f"Parsing for action \"{action_name}\" is loaded." ) + return parser, loaded_actions, loaded_actions_helpers diff --git a/geos-mesh/tests/test_all_checks.py b/geos-mesh/tests/test_all_checks.py new file mode 100644 index 00000000..cf1c2efa --- /dev/null +++ b/geos-mesh/tests/test_all_checks.py @@ -0,0 +1,167 @@ +import pytest +import argparse +from unittest.mock import patch, MagicMock, call +from geos.mesh.doctor.actions.all_checks import Options as AllChecksOptions +from geos.mesh.doctor.actions.all_checks import Result as AllChecksResult +from geos.mesh.doctor.actions.all_checks import action +from geos.mesh.doctor.parsing.all_checks_parsing import convert, fill_subparser, display_results +from geos.mesh.doctor.parsing.all_checks_parsing import ORDERED_CHECK_NAMES, CHECK_FEATURES_CONFIG + + +# Mock data and fixtures +@pytest.fixture +def mock_parser(): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers( dest="action" ) + return parser, subparsers + + +@pytest.fixture +def mock_check_action(): + return MagicMock( return_value={ "status": "success" } ) + + +@pytest.fixture +def mock_args(): + return { + "checks_to_perform": "collocated_nodes, element_volumes", + "set_parameters": "tolerance:1.0, min_volume:0.5" + } + + +# Tests for all_checks_parsing.py +class TestAllChecksParsing: + + def test_fill_subparser( self, mock_parser ): + parser, subparsers = mock_parser + fill_subparser( subparsers ) + + # Verify subparser was created + subparsers_actions = [ + action for action in parser._subparsers._actions if isinstance( action, argparse._SubParsersAction ) + ] + assert len( subparsers_actions ) == 1 + + # Check if our subparser is in the choices + subparser_choices = subparsers_actions[ 0 ].choices + assert "all_checks" in subparser_choices # assuming ALL_CHECKS is "all_checks" + + def test_convert_with_default_checks( self ): + # Test with empty string for checks_to_perform (should use all checks) + args = { "checks_to_perform": "", "set_parameters": "" } + with patch( 'geos.mesh.doctor.parsing.all_checks_parsing.logger' ) as mock_logger: + options = convert( args ) + + # Should log that all checks will be performed + mock_logger.info.assert_any_call( "All current available checks in mesh-doctor will be performed." ) + + # Should include all checks + assert options.checks_to_perform == ORDERED_CHECK_NAMES + + # Should use default parameters + for check_name in ORDERED_CHECK_NAMES: + assert check_name in options.checks_options + + def test_convert_with_specific_checks( self, mock_args ): + with patch( 'geos.mesh.doctor.parsing.all_checks_parsing.logger' ): + options = convert( mock_args ) + + # Should only include the specified checks + expected_checks = [ "collocated_nodes", "element_volumes" ] + assert options.checks_to_perform == expected_checks + + # Should only have options for specified checks + assert set( options.checks_options.keys() ) == set( expected_checks ) + + def test_convert_with_invalid_check( self ): + args = { "checks_to_perform": "invalid_check_name", "set_parameters": "" } + with patch( 'geos.mesh.doctor.parsing.all_checks_parsing.logger' ) as mock_logger: + with pytest.raises( ValueError, match="No valid checks selected" ): + convert( args ) + + # Should log warning about invalid check + mock_logger.warning.assert_called() + + def test_convert_with_parameter_override( self ): + # Choose a check and parameter that exists in DEFAULT_PARAMS + check_name = "collocated_nodes" + param_name = next( iter( CHECK_FEATURES_CONFIG[ check_name ].default_params.keys() ) ) + args = { "checks_to_perform": check_name, "set_parameters": f"{param_name}:99.9" } + with patch( 'geos.mesh.doctor.parsing.all_checks_parsing.logger' ): + options = convert( args ) + + # Get the options object for the check + check_options = options.checks_options[ check_name ] + + # Verify the parameter was overridden + # This assumes the parameter is accessible as an attribute of the options object + # May need adjustment based on your actual implementation + assert getattr( check_options, param_name, None ) == 99.9 + + def test_display_results( self ): + # Create mock options and results + mock_display_func = MagicMock() + check_name = ORDERED_CHECK_NAMES[ 0 ] + options = AllChecksOptions( checks_to_perform=[ check_name ], + checks_options={ check_name: "mock_options" }, + check_displays={ check_name: mock_display_func } ) + result = AllChecksResult( check_results={ check_name: "mock_result" } ) + with patch( 'geos.mesh.doctor.parsing.all_checks_parsing.logger' ): + display_results( options, result ) + + # Verify display function was called with correct arguments + mock_display_func.assert_called_once_with( "mock_options", "mock_result" ) + + +# Tests for all_checks.py +class TestAllChecks: + + def test_action_calls_check_modules( self, mock_check_action ): + # Setup mock options + check_name = ORDERED_CHECK_NAMES[ 0 ] + mock_options = AllChecksOptions( checks_to_perform=[ check_name ], + checks_options={ check_name: "mock_options" }, + check_displays={ check_name: MagicMock() } ) + # Mock the module loading function + with patch( 'geos.mesh.doctor.actions.all_checks.__load_module_action', + return_value=mock_check_action ) as mock_load: + with patch( 'geos.mesh.doctor.actions.all_checks.logger' ): + result = action( "test_file.vtk", mock_options ) + + # Verify the module was loaded + mock_load.assert_called_once_with( check_name ) + + # Verify the check action was called with correct args + mock_check_action.assert_called_once_with( "test_file.vtk", "mock_options" ) + + # Verify result contains the check result + assert check_name in result.check_results + assert result.check_results[ check_name ] == { "status": "success" } + + def test_action_with_multiple_checks( self, mock_check_action ): + # Setup mock options with multiple checks + check_names = [ ORDERED_CHECK_NAMES[ 0 ], ORDERED_CHECK_NAMES[ 1 ] ] + mock_options = AllChecksOptions( checks_to_perform=check_names, + checks_options={ + name: f"mock_options_{i}" + for i, name in enumerate( check_names ) + }, + check_displays={ name: MagicMock() + for name in check_names } ) + # Mock the module loading function + with patch( 'geos.mesh.doctor.actions.all_checks.__load_module_action', + return_value=mock_check_action ) as mock_load: + with patch( 'geos.mesh.doctor.actions.all_checks.logger' ): + result = action( "test_file.vtk", mock_options ) + + # Verify the modules were loaded + assert mock_load.call_count == 2 + mock_load.assert_has_calls( [ call( check_names[ 0 ] ), call( check_names[ 1 ] ) ] ) + + # Verify all checks were called + assert mock_check_action.call_count == 2 + + # Verify result contains all check results + for name in check_names: + assert name in result.check_results + assert result.check_results[ name ] == { "status": "success" } diff --git a/geos-mesh/tests/test_cli_parsing.py b/geos-mesh/tests/test_cli_parsing.py index a73fe3f3..5187d2e5 100644 --- a/geos-mesh/tests/test_cli_parsing.py +++ b/geos-mesh/tests/test_cli_parsing.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import pytest from typing import Iterator, Sequence -from geos.mesh.doctor.checks.generate_fractures import FracturePolicy, Options +from geos.mesh.doctor.actions.generate_fractures import FracturePolicy, Options from geos.mesh.doctor.parsing.generate_fractures_parsing import convert, display_results, fill_subparser from geos.mesh.io.vtkIO import VtkOutput diff --git a/geos-mesh/tests/test_collocated_nodes.py b/geos-mesh/tests/test_collocated_nodes.py index ecc36dbb..86f798f7 100644 --- a/geos-mesh/tests/test_collocated_nodes.py +++ b/geos-mesh/tests/test_collocated_nodes.py @@ -2,7 +2,7 @@ from typing import Iterator, Tuple from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkTetra, vtkUnstructuredGrid, VTK_TETRA -from geos.mesh.doctor.checks.collocated_nodes import Options, __check +from geos.mesh.doctor.actions.collocated_nodes import Options, __action def get_points() -> Iterator[ Tuple[ vtkPoints, int ] ]: @@ -27,7 +27,7 @@ def test_simple_collocated_points( data: Tuple[ vtkPoints, int ] ): mesh = vtkUnstructuredGrid() mesh.SetPoints( points ) - result = __check( mesh, Options( tolerance=1.e-12 ) ) + result = __action( mesh, Options( tolerance=1.e-12 ) ) assert len( result.wrong_support_elements ) == 0 assert len( result.nodes_buckets ) == num_nodes_bucket @@ -58,7 +58,7 @@ def test_wrong_support_elements(): mesh.SetPoints( points ) mesh.SetCells( cell_types, cells ) - result = __check( mesh, Options( tolerance=1.e-12 ) ) + result = __action( mesh, Options( tolerance=1.e-12 ) ) assert len( result.nodes_buckets ) == 0 assert len( result.wrong_support_elements ) == 1 diff --git a/geos-mesh/tests/test_element_volumes.py b/geos-mesh/tests/test_element_volumes.py index 50635eb0..dccbda93 100644 --- a/geos-mesh/tests/test_element_volumes.py +++ b/geos-mesh/tests/test_element_volumes.py @@ -1,7 +1,7 @@ import numpy from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkCommonDataModel import VTK_TETRA, vtkCellArray, vtkTetra, vtkUnstructuredGrid -from geos.mesh.doctor.checks.element_volumes import Options, __check +from geos.mesh.doctor.actions.element_volumes import Options, __action def test_simple_tet(): @@ -28,12 +28,12 @@ def test_simple_tet(): mesh.SetPoints( points ) mesh.SetCells( cell_types, cells ) - result = __check( mesh, Options( min_volume=1. ) ) + 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 = __check( mesh, Options( min_volume=0. ) ) + 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 effa8aa8..d02ef68b 100644 --- a/geos-mesh/tests/test_generate_cube.py +++ b/geos-mesh/tests/test_generate_cube.py @@ -1,4 +1,4 @@ -from geos.mesh.doctor.checks.generate_cube import __build, Options, FieldInfo +from geos.mesh.doctor.actions.generate_cube import __build, Options, FieldInfo def test_generate_cube(): diff --git a/geos-mesh/tests/test_generate_fractures.py b/geos-mesh/tests/test_generate_fractures.py index 49f9bd82..66c9496f 100644 --- a/geos-mesh/tests/test_generate_fractures.py +++ b/geos-mesh/tests/test_generate_fractures.py @@ -4,10 +4,10 @@ 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.checks.check_fractures import format_collocated_nodes -from geos.mesh.doctor.checks.generate_cube import build_rectilinear_blocks_mesh, XYZ -from geos.mesh.doctor.checks.generate_fractures import ( __split_mesh_on_fractures, Options, FracturePolicy, - Coordinates3D, IDMapping ) +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, + Coordinates3D, IDMapping ) from geos.mesh.utils.genericHelpers import to_vtk_id_list FaceNodesCoords = tuple[ tuple[ float ] ] @@ -215,7 +215,7 @@ def test_generate_fracture( test_case: TestCase ): def add_simplified_field_for_cells( mesh: vtkUnstructuredGrid, field_name: str, field_dimension: int ): - """Reduce functionality obtained from src.geos.mesh.doctor.checks.generate_fracture.__add_fields + """Reduce functionality obtained from src.geos.mesh.doctor.actions.generate_fracture.__add_fields where the goal is to add a cell data array with incrementing values. Args: diff --git a/geos-mesh/tests/test_generate_global_ids.py b/geos-mesh/tests/test_generate_global_ids.py index 40c21179..614f771c 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.checks.generate_global_ids import __build_global_ids +from geos.mesh.doctor.actions.generate_global_ids import __build_global_ids def test_generate_global_ids(): diff --git a/geos-mesh/tests/test_non_conformal.py b/geos-mesh/tests/test_non_conformal.py index 438f0948..9f6da41a 100644 --- a/geos-mesh/tests/test_non_conformal.py +++ b/geos-mesh/tests/test_non_conformal.py @@ -1,6 +1,6 @@ import numpy -from geos.mesh.doctor.checks.generate_cube import build_rectilinear_blocks_mesh, XYZ -from geos.mesh.doctor.checks.non_conformal import Options, __check +from geos.mesh.doctor.actions.generate_cube import build_rectilinear_blocks_mesh, XYZ +from geos.mesh.doctor.actions.non_conformal import Options, __action def test_two_close_hexs(): @@ -12,13 +12,13 @@ def test_two_close_hexs(): # 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 = __check( mesh, options ) + results = __action( 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 = __check( mesh, options ) + results = __action( mesh, options ) assert len( results.non_conformal_cells ) == 0 @@ -31,7 +31,7 @@ def test_two_distant_hexs(): options = Options( angle_tolerance=1., point_tolerance=delta / 2., face_tolerance=delta / 2. ) - results = __check( mesh, options ) + results = __action( mesh, options ) assert len( results.non_conformal_cells ) == 0 @@ -44,7 +44,7 @@ def test_two_close_shifted_hexs(): options = Options( angle_tolerance=1., point_tolerance=delta_x * 2, face_tolerance=delta_x * 2 ) - results = __check( mesh, options ) + results = __action( mesh, options ) assert len( results.non_conformal_cells ) == 1 assert set( results.non_conformal_cells[ 0 ] ) == { 0, 1 } @@ -58,6 +58,6 @@ def test_big_elem_next_to_small_elem(): options = Options( angle_tolerance=1., point_tolerance=delta * 2, face_tolerance=delta * 2 ) - results = __check( mesh, options ) + results = __action( mesh, options ) assert len( results.non_conformal_cells ) == 1 assert set( results.non_conformal_cells[ 0 ] ) == { 0, 1 } diff --git a/geos-mesh/tests/test_reorient_mesh.py b/geos-mesh/tests/test_reorient_mesh.py index 5884d5f7..dea8abdc 100644 --- a/geos-mesh/tests/test_reorient_mesh.py +++ b/geos-mesh/tests/test_reorient_mesh.py @@ -4,8 +4,8 @@ from typing import Generator from vtkmodules.vtkCommonCore import vtkIdList, vtkPoints from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_POLYHEDRON -from geos.mesh.doctor.checks.reorient_mesh import reorient_mesh -from geos.mesh.doctor.checks.vtk_polyhedron import FaceStream +from geos.mesh.doctor.actions.reorient_mesh import reorient_mesh +from geos.mesh.doctor.actions.vtk_polyhedron import FaceStream from geos.mesh.utils.genericHelpers import to_vtk_id_list, vtk_iter diff --git a/geos-mesh/tests/test_self_intersecting_elements.py b/geos-mesh/tests/test_self_intersecting_elements.py index d890b1e1..45216f01 100644 --- a/geos-mesh/tests/test_self_intersecting_elements.py +++ b/geos-mesh/tests/test_self_intersecting_elements.py @@ -1,6 +1,6 @@ from vtkmodules.vtkCommonCore import vtkPoints from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkHexahedron, vtkUnstructuredGrid, VTK_HEXAHEDRON -from geos.mesh.doctor.checks.self_intersecting_elements import Options, __check +from geos.mesh.doctor.actions.self_intersecting_elements import Options, __action def test_jumbled_hex(): @@ -35,7 +35,7 @@ def test_jumbled_hex(): mesh.SetPoints( points ) mesh.SetCells( cell_types, cells ) - result = __check( mesh, Options( tolerance=0. ) ) + 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 index 5e87fb38..07321abc 100644 --- a/geos-mesh/tests/test_supported_elements.py +++ b/geos-mesh/tests/test_supported_elements.py @@ -3,8 +3,8 @@ from typing import Tuple from vtkmodules.vtkCommonCore import vtkIdList, vtkPoints from vtkmodules.vtkCommonDataModel import vtkUnstructuredGrid, VTK_POLYHEDRON -# from geos.mesh.doctor.checks.supported_elements import Options, check, __check -from geos.mesh.doctor.checks.vtk_polyhedron import parse_face_stream, FaceStream +# 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 diff --git a/geos-mesh/tests/test_triangle_distance.py b/geos-mesh/tests/test_triangle_distance.py index b90f881b..96274f14 100644 --- a/geos-mesh/tests/test_triangle_distance.py +++ b/geos-mesh/tests/test_triangle_distance.py @@ -2,7 +2,7 @@ import numpy from numpy.linalg import norm import pytest -from geos.mesh.doctor.checks.triangle_distance import distance_between_two_segments, distance_between_two_triangles +from geos.mesh.doctor.actions.triangle_distance import distance_between_two_segments, distance_between_two_triangles @dataclass( frozen=True ) diff --git a/geos-utils/src/geos/utils/Logger.py b/geos-utils/src/geos/utils/Logger.py index 78945525..9cb6eb03 100644 --- a/geos-utils/src/geos/utils/Logger.py +++ b/geos-utils/src/geos/utils/Logger.py @@ -3,7 +3,6 @@ # SPDX-FileContributor: Martin Lemay import logging from typing import Union - from typing_extensions import Self __doc__ = """ @@ -15,6 +14,7 @@ # types redefinition to import logging.* from this module Logger = logging.Logger #: logger type +# Define logging levels at the module level so they are available for the Formatter class DEBUG: int = logging.DEBUG INFO: int = logging.INFO WARNING: int = logging.WARNING @@ -31,24 +31,26 @@ class CustomLoggerFormatter( logging.Formatter ): .. code-block:: python - logger = logging.getLogger("Logger name") - ch = logging.StreamHandler() - ch.setFormatter(CustomLoggerFormatter()) - logger.addHandler(ch) + logger = logging.getLogger( "Logger name", use_color=False ) + # Ensure handler is added only once, e.g., by checking logger.handlers + if not logger.handlers: + ch = logging.StreamHandler() + ch.setFormatter(CustomLoggerFormatter()) + logger.addHandler(ch) """ - # define color codes grey: str = "\x1b[38;20m" yellow: str = "\x1b[33;20m" red: str = "\x1b[31;20m" bold_red: str = "\x1b[31;1m" reset: str = "\x1b[0m" + # define prefix of log messages format1: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" format2: str = ( "%(asctime)s - %(name)s - %(levelname)s - %(message)s (%(filename)s:%(lineno)d)" ) - #: format for each logger output type - FORMATS: dict[ int, str ] = { + #: format for each logger output type with colors + FORMATS_COLOR: dict[ int, str ] = { DEBUG: grey + format2 + reset, INFO: grey + format1 + reset, WARNING: yellow + format1 + reset, @@ -56,8 +58,8 @@ class CustomLoggerFormatter( logging.Formatter ): CRITICAL: bold_red + format2 + reset, } - #: same without colors - FORMATS_PV: dict[ int, str ] = { + #: format for each logger output type without colors (e.g., for Paraview) + FORMATS_PLAIN: dict[ int, str ] = { DEBUG: format2, INFO: format1, WARNING: format1, @@ -65,6 +67,29 @@ class CustomLoggerFormatter( logging.Formatter ): CRITICAL: format2, } + # Pre-compiled formatters for efficiency + _compiled_formatters: dict[ int, logging.Formatter ] = { + level: logging.Formatter( fmt ) + for level, fmt in FORMATS_PLAIN.items() + } + + _compiled_color_formatters: dict[ int, logging.Formatter ] = { + level: logging.Formatter( fmt ) + for level, fmt in FORMATS_COLOR.items() + } + + def __init__( self: Self, use_color: bool = False ) -> None: + """Initialize the log formatter. + + Args: + use_color (bool): If True, use color-coded log formatters. + Defaults to False. + """ + if use_color: + self.active_formatters = self._compiled_color_formatters + else: + self.active_formatters = self._compiled_formatters + def format( self: Self, record: logging.LogRecord ) -> str: """Return the format according to input record. @@ -74,14 +99,22 @@ def format( self: Self, record: logging.LogRecord ) -> str: Returns: str: format as a string """ - log_fmt: Union[ str, None ] = self.FORMATS_PV.get( record.levelno ) - formatter = logging.Formatter( log_fmt ) - return formatter.format( record ) + # Defaulting to plain formatters as per original logic + log_fmt_obj: Union[ logging.Formatter, None ] = self.active_formatters.get( record.levelno ) + if log_fmt_obj: + return log_fmt_obj.format( record ) + else: + # Fallback for unknown levels or if a level is missing in the map + return logging.Formatter().format( record ) -def getLogger( title: str ) -> Logger: +def getLogger( title: str, use_color: bool = False ) -> Logger: """Return the Logger with pre-defined configuration. + This function is now idempotent regarding handler addition. + Calling it multiple times with the same title will return the same + logger instance without adding more handlers if one is already present. + Example: .. code-block:: python @@ -101,13 +134,24 @@ def getLogger( title: str ) -> Logger: Args: title (str): Name of the logger. + use_color (bool): If True, configure the logger to output with color. + Defaults to False. Returns: Logger: logger """ logger: Logger = logging.getLogger( title ) - logger.setLevel( logging.INFO ) - ch = logging.StreamHandler() - ch.setFormatter( CustomLoggerFormatter() ) - logger.addHandler( ch ) + # Only configure the logger (add handlers, set level) if it hasn't been configured before. + if not logger.hasHandlers(): # More Pythonic way to check if logger.handlers is empty + logger.setLevel( INFO ) # Set the desired default level for this logger + # Create and add the stream handler + ch = logging.StreamHandler() + ch.setFormatter( CustomLoggerFormatter( use_color ) ) # Use your custom formatter + logger.addHandler( ch ) + # Optional: Prevent messages from propagating to the root logger's handlers + logger.propagate = False + # If you need to ensure a certain level is set every time getLogger is called, + # even if handlers were already present, you can set the level outside the 'if' block. + # However, typically, setLevel is part of the initial handler configuration. + # logger.setLevel(INFO) # Uncomment if you need to enforce level on every call return logger