Coverage for src/beamme/mesh_creation_functions/beam_parametric_curve.py: 98%

160 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 file has functions to create a beam from a parametric curve.""" 

23 

24from collections.abc import Callable as _Callable 

25 

26import numpy as _np 

27import scipy.integrate as _integrate 

28from autograd import jacobian as _jacobian 

29from scipy.integrate import quad as _quad 

30from scipy.interpolate import interp1d as _interp1d 

31 

32from beamme.core.element_beam import Beam as _Beam 

33from beamme.core.geometry_set import GeometryName as _GeometryName 

34from beamme.core.material import MaterialBeamBase as _MaterialBeamBase 

35from beamme.core.mesh import Mesh as _Mesh 

36from beamme.core.rotation import Rotation as _Rotation 

37from beamme.core.rotation import smallest_rotation as _smallest_rotation 

38from beamme.mesh_creation_functions.beam_generic import ( 

39 create_beam_mesh_generic as _create_beam_mesh_generic, 

40) 

41 

42 

43class _ArcLengthEvaluation: 

44 """Class to allow evaluation of the arc length S(t) and the inverse mapping t(S). 

45 

46 This class uses precomputed samples to interpolate between arc length values. This 

47 is much more efficient than root finding algorithms and should provide a suitable 

48 accuracy. 

49 """ 

50 

51 def __init__( 

52 self, 

53 function_derivative: _Callable, 

54 interval: tuple[float, float], 

55 n_precomputed_intervals: int = 100, 

56 scipy_integrate: bool = True, 

57 scipy_integrate_points: list[float] | None = None, 

58 method: str = "arc-length", 

59 ) -> None: 

60 """Initialize the arc length evaluator and precomputed samples. 

61 

62 Args: 

63 function_derivative: 

64 Function that returns the tangent vector along the curve for a given 

65 parameter value t. 

66 interval: 

67 Start and end values for the parameter coordinate of the curve, 

68 must be in ascending order. 

69 n_precomputed_intervals: 

70 Number of intervals to use for the pre-computation. More intervals 

71 lead to higher accuracy. 

72 scipy_integrate: 

73 If true, the one single arc length integration is performed with scipy's 

74 quad function. This is an adaptive method and gives good estimates on 

75 subdivisions of the total interval. 

76 scipy_integrate_points: 

77 If scipy_integrate is true, this can be used to provide additional points 

78 for the adaptive integration. This can be useful if there are known points 

79 along the curve where Jacobian has a kink. 

80 method: 

81 Method to use for evaluation of nodal positions along the curve: 

82 - "arc-length": Uniform spacing along the arc-length of the curve. This means 

83 that elements along a curve will have equal length in space. 

84 - "parametric": Uniform spacing along the parameter coordinate of the curve. 

85 This means that elements along a curve will have equal length in parameter 

86 space, but not necessarily in physical space. 

87 - "parametric_consistent_middle_nodes": Same as `parametric`, but element 

88 middle nodes are adjusted to be consistent with the arc-length mapping. 

89 This means that elements along a curve will have equal length in parameter 

90 space, but the middle nodes are placed such that the elements themselves are 

91 not distorted. 

92 """ 

93 if not scipy_integrate and scipy_integrate_points is not None: 

94 raise ValueError( 

95 "scipy_integrate_points cannot be provided if scipy_integrate is False!" 

96 ) 

97 

98 self.function_derivative = function_derivative 

99 self.interval = interval 

100 self.n_precomputed_intervals = n_precomputed_intervals 

101 self.scipy_integrate = scipy_integrate 

102 self.scipy_integrate_points = scipy_integrate_points 

103 self.method = method 

104 

105 self._compute_samples() 

106 self._compute_interpolation_functions() 

107 

108 def _compute_samples(self) -> None: 

109 """Compute the samples for the arc length mapping. 

110 

111 This function computes the arc length S(t) at a set of sample points along the 

112 parameter coordinate t with the accumulative Simpson integration. 

