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

152 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-08 11:03 +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 dataclasses import dataclass as _dataclass 

25from itertools import repeat as _repeat 

26from typing import Any as _Any 

27from typing import Iterable as _Iterable 

28 

29import numpy as _np 

30from numpy.typing import NDArray as _NDArray 

31 

32from beamme.core.conf import Geometry as _Geometry 

33from beamme.core.conf import bme as _bme 

34 

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

36# fmt: off 

37MESH_REPRESENTATION_MAPPINGS[ 

38 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

39] = { 

40 

41 # Only list the non-standard mappings 

42 (_bme.element_type.solid, 20): 

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

44 (_bme.element_type.solid, 27): 

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

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

47} 

48# fmt: on 

49MESH_REPRESENTATION_MAPPINGS[ 

50 "element_type_and_n_nodes_to_connectivity_mapping_vtk_to_beamme" 

51] = { 

52 # Only list the non-standard mappings 

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

54 MESH_REPRESENTATION_MAPPINGS[ 

55 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

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

57 ), 

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

59 MESH_REPRESENTATION_MAPPINGS[ 

60 "element_type_and_n_nodes_to_connectivity_mapping_beamme_to_vtk" 

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

62 ), 

63} 

64 

65GEOMETRY_SET_INFO_PREFIX = "geometry_set" 

66 

67 

68@_dataclass 

69class GeometrySetInfo: 

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

71 

72 geometry_type: _Geometry 

73 i_global: int 

74 name: str | None = None 

75 point_flag_vector: _NDArray | None = None 

76 cell_flag_vector: _NDArray | None = None 

77 

78 def __str__(self): 

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

80 name = self.name 

81 return ( 

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

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

84 ) 

85 

86 

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

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

89 if not name.startswith(GEOMETRY_SET_INFO_PREFIX): 

90 return None 

91 

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

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

94 i_global = int(split[0]) 

95 geometry_type = _Geometry[split[1]] 

96 return GeometrySetInfo( 

97 i_global=i_global, 

98 geometry_type=geometry_type, 

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

100 ) 

101 

102 

103class MeshRepresentation: 

104 """Class representing a generic mesh.""" 

105 

106 def __init__( 

107 self, 

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

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

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

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

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

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

114 ): 

115 def _convert_argument_numpy( 

116 argument: _NDArray | None, 

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

118 dtype: type, 

119 ) -> _NDArray: 

120 """Convert given array arguments so we can store them in this 

121 object.""" 

122 if argument is None: 

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

124 else: 

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

126 

127 def _filter_none_entries( 

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

129 ) -> dict[str, _NDArray]: 

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

131 from it.""" 

132 if argument is None: 

133 return {} 

134 else: 

135 data = { 

136 key: _np.asarray(value) 

137 for key, value in argument.items() 

138 if value is not None 

139 } 

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

141 if len(value) != expected_size: 

142 raise ValueError( 

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

144 ) 

145 return data 

146 

147 self.cell_connectivity = _convert_argument_numpy( 

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

149 ) 

150 self.cell_types = _convert_argument_numpy( 

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

152 ) 

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

154 

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

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

157 

158 def _store_geometry_set_data( 

159 data_field: str, 

160 name: str, 

161 field_vector: _NDArray | None, 

162 expected_size: int, 

163 ) -> None: 

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

165 if field_vector is not None: 

166 if len(field_vector) != expected_size: 

167 raise ValueError( 

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

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

170 ) 

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

172 

173 if geometry_sets is not None: 

174 for geometry_set in geometry_sets: 

175 geometry_set_name = str(geometry_set) 

176 _store_geometry_set_data( 

177 "point_data", 

178 geometry_set_name, 

179 geometry_set.point_flag_vector, 

180 self.n_points, 

181 ) 

182 _store_geometry_set_data( 

183 "cell_data", 

184 geometry_set_name, 

185 geometry_set.cell_flag_vector, 

186 self.n_cells, 

187 ) 

188 

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

190 # performant manner. 

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

192 for i_cell in range(self.n_cells): 

193 last_offset = self.cell_connectivity_offsets[i_cell] 

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

195 raise ValueError( 

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

197 ) 

198 cell_size = self.cell_connectivity[last_offset] 

199 if cell_size < 1: 

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

201 self.cell_connectivity_offsets[i_cell + 1] = ( 

202 cell_size + self.cell_connectivity_offsets[i_cell] + 1 

203 ) 

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

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

206 

207 @property 

208 def n_cells(self) -> int: 

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

210 

211 Returns: 

212 Number of cells in this mesh representation. 

213 """ 

214 return len(self.cell_types) 

215 

216 @property 

217 def n_points(self) -> int: 

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

219 

220 Returns: 

221 Number of points in this mesh representation. 

222 """ 

223 return len(self.points) 

224 

225 def connectivity_iterator(self) -> _Iterable: 

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

227 

228 Returns: 

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

230 """ 

231 for i in range(self.n_cells): 

232 start = self.cell_connectivity_offsets[i] + 1 

233 end = self.cell_connectivity_offsets[i + 1] 

234 yield self.cell_connectivity[start:end] 

235 

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

