Coverage for src / sdynpy / core / sdynpy_coordinate.py: 92%

144 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-11 16:22 +0000

1""" 

2Defines the CoordinateArray, which specifies arrays of node numbers and directions. 

3 

4Coordinates in SDynPy are used to define degrees of freedom. These consist of 

5a node number (which corresponds to a node in a SDynPy Geometry object) and a 

6direction (which corresponds to the local displacement coordinate system of 

7that node in the SDynPy Geometry object). Directions are the translations or 

8rotations about the principal axis, and can be positive or negative. The 

9direction can also be empty for non-directional data. 

10""" 

11 

12""" 

13Copyright 2022 National Technology & Engineering Solutions of Sandia, 

14LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S. 

15Government retains certain rights in this software. 

16 

17This program is free software: you can redistribute it and/or modify 

18it under the terms of the GNU General Public License as published by 

19the Free Software Foundation, either version 3 of the License, or 

20(at your option) any later version. 

21 

22This program is distributed in the hope that it will be useful, 

23but WITHOUT ANY WARRANTY; without even the implied warranty of 

24MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

25GNU General Public License for more details. 

26 

27You should have received a copy of the GNU General Public License 

28along with this program. If not, see <https://www.gnu.org/licenses/>. 

29""" 

30 

31import numpy as np 

32from .sdynpy_array import SdynpyArray 

33import warnings 

34 

35# This maps direction integers to their string counterparts 

36_string_map = {1: 'X+', 2: 'Y+', 3: 'Z+', 4: 'RX+', 5: 'RY+', 6: 'RZ+', 

37 -1: 'X-', -2: 'Y-', -3: 'Z-', -4: 'RX-', -5: 'RY-', -6: 'RZ-', 

38 0: ''} 

39 

40# This maps direction strings to their integer counterparts 

41_direction_map = {'X+': 1, 'X': 1, '+X': 1, 

42 'Y+': 2, 'Y': 2, '+Y': 2, 

43 'Z+': 3, 'Z': 3, '+Z': 3, 

44 'RX+': 4, 'RX': 4, '+RX': 4, 

45 'RY+': 5, 'RY': 5, '+RY': 5, 

46 'RZ+': 6, 'RZ': 6, '+RZ': 6, 

47 'X-': -1, '-X': -1, 

48 'Y-': -2, '-Y': -2, 

49 'Z-': -3, '-Z': -3, 

50 'RX-': -4, '-RX': -4, 

51 'RY-': -5, '-RY': -5, 

52 'RZ-': -6, '-RZ': -6, 

53 '': 0} 

54 

55_map_direction_string_array = np.vectorize(_string_map.get) 

56_map_direction_array = np.vectorize(_direction_map.get) 

57 

58 

59def parse_coordinate_string(coordinate: str): 

60 """ 

61 Parse coordinate string into node and direction integers. 

62 

63 Parameters 

64 ---------- 

65 coordinate : str 

66 String representation of a coordinate, e.g. '101X+' 

67 

68 Returns 

69 ------- 

70 node : int 

71 Integer representing the node number 

72 direction : int 

73 Integer representing the direction, 'X+' = 1, 'Y+' = 2, 'Z+' = 3, 

74 'X-' = -1, 'Y-' = -2, 'Z-' = -3 

75 

76 """ 

77 try: 

78 node = int(''.join(v for v in coordinate if v in '0123456789')) 

79 except ValueError: 

80 warnings.warn('Node ID {:} is not Valid. Defaulting to 0.'.format(''.join(v for v in coordinate if v in '0123456789'))) 

81 node = 0 

82 try: 

83 direction = _direction_map[''.join(v for v in coordinate if not v in '0123456789')] 

84 except KeyError: 

85 warnings.warn('Direction {:} is not Valid. Defaulting to no direction.'.format(''.join(v for v in coordinate if not v in '0123456789'))) 

86 direction = 0 

87 return node, direction 

88 

89 

90parse_coordinate_string_array = np.vectorize(parse_coordinate_string, otypes=('int64', 'int64')) 

91 

92 

93def create_coordinate_string(node: int, direction: int): 