113 """ 

114 if self.scipy_integrate: 

115 ds_dt = lambda t: _np.linalg.norm(self.function_derivative([t])[0]) 

116 integral = _quad( 

117 ds_dt, 

118 self.interval[0], 

119 self.interval[1], 

120 points=self.scipy_integrate_points, 

121 full_output=True, 

122 ) 

123 integral_data = integral[2] 

124 n_segments = integral_data["last"] 

125 

126 # Sort the segments such that they are in ascending order along the 

127 # integration integral. 

128 interval_sort = _np.argsort(integral_data["alist"][:n_segments]) 

129 intervals_integral = integral_data["rlist"][interval_sort] 

130 intervals_left_end = integral_data["alist"][interval_sort] 

131 intervals_right_end = integral_data["blist"][interval_sort] 

132 

133 self.t_grid = _np.zeros(n_segments * self.n_precomputed_intervals + 1) 

134 self.S_grid = _np.zeros(n_segments * self.n_precomputed_intervals + 1) 

135 local_integral_start = 0.0 

136 for i_segment in range(n_segments): 

137 segment_a = intervals_left_end[i_segment] 

138 segment_b = intervals_right_end[i_segment] 

139 

140 global_start_index = i_segment * self.n_precomputed_intervals 

141 global_end_index = (i_segment + 1) * self.n_precomputed_intervals + 1 

142 

143 t_local = _np.linspace( 

144 segment_a, segment_b, self.n_precomputed_intervals + 1 

145 ) 

146 self.t_grid[global_start_index:global_end_index] = t_local 

147 

148 local_integral = intervals_integral[i_segment] 

149 

150 tangents = self.function_derivative(t_local) 

151 ds_at_t_samples = _np.linalg.norm(tangents, axis=1) 

152 S_local = _integrate.cumulative_simpson( 

153 y=ds_at_t_samples, x=t_local, initial=0.0 

154 ) 

155 S_local *= local_integral / S_local[-1] 

156 S_local += local_integral_start 

157 

158 self.S_grid[global_start_index:global_end_index] = S_local 

159 

160 local_integral_start += local_integral 

161 

162 else: 

163 # Uniform grid in t for sampling. 

164 self.t_grid = _np.linspace( 

165 self.interval[0], self.interval[1], self.n_precomputed_intervals + 1 

166 ) 

167 

168 # Evaluate ds at all grid points. 

169 tangents = self.function_derivative(self.t_grid) 

170 ds_at_t_samples = _np.linalg.norm(tangents, axis=1) 

171 

172 self.S_grid = _integrate.cumulative_simpson( 

173 y=ds_at_t_samples, x=self.t_grid, initial=0.0 

174 ) 

175 

176 def _compute_interpolation_functions(self) -> None: 

177 """Setup the interpolation functions for S(t) and t(S).""" 

178 self.S_from_t = _interp1d( 

179 self.t_grid, 

180 self.S_grid, 

181 kind="cubic", 

182 fill_value="extrapolate", 

183 assume_sorted=True, 

184 ) 

185 self.t_from_S = _interp1d( 

186 self.S_grid, 

187 self.t_grid, 

188 kind="cubic", 

189 fill_value="extrapolate", 

190 assume_sorted=True, 

191 ) 

192 

193 def approximate_total_arc_length(self) -> float: 

194 """Approximate the total arc length along the curve. 

195 

196 This value is only needed to choose the number of elements along the curve. 

197 """ 

198 return self.S_grid[-1] 

199 

200 def get_total_arc_length(self) -> float: 

201 """Get the total arc length along the curve. 

202 

203 This function might return a different arc-length than 

204 `approximate_total_arc_length`, if the integral is adaptively refined in 

205 `evaluate_all`. 

206 """ 

207 return self.S_grid[-1] 

208 

209 def evaluate_all( 

210 self, evaluation_points: _np.ndarray, middle_node_flags: _np.ndarray 

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

212 """Evaluate the parameter coordinates corresponding to each node. 

213 

214 Args: 

215 evaluation_points: 

