Coverage for src / beamme / mesh_creation_functions / beam_generic.py: 93%

153 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-23 08:08 +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"""Generic function for beam creation.""" 

23 

24from typing import Any as _Any 

25from typing import Callable as _Callable 

26from typing import Type as _Type 

27 

28import numpy as _np 

29 

30from beamme.core.conf import bme as _bme 

31from beamme.core.element_beam import Beam as _Beam 

32from beamme.core.geometry_set import GeometryName as _GeometryName 

33from beamme.core.geometry_set import GeometrySet as _GeometrySet 

34from beamme.core.material import MaterialBeamBase as _MaterialBeamBase 

35from beamme.core.mesh import Mesh as _Mesh 

36from beamme.core.node import NodeCosserat as _NodeCosserat 

37from beamme.core.rotation import Rotation as _Rotation 

38from beamme.utils.nodes import get_single_node as _get_single_node 

39 

40 

41def _get_interval_node_positions_of_elements( 

42 interval: tuple[float, float], 

43 n_el: int | None, 

44 l_el: float | None, 

45 node_positions_of_elements: list[float] | None, 

46 interval_length: float | None, 

47) -> _np.ndarray: 

48 """Get the node positions within the interval [0,1]. 

49 

50 Args: 

51 interval: 

52 Start and end values for interval that will be used to create the 

53 beam filament. 

54 n_el: 

55 Number of equally spaced beam elements along the line. Defaults to 1. 

56 Mutually exclusive with l_el 

57 l_el: 

58 Desired length of beam elements. This requires the option interval_length 

59 to be set. Mutually exclusive with n_el. Be aware, that this length 

60 might not be achieved, if the elements are warped after they are 

61 created. 

62 node_positions_of_elements: 

63 A list of normalized positions (within [0,1] and in ascending order) 

64 that define the boundaries of beam elements along the created curve. 

65 The given values will be mapped to the actual `interval` given as an 

66 argument to this function. These values specify where elements start 

67 and end, additional internal nodes (such as midpoints in higher-order 

68 elements) may be placed automatically. 

69 interval_length: 

70 Approximation of the total length of the interval. Is required when 

71 the option `l_el` is given. 

72 

73 Returns: 

74 Numpy array with the node positions within the interval. 

75 """ 

76 

77 # Check for mutually exclusive parameters 

78 n_given_arguments = sum( 

79 1 

80 for argument in [n_el, l_el, node_positions_of_elements] 

81 if argument is not None 

82 ) 

83 if n_given_arguments == 0: 

84 # No arguments were given, use a single element per default 

85 n_el = 1 

86 elif n_given_arguments > 1: 

87 raise ValueError( 

88 'The arguments "n_el", "l_el" and "node_positions_of_elements" are mutually exclusive' 

89 ) 

90 

91 # Cases where we have equally spaced elements 

92 if n_el is not None or l_el is not None: 

93 if l_el is not None: 

94 # Calculate the number of elements in case a desired element length is provided 

95 if interval_length is None: 

96 raise ValueError( 

97 'The parameter "l_el" requires "interval_length" to be set.' 

98 ) 

99 n_el = max([1, round(interval_length / l_el)]) 

100 elif n_el is None: 

101 raise ValueError("n_el should not be None at this point") 

102 

103 node_positions_of_elements = [i_node / n_el for i_node in range(n_el + 1)] 

104 # A list for the element node positions was provided 

105 elif node_positions_of_elements is not None: 

106 # Check that the given positions are in ascending order and start with 0 and end with 1 

107 for index, value, name in zip([0, -1], [0, 1], ["First", "Last"]): 

108 if not _np.isclose( 

109 value, 

110 node_positions_of_elements[index], 

111 atol=1e-12, 

112 rtol=0.0, 

113 ): 

114 raise ValueError( 

115 f"{name} entry of node_positions_of_elements must be {value}, got {node_positions_of_elements[index]}" 

116 ) 

117 if not all( 

118 x < y 

119 for x, y in zip(node_positions_of_elements, node_positions_of_elements[1:]) 

120 ): 

121 raise ValueError( 

122 f"The given node_positions_of_elements must be in ascending order. Got {node_positions_of_elements}" 

123 ) 

124 else: 

125 raise ValueError( 

126 'One of the parameters "n_el", "l_el" or "node_positions_of_elements" has to be provided.' 

127 ) 

