Coverage for src/beamme/core/mesh_representation.py: 95%

168 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-28 15:20 +0000

1# The MIT License (MIT) 

2# 

3# Copyright (c) 2018-2026 BeamMe Authors 

4# 

5# Permission is hereby granted, free of charge, to any person obtaining a copy 

6# of this software and associated documentation files (the "Software"), to deal 

7# in the Software without restriction, including without limitation the rights 

8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 

9# copies of the Software, and to permit persons to whom the Software is 

10# furnished to do so, subject to the following conditions: 

11# 

12# The above copyright notice and this permission notice shall be included in 

13# all copies or substantial portions of the Software. 

14# 

15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 

16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 

17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 

18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 

19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 

20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 

21# THE SOFTWARE. 

22"""This module defines the mesh representation data structure.""" 

23 

24from collections.abc import Iterable as _Iterable 

25from dataclasses import dataclass as _dataclass 

26from itertools import repeat as _repeat 

27from typing import Any as _Any 

28 

29import numpy as _np 

30import pyvista as _pv 

31from numpy.typing import NDArray as _NDArray 

32 

33from beamme.core.conf import Geometry as _Geometry 

34from beamme.core.conf import bme as _bme 

35 

36MESH_REPRESENTATION_MAPPINGS: dict[str, _Any] = {} 

37# fmt: off 

38MESH_REPRESENTATION_MAPPINGS[ 

39 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

40] = { 

41 

42 # Only list the non-standard mappings 

43 (_bme.element_type.solid, 20): 

44 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15], 

45 (_bme.element_type.solid, 27): 

46 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15, 

47 24, 22, 21, 23, 20, 25, 26], 

48} 

49# fmt: on 

50MESH_REPRESENTATION_MAPPINGS[ 

51 "element_type_and_n_nodes_to_connectivity_mapping_vtk_to_beamme" 

52] = { 

53 # Only list the non-standard mappings 

54 (_bme.element_type.solid, 20): _np.argsort( 

55 MESH_REPRESENTATION_MAPPINGS[ 

56 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

57 ][(_bme.element_type.solid, 20)] 

58 ), 

59 (_bme.element_type.solid, 27): _np.argsort( 

60 MESH_REPRESENTATION_MAPPINGS[ 

61 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

62 ][(_bme.element_type.solid, 27)] 

63 ), 

64} 

65 

66# fmt: off 

67MESH_REPRESENTATION_MAPPINGS["connectivity_mapping_exodus_to_vtk"] = { 

68 # Only list the non-standard mappings 

69 20: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15], 

70 27: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 16, 17, 18, 19, 12, 13, 14, 15, 23, 24, 25, 26, 21, 22, 20], 

71} 

72# fmt: on 

73 

74 

75GEOMETRY_SET_INFO_PREFIX = "geometry_set" 

76 

77 

78@_dataclass 

79class GeometrySetInfo: 

80 """Data class that contains information of a geometry set.""" 

81 

82 geometry_type: _Geometry 

83 i_global: int 

84 name: str | None = None 

85 point_flag_vector: _NDArray | None = None 

86 cell_flag_vector: _NDArray | None = None 

87 

88 def __str__(self): 

89 """Return a string representation of this geometry set info - the node and cell flags are not included.""" 

90 name = self.name 

91 return ( 

92 f"{GEOMETRY_SET_INFO_PREFIX}_{self.i_global}_{self.geometry_type.name}" 

93 + (f"_{name}" if name is not None else "") 

94 ) 

95 

96 

97def string_to_geometry_set_info(name: str) -> GeometrySetInfo | None: 

98 """Extract the geometry set information from a given string.""" 

99 if not name.startswith(GEOMETRY_SET_INFO_PREFIX): 

100 return None 

101 

102 name_without_prefix = name[len(GEOMETRY_SET_INFO_PREFIX) + 1 :] 

103 split = name_without_prefix.split("_", 2) 

104 i_global = int(split[0]) 

105 geometry_type = _Geometry[split[1]] 

106 return GeometrySetInfo( 

107 i_global=i_global, 

108 geometry_type=geometry_type, 

109 name=split[2] if len(split) == 3 else None, 

110 ) 

111 

112 

113class MeshRepresentation: 

114 """Class representing a generic mesh.""" 

115 

116 def __init__( 

117 self, 

118 cell_connectivity: _NDArray[_np.integer] | None = None, 

119 cell_types: _NDArray[_np.integer] | None = None, 

120 points: _NDArray[_np.floating] | None = None, 

121 geometry_sets: list[GeometrySetInfo] | None = None, 

122 cell_data: dict[str, _NDArray | None] | None = None, 

123 point_data: dict[str, _NDArray | None] | None = None, 

124 ): 