237 """This method returns an iterator for the given data field and data 

238 name. 

239 

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

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

242 is returned that will always return `None`. 

243 

244 Args: 

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

246 "point_data" or "cell_data". 

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

248 

249 Returns: 

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

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

252 """ 

253 data_dict = getattr(self, data_field) 

254 if data_name in data_dict: 

255 return data_dict[data_name] 

256 else: 

257 match data_field: 

258 case "point_data": 

259 size = self.n_points 

260 case "cell_data": 

261 size = self.n_cells 

262 case _: 

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

264 return _repeat(None, size) 

265 

266 def offset_indices( 

267 self, 

268 element_type_id_offset: int | None = None, 

269 material_offset: int | None = None, 

270 geometry_set_offset: int | None = None, 

271 ) -> None: 

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

273 

274 This is required when merging multiple mesh representations together, 

275 to ensure that there are no index conflicts. 

276 

277 Args: 

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

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

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

281 """ 

282 

283 if element_type_id_offset is not None: 

284 if "element_type_id" in self.cell_data: 

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

286 

287 if material_offset is not None: 

288 if "material_id" in self.cell_data: 

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

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

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

292 material_offset 

293 ) 

294 

295 if geometry_set_offset is not None: 

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

297 new_dict = {} 

298 data_field = getattr(self, field_type) 

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

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

301 info = string_to_geometry_set_info(name) 

302 if info is not None: 

303 new_name = str( 

304 GeometrySetInfo( 

305 geometry_type=info.geometry_type, 

306 i_global=info.i_global + geometry_set_offset, 

307 name=info.name, 

308 ) 

309 ) 

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

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

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

313 

314 

315def merge_mesh_representations( 

316 mesh_representation_a: MeshRepresentation, mesh_representation_b: MeshRepresentation 

317) -> MeshRepresentation: 

318 """Merge two mesh representations. 

319 

320 Args: 

321 mesh_representation_a: First mesh representation. 

322 mesh_representation_b: Second mesh representation. 

323 

324 Returns: 

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

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

327 """ 

328 

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

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

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

332 

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

334 # could be empty as well). 

335 if _is_empty(mesh_representation_a): 

336 return mesh_representation_b 

337 elif _is_empty(mesh_representation_b): 

338 return mesh_representation_a 

339 

340 # Merge the points 

341 merged_points = _np.vstack( 

342 (mesh_representation_a.points, mesh_representation_b.points) 

343 ) 

344 

345 # Merge the connectivity 

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

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

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

349 cell_connectivity_a = mesh_representation_a.cell_connectivity 

350 cell_connectivity_b = mesh_representation_b.cell_connectivity 

351 offsets_b = mesh_representation_b.cell_connectivity_offsets 

352 

353 merged_cell_connectivity = _np.empty( 

354 cell_connectivity_a.size + cell_connectivity_b.size, 

355 dtype=cell_connectivity_a.dtype, 

356 ) 

357 merged_cell_connectivity[: cell_connectivity_a.size] = cell_connectivity_a 

358 merged_cell_connectivity[cell_connectivity_a.size :] = cell_connectivity_b 

359 

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

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

362 

363 merged_cell_connectivity_view_part_b = merged_cell_connectivity[ 

364 cell_connectivity_a.size : 

365 ] 

366 merged_cell_connectivity_view_part_b[mask] += mesh_representation_a.n_points 

367 

368 # Merge the cell types. 

369 merged_cell_types = _np.concatenate( 

370 ( 

371 mesh_representation_a.cell_types, 

372 mesh_representation_b.cell_types, 

373 ) 

374 ) 

375 

376 # Merge the contained data dictionaries. 

377 def _merge_data_dicts( 

378 dict_a: dict[str, _NDArray], 

379 size_a: int, 

380 dict_b: dict[str, _NDArray], 

381 size_b: int, 

382 ) -> dict[str, _NDArray]: 

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

384 

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

386 """Create an empty array that matches the columns of the reference 

387 array and has the given size, i.e., number of rows.""" 

388 new_shape = reference_array.shape 

389 if len(new_shape) == 1: 

390 new_shape = (size,) 

391 elif len(new_shape) == 2: 

392 new_shape = (size, new_shape[1]) 

393 else: 

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

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

396 

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

398 merged_data = {} 

399 for key in all_keys: 

400 data_a = dict_a.get(key, None) 

401 data_b = dict_b.get(key, None) 

402 if data_a is None: 

403 data_a = _ensure_array_size(size_a, data_b) 

404 elif data_b is None: 

405 data_b = _ensure_array_size(size_b, data_a) 

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

407 return merged_data 

408 

409 merged_cell_data = _merge_data_dicts( 

410 mesh_representation_a.cell_data, 

411 mesh_representation_a.n_cells, 

412 mesh_representation_b.cell_data, 

413 mesh_representation_b.n_cells, 

414 ) 

415 merged_point_data = _merge_data_dicts( 

416 mesh_representation_a.point_data, 

417 mesh_representation_a.n_points, 

418 mesh_representation_b.point_data, 

419 mesh_representation_b.n_points, 

420 ) 

421 

422 return MeshRepresentation( 

423 cell_connectivity=merged_cell_connectivity, 

424 cell_types=merged_cell_types, 

425 points=merged_points, 

426 cell_data=merged_cell_data, 

427 point_data=merged_point_data, 

428 )