128 

129 return interval[0] + (interval[1] - interval[0]) * _np.asarray( 

130 node_positions_of_elements 

131 ) 

132 

133 

134def _get_interval_nodal_positions( 

135 interval_node_positions_of_elements: _np.ndarray, nodes_create: list[float] 

136) -> tuple[_np.ndarray, _np.ndarray]: 

137 """Return the nodal positions along the interval depending on the element 

138 formulation. 

139 

140 Args: 

141 interval_node_positions_of_elements: 

142 Numpy array with the element boundary node positions within the interval of the filament 

143 nodes_create: 

144 List with the FE parameter coordinates (in the interval [-1,1]) of the 

145 element nodes. 

146 

147 Returns: 

148 evaluation_positions: 

149 Numpy array with the interval positions of all nodes along the beam filament (ordered). 

150 middle_node_flags: 

151 Numpy array with flags that indicate if a node is an element internal node. 

152 """ 

153 

154 if ( 

155 _np.abs(nodes_create[0] + 1.0) > _bme.eps_parameter_space 

156 or _np.abs(nodes_create[-1] - 1.0) > _bme.eps_parameter_space 

157 ): 

158 raise ValueError( 

159 "The first and last entry of nodes_create must be -1 and 1, respectively." 

160 ) 

161 

162 middle_node_coordinates = nodes_create[1:-1] 

163 n_middle_nodes = len(middle_node_coordinates) 

164 n_el = len(interval_node_positions_of_elements) - 1 

165 n_nodes = n_el * n_middle_nodes + (n_el + 1) 

166 

167 evaluation_positions = _np.zeros(n_nodes) 

168 evaluation_positions[:: n_middle_nodes + 1] = interval_node_positions_of_elements 

169 

170 interval_start_positions = interval_node_positions_of_elements[:-1] 

171 interval_end_positions = interval_node_positions_of_elements[1:] 

172 interval_length = interval_end_positions - interval_start_positions 

173 

174 for i in range(n_middle_nodes): 

175 nodes_create_position = 0.5 * (middle_node_coordinates[i] + 1.0) 

176 evaluation_positions[i + 1 :: n_middle_nodes + 1] = ( 

177 interval_start_positions + nodes_create_position * interval_length 

178 ) 

179 

180 middle_node_flags = _np.ones(n_nodes, dtype=bool) 

181 middle_node_flags[:: n_middle_nodes + 1] = False 

182 

183 return evaluation_positions, middle_node_flags 

184 

185 

186def _evaluate_positions_and_rotations( 

187 beam_function: _Callable[[float], tuple[_np.ndarray, _Rotation, float | None]], 

188 evaluation_positions: _np.ndarray, 

189) -> tuple[_np.ndarray, list[_Rotation], _np.ndarray]: 

190 """Evaluate positions, rotations and arc lengths along the filament, also 

191 return a flag indicating middle nodes. 

192 

193 Args: 

194 beam_function: 

195 The `beam_function` has to take one variable s (from `evaluation_positions`) 

196 and return the position, rotation and arc-length along the beam. 

197 evaluation_positions: 

198 Numpy array with the node positions within the interval of the filament. 

199 

200 Returns: 

201 coordinates: 

202 Numpy array with the coordinates of all nodes along the beam. 

203 rotations: 

204 List with the rotations of all nodes along the beam. 

205 arc_lengths: 

206 Numpy array with the arc lengths of all nodes along the beam. 

207 """ 

208 

209 n_nodes = len(evaluation_positions) 

210 coordinates = _np.zeros((n_nodes, 3)) 

211 rotations: list[_Rotation] = [] 

212 arc_lengths = _np.zeros(n_nodes) 

213 

214 for i_node, evaluation_position in enumerate(evaluation_positions): 

215 position, rotation, arc_length = beam_function(evaluation_position) 

216 coordinates[i_node, :] = position 

217 rotations.append(rotation) 

218 arc_lengths[i_node] = arc_length 

219 

220 return coordinates, rotations, arc_lengths 

221 

222 

223def _check_given_node_and_return_relative_twist( 

224 mesh: _Mesh, 

225 node: _NodeCosserat, 

226 position_from_function: _np.ndarray, 

227 rotation_from_function: _Rotation, 

228 name: str, 

229) -> _Rotation | None: 