94 """ 

95 Create a string from node and directions integers 

96 

97 Parameters 

98 ---------- 

99 node : int 

100 Node number 

101 direction : int 

102 Integer representing the direction, 'X+' = 1, 'Y+' = 2, 'Z+' = 3, 

103 'X-' = -1, 'Y-' = -2, 'Z-' = -3 

104 

105 Returns 

106 ------- 

107 str 

108 String representation of the coordinate, e.g. '101X+' 

109 

110 """ 

111 return str(node) + _string_map[direction] 

112 

113 

114create_coordinate_string_array = np.vectorize(create_coordinate_string, otypes=['<U4']) 

115 

116 

117class CoordinateArray(SdynpyArray): 

118 """Coordinate information specifying Degrees of Freedom (e.g. 101X+). 

119 

120 Use the coordinate_array helper function to create the array. 

121 """ 

122 

123 data_dtype = [('node', 'uint64'), ('direction', 'int8')] 

124 """Datatype for the underlying numpy structured array""" 

125 

126 def __new__(subtype, shape, buffer=None, offset=0, 

127 strides=None, order=None): 

128 # Create the ndarray instance of our type, given the usual 

129 # ndarray input arguments. This will call the standard 

130 # ndarray constructor, but return an object of our type. 

131 # It also triggers a call to InfoArray.__array_finalize__ 

132 obj = super(CoordinateArray, subtype).__new__(subtype, shape, CoordinateArray.data_dtype, 

133 buffer, offset, strides, 

134 order) 

135 # Finally, we must return the newly created object: 

136 return obj 

137 

138 def string_array(self): 

139 """ 

140 Returns a string array representation of the coordinate array 

141 

142 Returns 

143 ------- 

144 np.ndarray 

145 ndarray of strings representing the CoordinateArray 

146 """ 

147 return create_coordinate_string_array(self.node, self.direction) 

148 

149 def direction_string_array(self): 

150 """ 

151 Returns a string array representation of the direction 

152 

153 Returns 

154 ------- 

155 np.ndarray 

156 ndarray of direction strings representing the CoordinateArray 

157 """ 

158 return _map_direction_string_array(self.direction) 

159 

160 def __repr__(self): 

161 return 'coordinate_array(string_array=\n' + repr(self.string_array()) + ')' 

162 

163 def __str__(self): 

164 return str(self.string_array()) 

165 

166 def __eq__(self, value): 

167 value = np.array(value) 

168# # A string 

169 if np.issubdtype(value.dtype, np.character): 

170 value = coordinate_array(string_array=value) 

171 if value.dtype.names is None: 

172 node_logical = self.node == value[..., 0] 

173 direction_logical = self.direction == value[..., 1] 

174 else: 

175 node_logical = self.node == value['node'] 

176 direction_logical = self.direction == value['direction'] 

177 return np.logical_and(node_logical, direction_logical) 

178 

179 def __ne__(self, value): 

180 return ~self.__eq__(value) 

181 

182 def __abs__(self): 

183 abs_coord = self.copy() 

184 abs_coord.direction = abs(abs_coord.direction) 

185 return abs_coord 

186 

187 def __neg__(self): 

188 neg_coord = self.copy() 

189 neg_coord.direction = -neg_coord.direction 

190 return neg_coord 

191 

192 def __pos__(self): 

193 pos_coord = self.copy() 

194 pos_coord.direction = +pos_coord.direction 

195 return pos_coord 

196 

197 def abs(self): 

198 """Returns a coordinate array with direction signs flipped positive""" 

199 return self.__abs__() 

200 

201 def sign(self): 

202 """Returns the sign on the directions of the CoordinateArray""" 

203 out = np.ones(self.shape) 

204 out[self.direction < 0] = -1 

205 return out 

206 

207 def local_direction(self): 

208 """ 

209 Returns a local direction array 

210 

211 Returns 

212 ------- 

213 local_direction_array : np.ndarray 

214 Returns a (...,3) array where ... is the dimension of the 

215 CoordinateArray. The (...,0), (...,1), and (...,2) indices 

216 represent the x,y,z direction of the local coordinate direction. 

217 For example, a CoordinateArray with direction X- would return 

218 [-1,0,0]. 

219 

220 """ 

221 output = np.zeros(self.shape + (3,)) 

222 signs = self.sign() 

223 indices = abs(self.direction) - 1 

224 if self.ndim > 0: 

225 indices[indices > 2] -= 3 