216 Evaluation points in the interval [0, 1]. Depending on the integration method, 

217 this can be in normalized parameter space or arc-length space. 

218 middle_node_flags: 

219 Boolean array indicating which of the evaluation points 

220 are middle nodes (True) and which are nodal points (False). 

221 

222 Returns: 

223 t_evaluate: 

224 Parameter coordinates along the curve for each evaluation point. 

225 S_evaluate: 

226 Arc-length coordinates along the curve for each evaluation point. 

227 """ 

228 # Todo: Check if it makes sense to adaptively refine the arc-length 

229 # integration here. 

230 

231 if self.method == "arc-length": 

232 S_evaluate = evaluation_points * self.get_total_arc_length() 

233 t_evaluate = self.t_from_S(S_evaluate) 

234 

235 elif self.method == "parametric": 

236 interval_length = self.interval[1] - self.interval[0] 

237 t_evaluate = self.interval[0] + evaluation_points * interval_length 

238 S_evaluate = self.S_from_t(t_evaluate) 

239 

240 elif self.method == "parametric_consistent_middle_nodes": 

241 interval_length = self.interval[1] - self.interval[0] 

242 is_nodal_point = ~middle_node_flags 

243 nodal_evaluation_points = evaluation_points[is_nodal_point] 

244 

245 n_el = len(nodal_evaluation_points) - 1 

246 middle_nodes = int(((len(evaluation_points) - 1) / n_el) - 1) 

247 

248 t_evaluate = _np.zeros_like(evaluation_points) 

249 S_evaluate = _np.zeros_like(evaluation_points) 

250 

251 # Evaluate the nodal points based on direct parametric mapping. 

252 t_evaluate[is_nodal_point] = ( 

253 self.interval[0] + nodal_evaluation_points * interval_length 

254 ) 

255 S_evaluate[is_nodal_point] = self.S_from_t(t_evaluate[is_nodal_point]) 

256 

257 # For the middle nodes, do an interpolation in arc-length space. 

258 for i_interval in range(n_el): 

259 eval_a = nodal_evaluation_points[i_interval] 

260 eval_b = nodal_evaluation_points[i_interval + 1] 

261 

262 S_a = S_evaluate[i_interval * (middle_nodes + 1)] 

263 S_b = S_evaluate[(i_interval + 1) * (middle_nodes + 1)] 

264 

265 for i_middle in range(middle_nodes): 

266 index_node = i_interval * (middle_nodes + 1) + i_middle + 1 

267 eval_middle_node = evaluation_points[index_node] 

268 factor = (eval_middle_node - eval_a) / (eval_b - eval_a) 

269 S = S_a + factor * (S_b - S_a) 

270 t_evaluate[index_node] = self.t_from_S(S) 

271 S_evaluate[index_node] = S 

272 

273 else: 

274 raise ValueError(f"Unknown method {self.method} for arc-length evaluation!") 

275 

276 return (t_evaluate, S_evaluate) 

277 

278 

279def create_beam_mesh_parametric_curve( 

280 mesh: _Mesh, 

281 beam_class: type[_Beam], 

282 material: _MaterialBeamBase, 

283 function: _Callable, 

284 interval: tuple[float, float], 

285 *, 

286 output_length: bool | None = False, 

287 function_derivative: _Callable | None = None, 

288 function_rotation: _Callable | None = None, 

289 vectorized: bool = False, 

290 arc_length_integrator_kwargs: dict | None = None, 

291 **kwargs, 

292) -> _GeometryName | tuple[_GeometryName, float]: 

293 """Generate a beam from a parametric curve. 

294 

295 Integration along the beam is performed with scipy, and if the gradient is 

296 not explicitly provided, it is calculated with the numpy wrapper autograd. 

297 

298 Args 

299 ---- 

300 mesh: Mesh 

301 Mesh that the curve will be added to. 

302 beam_class: Beam 

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

304 material: Material 

305 Material for this line. 

306 function: function 

307 3D-parametric curve that represents the beam axis. If only a 2D 