125 def _convert_argument_numpy( 

126 argument: _NDArray | None, 

127 default_shape: tuple[int, ...], 

128 dtype: type, 

129 ) -> _NDArray: 

130 """Convert given array arguments so we can store them in this object.""" 

131 if argument is None: 

132 return _np.empty(default_shape, dtype=dtype) 

133 else: 

134 return _np.asarray(argument, dtype=dtype) 

135 

136 def _filter_none_entries( 

137 argument: dict[str, _NDArray | None] | None, expected_size: int 

138 ) -> dict[str, _NDArray]: 

139 """Check if a dictionary is given, and if so, filter None entries from 

140 it.""" 

141 if argument is None: 

142 return {} 

143 else: 

144 data = { 

145 key: _np.asarray(value) 

146 for key, value in argument.items() 

147 if value is not None 

148 } 

149 for key, value in data.items(): 

150 if len(value) != expected_size: 

151 raise ValueError( 

152 f"Data field {key} has size {len(value)}, but expected size is {expected_size}." 

153 ) 

154 return data 

155 

156 self.cell_connectivity = _convert_argument_numpy( 

157 cell_connectivity, default_shape=(0,), dtype=int 

158 ) 

159 self.cell_types = _convert_argument_numpy( 

160 cell_types, default_shape=(0,), dtype=int 

161 ) 

162 self.points = _convert_argument_numpy(points, default_shape=(0, 3), dtype=float) 

163 

164 self.cell_data = _filter_none_entries(cell_data, self.n_cells) 

165 self.point_data = _filter_none_entries(point_data, self.n_points) 

166 

167 def _store_geometry_set_data( 

168 data_field: str, 

169 name: str, 

170 field_vector: _NDArray | None, 

171 expected_size: int, 

172 ) -> None: 

173 """Store point or cell data defining a geometry set.""" 

174 if field_vector is not None: 

175 if len(field_vector) != expected_size: 

176 raise ValueError( 

177 f"Geometry set {name} has size {len(field_vector)}," 

178 f" but expected size is {expected_size}." 

179 ) 

180 getattr(self, data_field)[str(name)] = field_vector 

181 

182 if geometry_sets is not None: 

183 for geometry_set in geometry_sets: 

184 geometry_set_name = str(geometry_set) 

185 _store_geometry_set_data( 

186 "point_data", 

187 geometry_set_name, 

188 geometry_set.point_flag_vector, 

189 self.n_points, 

190 ) 

191 _store_geometry_set_data( 

192 "cell_data", 

193 geometry_set_name, 

194 geometry_set.cell_flag_vector, 

195 self.n_cells, 

196 ) 

197 

198 # Get the offset array so we can iterate over the connectivity in a 

199 # performant manner. 

200 self.cell_connectivity_offsets = _np.zeros(self.n_cells + 1, dtype=int) 

201 for i_cell in range(self.n_cells): 

202 last_offset = self.cell_connectivity_offsets[i_cell] 

203 if last_offset >= len(self.cell_connectivity): 

204 raise ValueError( 

205 "Invalid offset, is larger than the size of the connectivity." 

206 ) 

207 cell_size = self.cell_connectivity[last_offset] 

208 if cell_size < 1: 

209 raise ValueError("Invalid offset, has to be at least 1.") 

210 self.cell_connectivity_offsets[i_cell + 1] = ( 

211 cell_size + self.cell_connectivity_offsets[i_cell] + 1 

212 ) 

213 if self.cell_connectivity_offsets[-1] != len(self.cell_connectivity): 

214 raise ValueError("Invalid cell connectivity offsets.") 

215 

216 @property 

217 def n_cells(self) -> int: 

218 """Return the number of cells in this mesh representation. 

219 

220 Returns: 

221 Number of cells in this mesh representation. 

222 """ 

223 return len(self.cell_types) 

224 

225 @property 

226 def n_points(self) -> int: 

227 """Return the number of points in this mesh representation. 

228 

229 Returns: 

230 Number of points in this mesh representation. 

231 """ 

232 return len(self.points) 

233 

234 def connectivity_iterator( 

235 self, *, element_indices: list[int] | _NDArray[_np.integer] | None = None 

236 ) -> _Iterable: 

237 """Return an iterator over the cell connectivity. 

238 

239 Args: 

240 element_indices: If this argument is given, only iterate over the 

241 given indices. 

242 

243 Returns: 

244 An iterator that returns the connectivity array for each cell. 

245 """ 

246 if element_indices is None: 

247 element_indices = _np.arange(self.n_cells) 

248 for i in element_indices: 

249 start = self.cell_connectivity_offsets[i] + 1 

250 end = self.cell_connectivity_offsets[i + 1] 

251 yield self.cell_connectivity[start:end] 

252 

253 def data_iterator(self, data_field: str, data_name: str) -> _Iterable: 