226 else: 

227 if indices > 2: 

228 indices -= 3 

229 for key, index in np.ndenumerate(indices): 

230 if index != -1: 

231 output[key + (index,)] = signs[key] 

232 return output 

233 

234 def offset_node_ids(self, offset_value): 

235 """ 

236 Returns a copy of the CoordinateArray with the node IDs offset 

237 

238 Parameters 

239 ---------- 

240 offset_value : int 

241 The value to offset the node IDs by. 

242 

243 Returns 

244 ------- 

245 CoordinateArray 

246 

247 """ 

248 output = self.copy() 

249 output.node += offset_value 

250 return output 

251 

252 def find_indices(self,coordinate): 

253 """Finds the indices and signs of the specified coordinates 

254 

255 Parameters 

256 ---------- 

257 coordinate : CoordinateArray 

258 A CoordinateArray containing the coordinates to find. 

259 

260 Returns 

261 ------- 

262 indices : tuple of ndarray 

263 An tuple of arrays of indices into this CoordinateArray for each entry in 

264 `coordinate`, which can be used directly to index this CoordinateArray. 

265 signs : ndarray 

266 An array of sign flips between this CoordinateArray at the indices in 

267 `indices` for each entry in `coordinate` 

268 

269 Raises 

270 ------ 

271 ValueError 

272 If more than one coordinate matches, or no coordinate matches. 

273 """ 

274 indices = np.zeros((self.ndim,)+coordinate.shape,dtype=int) 

275 signs = np.zeros(coordinate.shape,dtype=int) 

276 for check_index,coord in coordinate.ndenumerate(): 

277 index = np.array(np.where(abs(self)==abs(coord))) 

278 if index.shape[-1] == 0: 

279 raise ValueError(f'Coordinate {coordinate} not found in {self}') 

280 if index.shape[-1] > 1: 

281 raise ValueError(f'Coordinate {coordinate} found {index.shape[-1]} times in {self}') 

282 indices[(slice(None),)+check_index] = index[...,0] 

283 signs[check_index] = self[tuple(index)].sign()*coord.sign() 

284 return tuple(indices),signs 

285 

286 @classmethod 

287 def from_matlab_cellstr(cls, cellstr_data): 

288 """ 

289 Creates a CoordinateArray from a matlab cellstring object loaded from 

290 scipy.io.loadmat 

291 

292 Parameters 

293 ---------- 

294 cellstr_data : np.ndarray 

295 Dictionary entry corresponding to a cell string variable in a mat 

296 file loaded from scipy.io.loadmat 

297 

298 Returns 

299 ------- 

300 CoordinateArray 

301 CoordinateArray built from the provided cell string array 

302 

303 """ 

304 str_array = np.empty(cellstr_data.shape, dtype=object) 

305 for key, val in np.ndenumerate(cellstr_data): 

306 str_array[key] = val[0] 

307 return coordinate_array(string_array=str_array) 

308 

309 @classmethod 

310 def from_nodelist(cls, nodes, directions=[1, 2, 3], flatten=True): 

311 """ 

312 Returns a coordinate array with a set of nodes with a set of directions 

313 

314 Parameters 

315 ---------- 

316 nodes : iterable 

317 A list of nodes to create degrees of freedom at 

318 directions : iterable, optional 

319 A list of directions to create for each node. The default is [1,2,3], 

320 which provides the three positive translations (X+, Y+, Z+). 

321 flatten : bool, optional 

322 Specifies that the array should be flattened prior to output. The 

323 default is True. If False, the output will have a dimension one 

324 larger than the input node list due to the added direction 

325 dimension. 

326 

327 Returns 

328 ------- 

329 coordinate_array : CoordinateArray 

330 Array of coordinates with each specified direction defined at each 

331 node. If flatten is false, this array will have shape 

332 nodes.shape + directions.shape. Otherwise, this array will have 

333 shape (nodes.size*directions.size,) 

334 

335 """ 

336 ca = coordinate_array(np.array(nodes)[..., np.newaxis], np.array(directions)) 

337 if flatten: 

338 return ca.flatten() 

339 else: 

340 return ca 

341 

342 

343def coordinate_array(node=None, direction=None, 

344 structured_array=None, 

345 string_array=None, force_broadcast=False): 