308 point is returned, the triad creation is simplified. If 

309 mathematical functions are used, they have to come from the wrapper 

310 autograd.numpy. 

311 interval: [start end] 

312 Start and end values for the parameter of the curve, must be in ascending 

313 order. 

314 output_length: bool 

315 If this is true, the function returns a tuple containing the created 

316 sets and the total arc length along the integrated function. 

317 function_derivative: function -> R3 

318 Explicitly provide the jacobian of the centerline position. 

319 function_rotation: function -> Rotation 

320 If this argument is given, the triads are computed with this 

321 function, on the same interval as the position function. Must 

322 return a Rotation object. 

323 If no function_rotation is given, the rotation of the first node 

324 is calculated automatically and all subsequent nodal rotations 

325 are calculated based on a smallest rotation mapping onto the curve 

326 tangent vector. 

327 vectorized: 

328 If true, the function and function_derivative are assumed to be 

329 vectorized, i.e., they can take arrays of parameter values and return 

330 arrays of positions/tangents. 

331 arc_length_integrator_kwargs: 

332 Additional arguments for the arc-length integrator. 

333 

334 **kwargs (for all of them look into create_beam_mesh_function) 

335 ---- 

336 n_el: int 

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

338 Mutually exclusive with l_el. 

339 l_el: float 

340 Desired length of beam elements. Mutually exclusive with n_el. 

341 Be aware, that this length might not be achieved, if the elements are 

342 warped after they are created. 

343 

344 Return 

345 ---- 

346 return_set: GeometryName 

347 Set with the 'start' and 'end' node of the curve. Also a 'line' set 

348 with all nodes of the curve. 

349 """ 

350 # Set default values for optional arguments. 

351 if arc_length_integrator_kwargs is None: 

352 arc_length_integrator_kwargs = {} 

353 

354 # To avoid issues with automatic differentiation, we need to ensure that the interval 

355 # values are of type float. 

356 interval_array = _np.asarray(interval, dtype=float) 

357 

358 # Validate interval shape, length and order. 

359 if interval_array.ndim != 1: 

360 raise ValueError( 

361 f"Interval must be a 1D sequence of exactly two values, got array with shape {interval_array.shape}." 

362 ) 

363 if interval_array.size != 2: 

364 raise ValueError( 

365 f"Interval must contain exactly two values, got {interval_array.size}." 

366 ) 

367 if interval_array[0] >= interval_array[1]: 

368 raise ValueError(f"Interval must be in ascending order, got {interval_array}.") 

369 

370 # Ensure that the function can be evaluated for multiple values of t at once. 

371 if not vectorized: 

372 original_function = function 

373 

374 def function(t_array): 

375 """Perform a vectorized evaluation of the function.""" 

376 return _np.array([original_function(t) for t in t_array]) 

377 

378 if function_derivative is not None: 

379 original_function_derivative = function_derivative 

380 else: 

381 # If no function derivative is given, we can use autograd to compute it. We need to make sure that the 

382 # autograd jacobian can also handle vectorized inputs. 

383 original_function_derivative = _jacobian(original_function) 

384 

385 def function_derivative(t_array): 

386 """Perform a vectorized evaluation of the function derivative.""" 

387 return _np.array([original_function_derivative(t) for t in t_array]) 

388 

389 if function_derivative is None: 

390 raise ValueError( 

391 "Function derivative could not be determined! For vectorized inputs, " 

392 "the function derivative must be explicitly provided." 

393 ) 

394 

395 # Check size and type of position function 

396 test_evaluation_of_function = function([interval_array[0]])[0] 

397 

398 if len(test_evaluation_of_function) == 2: 

399 is_3d_curve = False 

400 elif len(test_evaluation_of_function) == 3: 

401 is_3d_curve = True 

402 else: 

403 raise ValueError("Function must return either 2d or 3d curve!") 

404 

405 # Setup the arc length integration object. 

406 arc_length_evaluator = _ArcLengthEvaluation( 

407 function_derivative, interval_array, **arc_length_integrator_kwargs 

408 ) 

409 

410 class _BeamFunctionGenerator: 

411 """This class manages the creation the actual beam nodes and rotations.""" 

412 

413 def __init__( 

414 self, 

415 interval_array: _np.ndarray, 

416 function: _Callable, 

417 function_derivative: _Callable, 

418 function_rotation: _Callable | None, 

419 is_3d_curve: bool, 

420 ): 

421 """Initialize the object and define a starting triad.""" 

422 self.interval_array = interval_array 

423 self.function = function 

424 self.function_derivative = function_derivative 

425 self.function_rotation = function_rotation 

426 self.is_3d_curve = is_3d_curve 

427 

428 if self.is_3d_curve: 

429 r_prime = self.function_derivative([self.interval_array[0]])[0] 

430 if abs(_np.dot(r_prime, [0, 0, 1])) < abs(_np.dot(r_prime, [0, 1, 0])): 

431 t2_temp = [0, 0, 1] 

432 else: 

433 t2_temp = [0, 1, 0] 

434 self.last_created_triad = _Rotation.from_basis(r_prime, t2_temp) 

435 

436 def evaluate_positions_and_rotations( 

437 self, evaluation_positions: _np.ndarray, middle_node_flags: _np.ndarray 

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

439 """This function evaluates the positions and rotations for given node 