254 """This method returns an iterator for the given data field and data name. 

255 

256 This is useful, when looping over the data, as accessing the data field in each 

257 loop iteration can be expensive. If the data field is not present, a iterator 

258 is returned that will always return `None`. 

259 

260 Args: 

261 data_field: The data field to get the iterator for. This can be either 

262 "point_data" or "cell_data". 

263 data_name: The name of the data to get the iterator for. 

264 

265 Returns: 

266 An iterator to iterate through the data. If the data field is not present, 

267 a iterator is returned that will always return `None`. 

268 """ 

269 data_dict = getattr(self, data_field) 

270 if data_name in data_dict: 

271 return data_dict[data_name] 

272 else: 

273 match data_field: 

274 case "point_data": 

275 size = self.n_points 

276 case "cell_data": 

277 size = self.n_cells 

278 case _: 

279 raise ValueError(f"Invalid data field: {data_field}") 

280 return _repeat(None, size) 

281 

282 def offset_indices( 

283 self, 

284 element_type_id_offset: int | None = None, 

285 material_offset: int | None = None, 

286 geometry_set_offset: int | None = None, 

287 ) -> None: 

288 """Add offsets to the internal indices of this mesh representation. 

289 

290 This is required when merging multiple mesh representations together, 

291 to ensure that there are no index conflicts. 

292 

293 Args: 

294 element_type_id_offset: The offset to add to the element type IDs. 

295 material_offset: The offset to add to the material IDs. 

296 geometry_set_offset: The offset to add to the geometry set IDs. 

297 """ 

298 if element_type_id_offset is not None: 

299 if "element_type_id" in self.cell_data: 

300 self.cell_data["element_type_id"] += element_type_id_offset 

301 

302 if material_offset is not None: 

303 if "material_id" in self.cell_data: 

304 # Add the offset to all material entries that are not -1. We use 

305 # -1 to indicate no assigned material and those values should not change. 

306 self.cell_data["material_id"][self.cell_data["material_id"] >= 0] += ( 

307 material_offset 

308 ) 

309 

310 if geometry_set_offset is not None: 

311 for field_type in ("point_data", "cell_data"): 

312 new_dict = {} 

313 data_field = getattr(self, field_type) 

314 # We change the keys in the loop, so we iterate over a copy of them. 

315 for name in list(data_field.keys()): 

316 info = string_to_geometry_set_info(name) 

317 if info is not None: 

318 new_name = str( 

319 GeometrySetInfo( 

320 geometry_type=info.geometry_type, 

321 i_global=info.i_global + geometry_set_offset, 

322 name=info.name, 

323 ) 

324 ) 

325 new_dict[new_name] = data_field.pop(name) 

326 # Add the renamed geometry sets back to the data field. 

327 setattr(self, field_type, {**data_field, **new_dict}) 

328 

329 def get_pyvista_grid( 

330 self, 

331 *, 

332 cell_data_fields: bool | list[str] = False, 

333 point_data_fields: bool | list[str] = False, 

334 add_geometry_sets: bool = False, 

335 ) -> _pv.UnstructuredGrid: 

336 """Return a PyVista UnstructuredGrid representation of this mesh representation. 

337 

338 Args: 

339 cell_data_fields: The cell data fields to add to the grid. This can be 

340 either a list of field names, or a boolean. If a list of field names 

341 is given, only those fields are added to the grid. If it is True, all 

342 cell data fields are added to the grid. If it is False, no cell data 

343 fields are added to the grid. 

344 point_data_fields: The point data fields to add to the grid. This can be 

345 either a list of field names, or a boolean. If a list of field names 

346 is given, only those fields are added to the grid. If it is True, all 

347 point data fields are added to the grid. If it is False, no point data 

348 fields are added to the grid. 

349 add_geometry_sets: If this is True, all geometry set information is added to 

350 the grid, even if it is not included in the cell_data_fields or 

351 point_data_fields arguments. 

352 

353 Returns: 

354 A PyVista UnstructuredGrid representation of this mesh representation. 

355 """ 

356 grid = _pv.UnstructuredGrid( 

357 self.cell_connectivity, 

358 self.cell_types, 

359 self.points, 

360 ) 

361 

362 for names, mesh_representation_field, grid_field in zip( 

363 (cell_data_fields, point_data_fields), 

364 (self.cell_data, self.point_data), 

365 (grid.cell_data, grid.point_data), 

366 ): 

367 for name, data in mesh_representation_field.items(): 

368 add_field = False 

369 if type(names) is bool: 

370 add_field = names 

371 elif name in names: 

372 add_field = True 

373 elif add_geometry_sets: 

374 add_field = string_to_geometry_set_info(name) is not None 

375 if add_field: 

376 grid_field[name] = data 

377 

378 return grid 

379 

380 