346 """ 

347 Creates a coordinate array that specify degrees of freedom. 

348 

349 Creates an array of coordinates that specify degrees of freedom in a test 

350 or analysis. Coordinate arrays can be created using a numpy structured 

351 array or two arrays for node and direction. Multidimensional arrays can 

352 be used. 

353 

354 Parameters 

355 ---------- 

356 node : ndarray 

357 Integer array corresponding to the node ids of the coordinates. Input 

358 will be cast to an integer (i.e. 2.0 -> 2, 1.9 -> 1) 

359 direction : ndarray 

360 Direction corresponding to the coordinate. If a string is passed, it 

361 must consist of a direction (RX, RY, RZ, X, Y, Z) and whether or not it 

362 is positive or negative (+ or -). If no positive or negative value is 

363 given, then positive will be assumed. 

364 structured_array : ndarray (structured) 

365 Alternatively to node and direction, a single numpy structured array 

366 can be passed, which should have names ['node','direction'] 

367 string_array : ndarray 

368 Alternatively to node and direction, a single numpy string array can 

369 be passed into the function, which will be parsed to create the 

370 data. 

371 force_broadcast : bool, optional 

372 Return all combinations of nodes and directions regardless of their 

373 shapes. This will return a flattened array 

374 

375 Returns 

376 ------- 

377 coordinate_array : CoordinateArray 

378 

379 """ 

380 if structured_array is not None: 

381 try: 

382 node = structured_array['node'] 

383 direction = structured_array['direction'] 

384 except (ValueError, TypeError): 

385 raise ValueError( 

386 'structured_array must be numpy.ndarray with dtype names "node" and "direction"') 

387 elif string_array is not None: 

388 string_array = np.array(string_array) 

389 node, direction = parse_coordinate_string_array(string_array) 

390 else: 

391 node = np.array(node) 

392 direction = np.array(direction) 

393 if force_broadcast: 

394 node = np.unique(node) 

395 direction = np.unique(direction) 

396 bc_direction = np.tile(direction, node.size) 

397 bc_node = np.repeat(node, direction.size) 

398 else: 

399 try: 

400 bc_node, bc_direction = np.broadcast_arrays(node, direction) 

401 except ValueError: 

402 raise ValueError('node and direction should be broadcastable to the same shape (node: {:}, direction: {:})'.format( 

403 node.shape, direction.shape)) 

404 

405 # Create the coordinate array 

406 coord_array = CoordinateArray(bc_node.shape) 

407 coord_array.node = bc_node 

408 if not np.issubdtype(direction.dtype.type, np.integer): 

409 bc_direction = _map_direction_array(bc_direction) 

410 coord_array.direction = bc_direction 

411 

412 return coord_array 

413 

414 

415def outer_product(*args): 

416 """ 

417 Returns a CoordinateArray consisting of all combinations of the provided 

418 CoordinateArrays 

419 

420 Parameters 

421 ---------- 

422 *args : CoordinateArray 

423 CoordinateArrays to combine into a single CoordinateArray 

424 

425 Returns 

426 ------- 

427 CoordinateArray 

428 CoordinateArray consisting of combinations of provided CoordinateArrays 

429 

430 """ 

431 ndims = len(args) + 1 

432 expanded_coord_array = [] 

433 for i, array in enumerate(args): 

434 index = tuple([Ellipsis]+[slice(None) if i == j else np.newaxis for j in range(ndims)]) 

435 expanded_coord_array.append(array[index]) 

436 return np.concatenate(np.broadcast_arrays(*expanded_coord_array), axis=-1) 

437 

438 

439def from_matlab_cellstr(cellstr_data): 

440 """ 

441 Creates a CoordinateArray from a matlab cellstring object loaded from 

442 scipy.io.loadmat 

443 

444 Parameters 

445 ---------- 

446 cellstr_data : np.ndarray 

447 Dictionary entry corresponding to a cell string variable in a mat 

448 file loaded from scipy.io.loadmat 

449 

450 Returns 

451 ------- 

452 CoordinateArray 

453 CoordinateArray built from the provided cell string array 

454 

455 """ 

456 return CoordinateArray.from_matlab_cellstr(cellstr_data) 

457 

458 

459load = CoordinateArray.load 

460from_nodelist = CoordinateArray.from_nodelist