230 """Perform some checks for given nodes and return relative twist if 

231 necessary. 

232 

233 If the rotations do not match, check if the first basis vector of the triads is the same. If that is the case, a simple relative twist can be applied to ensure that the triad field is continuous. This relative twist can lead to issues if the beam cross-section is not double symmetric. 

234 

235 Args: 

236 mesh: Mesh in which to check if the given nodes already exist. 

237 node: Given node that should be used at the start or end of the beam. 

238 position_from_function: Position at the start or end of the beam as given 

239 by the beam function. 

240 rotation_from_function: Rotation at the start or end of the beam as given 

241 by the beam function. 

242 name: Name of the node ("start" or "end") for better error messages. 

243 

244 Returns: 

245 relative_twist: 

246 If the rotation of the given node does not match with the one from the 

247 function, but the tangent is the same, the relative twist that has to 

248 be applied to the rotation field is returned. If no relative twist is 

249 necessary, None is returned. 

250 """ 

251 

252 if node not in mesh.nodes: 

253 raise ValueError("The given node is not in the current mesh") 

254 

255 if _np.linalg.norm(position_from_function - node.coordinates) > _bme.eps_pos: 

256 raise ValueError( 

257 f"The position of the given {name} node does not match with the position from the function!" 

258 ) 

259 

260 if rotation_from_function == node.rotation: 

261 return None 

262 elif not _bme.allow_beam_rotation: 

263 raise ValueError( 

264 f"The rotation of the given {name} node does not match with the rotation from the function!" 

265 ) 

266 else: 

267 # Evaluate the relative rotation 

268 # First check if the first basis vector is the same 

269 relative_basis_1 = node.rotation.inv() * rotation_from_function * [1, 0, 0] 

270 if _np.linalg.norm(relative_basis_1 - [1, 0, 0]) < _bme.eps_quaternion: 

271 # Calculate the relative rotation 

272 return rotation_from_function.inv() * node.rotation 

273 else: 

274 raise ValueError( 

275 f"The tangent of the {name} node does not match with the given function!" 

276 ) 

277 

278 

279def create_beam_mesh_generic( 

280 mesh: _Mesh, 

281 *, 

282 beam_class: _Type[_Beam], 

283 material: _MaterialBeamBase, 

284 beam_function: _Any, 

285 interval: tuple[float, float], 

286 beam_function_evaluate_positions_and_rotations: bool = False, 

287 n_el: int | None = None, 

288 l_el: float | None = None, 

289 node_positions_of_elements: list[float] | None = None, 

290 interval_length: float | None = None, 

291 set_nodal_arc_length: bool = False, 

292 nodal_arc_length_offset: float | None = None, 

293 start_node: _NodeCosserat | _GeometrySet | None = None, 

294 end_node: _NodeCosserat | _GeometrySet | None = None, 

295 close_beam: bool = False, 

296 vtk_cell_data: dict[str, tuple] | None = None, 

297) -> _GeometryName: 