381def merge_mesh_representations( 

382 mesh_representation_a: MeshRepresentation, mesh_representation_b: MeshRepresentation 

383) -> MeshRepresentation: 

384 """Merge two mesh representations. 

385 

386 Args: 

387 mesh_representation_a: First mesh representation. 

388 mesh_representation_b: Second mesh representation. 

389 

390 Returns: 

391 A merged mesh representation. Depending on the input data, this might 

392 be a reference to one of the input mesh representations. 

393 """ 

394 

395 def _is_empty(mesh_representation: MeshRepresentation) -> bool: 

396 """Check if the mesh representation contains any points or cells.""" 

397 return mesh_representation.n_points == 0 and mesh_representation.n_cells == 0 

398 

399 # If one of the two mesh representations is empty, we can simply return the other (which 

400 # could be empty as well). 

401 if _is_empty(mesh_representation_a): 

402 return mesh_representation_b 

403 elif _is_empty(mesh_representation_b): 

404 return mesh_representation_a 

405 

406 # Merge the points 

407 merged_points = _np.vstack( 

408 (mesh_representation_a.points, mesh_representation_b.points) 

409 ) 

410 

411 # Merge the connectivity 

412 # Before the connectivity can be merged, the IDs of the second mesh have 

413 # to be increased by the size of the first mesh. To do so, we create a 

414 # mask to identify the entries that have to be incremented. 

415 cell_connectivity_a = mesh_representation_a.cell_connectivity 

416 cell_connectivity_b = mesh_representation_b.cell_connectivity 

417 offsets_b = mesh_representation_b.cell_connectivity_offsets 

418 

419 merged_cell_connectivity = _np.empty( 

420 cell_connectivity_a.size + cell_connectivity_b.size, 

421 dtype=cell_connectivity_a.dtype, 

422 ) 

423 merged_cell_connectivity[: cell_connectivity_a.size] = cell_connectivity_a 

424 merged_cell_connectivity[cell_connectivity_a.size :] = cell_connectivity_b 

425 

426 mask = _np.ones(cell_connectivity_b.size, dtype=bool) 

427 mask[offsets_b[:-1]] = False 

428 

429 merged_cell_connectivity_view_part_b = merged_cell_connectivity[ 

430 cell_connectivity_a.size : 

431 ] 

432 merged_cell_connectivity_view_part_b[mask] += mesh_representation_a.n_points 

433 

434 # Merge the cell types. 

435 merged_cell_types = _np.concatenate( 

436 ( 

437 mesh_representation_a.cell_types, 

438 mesh_representation_b.cell_types, 

439 ) 

440 ) 

441 

442 # Merge the contained data dictionaries. 

443 def _merge_data_dicts( 

444 dict_a: dict[str, _NDArray], 

445 size_a: int, 

446 dict_b: dict[str, _NDArray], 

447 size_b: int, 

448 ) -> dict[str, _NDArray]: 

449 """Merge the given data dictionaries and fill in non-existing data.""" 

450 

451 def _ensure_array_size(size: int, reference_array: _NDArray) -> _NDArray: 

452 """Create an empty array that matches the columns of the reference array and 

453 has the given size, i.e., number of rows.""" 

454 new_shape = reference_array.shape 

455 if len(new_shape) == 1: 

456 new_shape = (size,) 

457 elif len(new_shape) == 2: 

458 new_shape = (size, new_shape[1]) 

459 else: 

460 raise ValueError(f"Got unexpected array shape {new_shape}.") 

461 return _np.zeros(new_shape, dtype=reference_array.dtype) 

462 

463 all_keys = set(dict_a.keys()) | set(dict_b.keys()) 

464 merged_data = {} 

465 for key in all_keys: 

466 data_a = dict_a.get(key, None) 

467 data_b = dict_b.get(key, None) 

468 if data_a is None: 

469 data_a = _ensure_array_size(size_a, data_b) 

470 elif data_b is None: 

471 data_b = _ensure_array_size(size_b, data_a) 

472 merged_data[key] = _np.concatenate((data_a, data_b)) 

473 return merged_data 

474 

475 merged_cell_data = _merge_data_dicts( 

476 mesh_representation_a.cell_data, 

477 mesh_representation_a.n_cells, 

478 mesh_representation_b.cell_data, 

479 mesh_representation_b.n_cells, 

480 ) 

481 merged_point_data = _merge_data_dicts( 

482 mesh_representation_a.point_data, 

483 mesh_representation_a.n_points, 

484 mesh_representation_b.point_data, 

485 mesh_representation_b.n_points, 

486 ) 

487 

488 return MeshRepresentation( 

489 cell_connectivity=merged_cell_connectivity, 

490 cell_types=merged_cell_types, 

491 points=merged_points, 

492 cell_data=merged_cell_data, 

493 point_data=merged_point_data, 

494 )