440 positions within the interval [0,1]. 

441 

442 Args: 

443 evaluation_positions: 

444 Node positions in the interval [0, 1]. Depending on the integration method, 

445 this can be in normalized parameter space or arc-length space. 

446 middle_node_flags: 

447 Boolean array indicating which of the node positions 

448 are middle nodes (True) and which are inter element nodes (False). 

449 

450 Returns: 

451 coordinates: 

452 Physical coordinates of all nodes points along the curve. 

453 rotations: 

454 Rotations at all nodes points along the curve. 

455 """ 

456 # Get the nodal parameter coordinates and the nodal arc-lengths. 

457 t_evaluate, S_evaluate = arc_length_evaluator.evaluate_all( 

458 evaluation_positions, middle_node_flags 

459 ) 

460 

461 # Position at S. 

462 coordinates = _np.zeros((len(evaluation_positions), 3)) 

463 function_evaluation = self.function(t_evaluate) 

464 if self.is_3d_curve: 

465 coordinates[:, :] = function_evaluation 

466 else: 

467 coordinates[:, :2] = function_evaluation 

468 

469 # Rotation at S. 

470 rotations = [] 

471 if self.function_rotation is not None: 

472 for t in t_evaluate: 

473 rotations.append(self.function_rotation(t)) 

474 

475 else: 

476 tangents = self.function_derivative(t_evaluate) 

477 for r_prime in tangents: 

478 if self.is_3d_curve: 

479 # Create the next triad via the smallest rotation mapping based 

480 # on the last triad. 

481 rot = _smallest_rotation(self.last_created_triad, r_prime) 

482 self.last_created_triad = rot.copy() 

483 else: 

484 # The rotation simplifies in the 2d case. 

485 rot = _Rotation([0, 0, 1], _np.arctan2(r_prime[1], r_prime[0])) 

486 rotations.append(rot) 

487 

488 # Return the needed values for beam creation. 

489 return (coordinates, rotations, S_evaluate) 

490 

491 # Create the beam in the mesh 

492 created_sets = _create_beam_mesh_generic( 

493 mesh, 

494 beam_class=beam_class, 

495 material=material, 

496 beam_function_evaluate_positions_and_rotations=True, 

497 beam_function=_BeamFunctionGenerator( 

498 interval_array, 

499 function, 

500 function_derivative, 

501 function_rotation, 

502 is_3d_curve, 

503 ), 

504 interval=(0.0, 1.0), 

505 interval_length=arc_length_evaluator.approximate_total_arc_length(), 

506 **kwargs, 

507 ) 

508 

509 if output_length: 

510 return (created_sets, arc_length_evaluator.get_total_arc_length()) 

511 else: 

512 return created_sets