298 """Generic beam creation function. 

299 

300 Remark for given start and/or end nodes: 

301 If the rotation does not match, but the tangent vector is the same, 

302 the created beams triads are rotated so the physical problem stays 

303 the same (for axi-symmetric beam cross-sections) but the nodes can 

304 be reused. 

305 

306 Args: 

307 mesh: 

308 Mesh that the created beam(s) should be added to. 

309 beam_class: 

310 Class of beam that will be used for this line. 

311 material: 

312 Material for this line. 

313 beam_function: 

314 The beam_function has to return the position along the beam centerline 

315 for any point in the given `interval`. 

316 

317 Usually, the Jacobian of the returned position field should be a unit 

318 vector. Otherwise, the nodes may be spaced in an undesired way. All 

319 standard mesh creation functions fulfill this property. 

320 interval: 

321 Start and end values for interval that will be used to create the 

322 beam. 

323 beam_function_evaluate_positions_and_rotations: 

324 Flag to indicate if the beam_function already provides an efficient 

325 evaluation of all positions and rotations (and arc lengths) at once. 

326 If this is True, the beam_function has to provide a method 

327 `evaluate_positions_and_rotations(evaluation_positions, middle_node_flags)` 

328 that returns the positions, rotations and arc lengths for all given 

329 evaluation positions at once. This can speed up the creation of beams 

330 significantly, especially for complex beam functions. 

331 n_el: 

332 Number of equally spaced beam elements along the line. Defaults to 1. 

333 Mutually exclusive with l_el 

334 l_el: 

335 Desired length of beam elements. This requires the option `interval_length` 

336 to be set. Mutually exclusive with n_el. Be aware, that this length 

337 might not be achieved, if the elements are warped after they are 

338 created. 

339 node_positions_of_elements: 

340 A list of normalized positions (within [0,1] and in ascending order) 

341 that define the boundaries of beam elements along the created curve. 

342 The given values will be mapped to the actual `interval` given as an 

343 argument to this function. These values specify where elements start 

344 and end, additional internal nodes (such as midpoints in higher-order 

345 elements) may be placed automatically. 

346 interval_length: 

347 Approximation of the total length of the interval. Is required when 

348 the option `l_el` is given. 

349 set_nodal_arc_length: 

350 Flag if the arc length along the beam filament is set in the created 

351 nodes. It is ensured that the arc length is consistent with possible 

352 given start/end nodes. 

353 nodal_arc_length_offset: 

354 Offset of the stored nodal arc length w.r.t. to the one generated by 

355 the function. Defaults to 0, the arc length set in the start node, or 

356 the arc length in the end node minus total length (such that the arc 

357 length at the end node matches). 

358 start_node: 

359 Node to use as the first node for this line. Use this if the line 

360 is connected to other lines (angles have to be the same, otherwise 

361 connections should be used). If a geometry set is given, it can 

362 contain one, and one node only. 

363 end_node: 

364 If this is a Node or GeometrySet, the last node of the created beam 

365 is set to that node. 

366 If it is True the created beam is closed within itself. 

367 close_beam: 

368 If it is True the created beam is closed within itself (mutually 

369 exclusive with end_node). 

370 vtk_cell_data: 

371 With this argument, a vtk cell data can be set for the elements 

372 created within this function. This can be used to check which 

373 elements are created by which function. 

374 

375 Returns: 

376 Geometry sets with the 'start' and 'end' node of the curve. Also a 'line' set 

377 with all nodes of the curve. 

378 """ 

379 

380 if close_beam and end_node is not None: 

381 raise ValueError( 

382 'The arguments "close_beam" and "end_node" are mutually exclusive' 

383 ) 

384 

385 if set_nodal_arc_length: 

386 if close_beam: 

387 raise ValueError( 

388 "The flags 'set_nodal_arc_length' and 'close_beam' are mutually exclusive." 

389 ) 

390 elif nodal_arc_length_offset is not None: 

391 raise ValueError( 

392 'Providing the argument "nodal_arc_length_offset" without setting ' 

393 '"set_nodal_arc_length" to True does not make sense.' 

394 ) 

395 

396 # Get element boundary node positions within the given interval 

397 interval_node_positions_of_elements = _get_interval_node_positions_of_elements( 

398 interval, n_el, l_el, node_positions_of_elements, interval_length 

399 ) 

400 n_el = len(interval_node_positions_of_elements) - 1 

401 

402 # Get the nodal positions in the interval for all nodes (depending on the element formulation). 

403 evaluation_positions, middle_node_flags = _get_interval_nodal_positions( 

404 interval_node_positions_of_elements, beam_class.nodes_create 

405 ) 

406 

407 # Evaluate the centerline position and the rotation for all beam nodes 

408 if not beam_function_evaluate_positions_and_rotations: 

409 coordinates, rotations, arc_lengths = _evaluate_positions_and_rotations( 

410 beam_function, evaluation_positions 

411 ) 

412 else: 

413 coordinates, rotations, arc_lengths = ( 

414 beam_function.evaluate_positions_and_rotations( 

415 evaluation_positions, middle_node_flags 

416 ) 

417 ) 

418 

419 # Make sure the material is in the mesh. 

420 mesh.add_material(material) 

421 

422 # Inspect given nodes and get relative twists if necessary 

423 relative_twist_start = None 

424 if start_node is not None: 

425 start_node = _get_single_node(start_node) 

426 relative_twist_start = _check_given_node_and_return_relative_twist( 

427 mesh, start_node, coordinates[0], rotations[0], "start" 

428 ) 

429 

430 # If an end node is given, check what behavior is wanted. 

431 relative_twist_end = None 

432 if end_node is not None: 

433 end_node = _get_single_node(end_node) 

434 relative_twist_end = _check_given_node_and_return_relative_twist( 

435 mesh, end_node, coordinates[-1], rotations[-1], "end" 

436 ) 

437 

438 # Check if a relative twist has to be applied 

439 relative_twist_list = [ 

440 twist 

441 for twist in [relative_twist_start, relative_twist_end] 

442 if twist is not None 

443 ] 

444 if len(relative_twist_list) == 2: 

445 if not relative_twist_list[0] == relative_twist_list[1]: 

446 raise ValueError( 

447 "The relative twist required for the start and end node do not match" 

448 ) 

449 if len(relative_twist_list) > 0: 

450 relative_twist = relative_twist_list[0] 

451 for i_rot, rotation in enumerate(rotations): 

452 rotations[i_rot] = rotation * relative_twist 

453 

454 # Get the start value for the arc length functionality 

455 if set_nodal_arc_length: 

456 if nodal_arc_length_offset is not None: 

457 # Let's use the given value, the later check will detect if this 

458 # does not match the given nodes. 

459 pass 

460 elif start_node is not None and start_node.arc_length is not None: 

461 nodal_arc_length_offset = start_node.arc_length 

462 elif end_node is not None and end_node.arc_length is not None: 

463 nodal_arc_length_offset = end_node.arc_length - arc_lengths[-1] 

464 else: 

465 # Default value 

466 nodal_arc_length_offset = 0.0 

467 arc_lengths += nodal_arc_length_offset 

468 

469 if start_node is not None: 

470 if _np.abs(start_node.arc_length - arc_lengths[0]) > _bme.eps_pos: 

471 raise ValueError( 

472 "The arc length at the start node does not match with " 

473 "the calculated one!" 

474 ) 

475 if end_node is not None: 

476 if _np.abs(end_node.arc_length - arc_lengths[-1]) > _bme.eps_pos: 

477 raise ValueError( 

478 "The arc length at the end node does not match with " 

479 "the calculated one!" 

480 ) 

481 else: 

482 arc_lengths = [None] * len(arc_lengths) 

483 

484 # Create the nodes and add the new ones to the mesh 

485 nodes = [ 

486 _NodeCosserat(pos, rot, is_middle_node=middle_node_flag, arc_length=arc_length) 

487 for pos, rot, arc_length, middle_node_flag in zip( 

488 coordinates, rotations, arc_lengths, middle_node_flags 

489 ) 

490 ] 

491 if start_node is not None: 

492 nodes[0] = start_node 

493 if close_beam: 

494 nodes[-1] = nodes[0] 

495 elif end_node is not None: 

496 nodes[-1] = end_node 

497 start_slice = 1 if start_node is not None else None 

498 end_slice = -1 if end_node is not None or close_beam else None 

499 mesh.nodes.extend(nodes[start_slice:end_slice]) 

500 

501 # Create the beam elements and assign the nodes 

502 nodes_per_element = len(beam_class.nodes_create) 

503 elements: list[_Beam] = [] 

504 for i_el in range(n_el): 

505 beam = beam_class( 

506 material=material, 

507 nodes=nodes[ 

508 i_el * (nodes_per_element - 1) : (i_el + 1) * (nodes_per_element - 1) 

509 + 1 

510 ], 

511 ) 

512 elements.append(beam) 

513 

514 # Set vtk cell data on created elements. 

515 if vtk_cell_data is not None: 

516 for data_name, data_value in vtk_cell_data.items(): 

517 for element in elements: 

518 if data_name in element.vtk_cell_data.keys(): 

519 raise KeyError( 

520 'The cell data "{}" already exists!'.format(data_name) 

521 ) 

522 element.vtk_cell_data[data_name] = data_value 

523 

524 # Add items to the mesh 

525 mesh.elements.extend(elements) 

526 

527 # Set the nodes that are at the beginning and end of line (for search 

528 # of overlapping points) 

529 nodes[0].is_end_node = True 

530 nodes[-1].is_end_node = True 

531 

532 # Create geometry sets that will be returned. 

533 return_set = _GeometryName() 

534 return_set["start"] = _GeometrySet(nodes[0]) 

535 return_set["end"] = _GeometrySet(nodes[-1]) 

536 return_set["line"] = _GeometrySet(elements) 

537 

538 return return_set