Coverage for nodimo / collection.py: 100%

253 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-26 03:10 +0000

1# ┓• Licensed under the MIT License 

2# ┏┓┏┓┏┫┓┏┳┓┏┓ Copyright (c) 2024 Rodrigo Castro 

3# ┛┗┗┛┗┻┗┛┗┗┗┛ https://nodimo.readthedocs.io 

4 

5""" 

6Collection 

7========== 

8 

9This module contains the base class for everything that is created with 

10a collection of quantities. 

11 

12Classes 

13------- 

14Collection 

15 Creates a collection of quantities. 

16""" 

17 

18from sympy import sstr, latex, S, Number, Matrix, ImmutableDenseMatrix, eye 1abcdef

19from sympy.printing.pretty.stringpict import prettyForm 1abcdef

20 

21from nodimo.dimension import Dimension 1abcdef

22from nodimo.quantity import Quantity, Constant, One 1abcdef

23from nodimo._internal import _show_object, _show_nodimo_warning 1abcdef

24 

25 

26class Collection: 1abcdef

27 """Collection of quantities. 

28 

29 This is the base class for all classes created with a collection of 

30 quantities. 

31 

32 Parameters 

33 ---------- 

34 *quantities : Quantity 

35 Quantities that constitute the collection. 

36 

37 Attributes 

38 ---------- 

39 quantities : list[Quantity] 

40 List with the quantities that constitute the collection. 

41 

42 Methods 

43 ------- 

44 show(use_custom_css=True, use_unicode=True) 

45 Displays the collection in a pretty format. 

46 

47 Raises 

48 ------ 

49 TypeError 

50 If inputs to class are not instances of Quantity. 

51 ValueError 

52 If inputs contains different quantities with equal names. 

53 """ 

54 

55 def __init__(self, *quantities: Quantity): 1abcdef

56 self._quantities: list[Quantity] 1abcdef

57 self._dimensions: dict[str, Number] 1abcdef

58 self._is_dimensionless: bool 1abcdef

59 

60 self._disassembled_quantities: list[Quantity] 1abcdef

61 self._base_quantities: list[Quantity] 1abcdef

62 

63 self._number_constants: list[Constant] 1abcdef

64 self._constants: list[Constant] 1abcdef

65 

66 self._scaling_quantities: list[Quantity] 1abcdef

67 self._nonscaling_quantities: list[Quantity] 1abcdef

68 

69 self._dependent_quantities: list[Quantity] 1abcdef

70 self._independent_quantities: list[Quantity] 1abcdef

71 

72 self._raw_matrix: list[list[Number]] 1abcdef

73 self._matrix: ImmutableDenseMatrix 1abcdef

74 self._rank: int 1abcdef

75 self._rcef: ImmutableDenseMatrix 1abcdef

76 self._independent_rows: tuple[int] 1abcdef

77 self._submatrices: dict[Quantity, ImmutableDenseMatrix] 1abcdef

78 

79 self._set_collection_quantities(*quantities) 1abcdef

80 self._set_collection() 1abcdef

81 self._validate_collection() 1abcdef

82 self._set_collection_dimensions() 1abcdef

83 

84 @property 1abcdef

85 def quantities(self) -> list[Quantity]: 1abcdef

86 return self._quantities 1abcdef

87 

88 def show(self, use_custom_css: bool = True, use_unicode: bool = True): 1abcdef

89 _show_object(self, use_custom_css=use_custom_css, use_unicode=use_unicode) 1abcdef

90 

91 def _set_collection_quantities(self, *quantities: Quantity): 1abcdef

92 not_quantities: list = [] 1abcdef

93 for qty in quantities: 1abcdef

94 if not isinstance(qty, Quantity): 1abcdef

95 not_quantities.append(qty) 1abcdef

96 if len(not_quantities) > 0: 1abcdef

97 raise TypeError( 1abcdef

98 f"Non-quantity types given as input ({str(not_quantities)[1:-1]})" 

99 ) 

100 

101 self._quantities = list(quantities) 1abcdef

102 

103 def _set_collection(self): 1abcdef

104 self._reduce_quantities() 1abcdef

105 self._set_constants() 1abcdef

106 self._set_disassembled_quantities() 1abcdef

107 self._set_base_quantities() 1abcdef

108 

109 def _validate_collection(self): 1abcdef

110 repeated_names = [] 1abcdef

111 for i, qty1 in enumerate(self._base_quantities): 1abcdef

112 for qty2 in list(self._base_quantities)[i + 1 :]: 1abcdef

113 repname = qty1.name if qty1.name == qty2.name else None 1abcdef

114 if repname is not None and repname not in repeated_names: 1abcdef

115 repeated_names.append(repname) 1abcdef

116 

117 if len(repeated_names) > 0: 1abcdef

118 raise ValueError( 1abcdef

119 f"A collection can not contain different quantities " 

120 f"with equal names ({str(repeated_names)[1:-1]})" 

121 ) 

122 

123 def _set_collection_dimensions(self): 1abcdef

124 """ 

125 Dimensional exponents are set to NaN, unless all quantities 

126 share the same exponent for a particular dimension, case in 

127 which the exponent value is preserved. 

128 """ 

129 

130 dimensions = {} 1abcdef

131 for qty in self._quantities: 1abcdef

132 for dim in qty.dimension: 1abcdef

133 if dim not in dimensions: 1abcdef

134 dimensions[dim] = S.NaN 1abcdef

135 

136 for dim in dimensions: 1abcdef

137 same_dim = [] 1abcdef

138 for i, qty in enumerate(self._quantities): 1abcdef

139 if dim not in qty.dimension: 1abcdef

140 same_dim.append(False) 1abcdef

141 break 1abcdef

142 if i > 0: 1abcdef

143 same_dim.append(qty.dimension[dim] == qty_ref.dimension[dim]) 1abcdef

144 qty_ref = qty 1abcdef

145 if all(same_dim): 1abcdef

146 dimensions[dim] = qty_ref.dimension[dim] 1abcdef

147 

148 self._dimensions = dimensions 1abcdef

149 self._is_dimensionless = all(dim == 0 for dim in dimensions.values()) 1abcdef

150 

151 def _set_dimensions(self, **dimensions: Number): 1abcdef

152 """Reserved for subclasses that need dimensions setting.""" 

153 

154 # Validate input dimensions in face of the collection's. 

155 dimension = Dimension(**dimensions) 1abcdef

156 if not set(dimension).issubset(set(self._dimensions)): 1abcdef

157 invalid_dimensions = [] 1abcdef

158 for dim in dimension: 1abcdef

159 if dim not in self._dimensions: 1abcdef

160 invalid_dimensions.append(dim) 1abcdef

161 raise ValueError(f"Invalid dimensions ({str(invalid_dimensions)[1:-1]})") 1abcdef

162 

163 for dim in self._dimensions: 1abcdef

164 if dim not in dimension: 1abcdef

165 dimension[dim] = S.Zero 1abcdef

166 

167 self._dimensions = dict(**dimension) 1abcdef

168 self._set_matrix_independent_rows() 1abcdef

169 self._is_dimensionless = all(dim == 0 for dim in self._dimensions.values()) 1abcdef

170 

171 def _clear_null_dimensions(self): 1abcdef

172 """Removes dimensions with null exponents.""" 

173 

174 dimensions = {} 1abcdef

175 for dim, exp in self._dimensions.items(): 1abcdef

176 if exp != 0: 1abcdef

177 dimensions[dim] = exp 1abcdef

178 

179 self._dimensions = dimensions 1abcdef

180 

181 def _clear_constants(self, only_numbers: bool = False, only_ones: bool = False): 1abcdef

182 """Removes dimensionless constants. 

183 

184 Parameters 

185 ---------- 

186 only_numbers : bool, default=False 

187 If ``True``, only numerical constants are removed. 

188 only_ones : bool, default=False 

189 If ``True``, only instances of One are removed. 

190 """ 

191 

192 if only_numbers: 1abcdef

193 constants = self._number_constants 1abcdef

194 elif only_ones: 1abcdef

195 constants = [One()] 1abcdef

196 else: 

197 constants = self._constants 1abcdef

198 

199 clear_quantities = [] 1abcdef

200 for qty in self._quantities: 1abcdef

201 if qty not in constants: 1abcdef

202 clear_quantities.append(qty) 1abcdef

203 

204 self._quantities = clear_quantities 1abcdef

205 self._set_collection() 1abcdef

206 

207 def _reduce_quantities(self): 1abcdef

208 self._quantities = list(qty.reduce() for qty in self._quantities) 1abcdef

209 

210 def _set_disassembled_quantities(self): 1abcdef

211 """Determines all instances of Quantity, Constant and Power. 

212 

213 It does that by disassembling instances of Product. 

214 """ 

215 

216 disassembled_quantities = [] 1abcdef

217 for qty in self._quantities: 1abcdef

218 if qty._is_product: 1abcdef

219 disassembled_quantities.extend(qty.factors) 1abcdef

220 else: 

221 disassembled_quantities.append(qty) 1abcdef

222 

223 self._disassembled_quantities = list(disassembled_quantities) 1abcdef

224 

225 def _set_base_quantities(self): 1abcdef

226 """Determines all nonnumerical instances of Quantity.""" 

227 

228 base_quantities = [] 1abcdef

229 for qty in self._disassembled_quantities: 1abcdef

230 if qty._is_power: 1abcdef

231 base_qty = qty.base 1abcdef

232 else: 

233 base_qty = qty 1abcdef

234 

235 if not base_qty._symbolic.is_number and base_qty not in base_quantities: 1abcdef

236 base_quantities.append(base_qty) 1abcdef

237 

238 self._base_quantities = list(base_quantities) 1abcdef

239 

240 def _set_constants(self): 1abcdef

241 """Determines all nonrepetitive instances of Constant.""" 

242 

243 number_constants = [] 1abcdef

244 constants = [] 1abcdef

245 for qty in self._quantities: 1abcdef

246 if qty._is_constant and qty not in constants: 1abcdef

247 if qty._is_number: 1abcdef

248 number_constants.append(qty) 1abcdef

249 constants.append(qty) 1abcdef

250 

251 self._number_constants = list(number_constants) 1abcdef

252 self._constants = list(constants) 1abcdef

253 

254 def _set_scaling_quantities(self): 1abcdef

255 """Separates scaling and nonscaling quantities.""" 

256 

257 scaling_quantities = [] 1abcdef

258 nonscaling_quantities = [] 1abcdef

259 for qty in self._quantities: 1abcdef

260 if qty.is_scaling: 1abcdef

261 scaling_quantities.append(qty) 1abcdef

262 else: 

263 nonscaling_quantities.append(qty) 1abcdef

264 

265 self._scaling_quantities = list(scaling_quantities) 1abcdef

266 self._nonscaling_quantities = list(nonscaling_quantities) 1abcdef

267 

268 def _set_dependent_quantities(self): 1abcdef

269 """Separates dependent and independent quantities.""" 

270 

271 dependent_quantities = [] 1abcdef

272 independent_quantities = [] 1abcdef

273 for qty in self.quantities: 1abcdef

274 if qty.is_dependent: 1abcdef

275 dependent_quantities.append(qty) 1abcdef

276 else: 

277 independent_quantities.append(qty) 1abcdef

278 

279 self._dependent_quantities = list(dependent_quantities) 1abcdef

280 self._independent_quantities = list(independent_quantities) 1abcdef

281 

282 def _set_matrix(self): 1abcdef

283 """Builds basic dimensional matrix.""" 

284 

285 raw_matrix = [] 1abcdef

286 for dim in self._dimensions: 1abcdef

287 dim_exponents = [] 1abcdef

288 for qty in self._quantities: 1abcdef

289 if dim in qty.dimension: 1abcdef

290 dim_exponents.append(qty.dimension[dim]) 1abcdef

291 else: 

292 dim_exponents.append(S.Zero) 1abcdef

293 raw_matrix.append(dim_exponents) 1abcdef

294 

295 self._raw_matrix = raw_matrix 1abcdef

296 self._matrix = ImmutableDenseMatrix(raw_matrix) 1abcdef

297 

298 def _set_matrix_rank(self): 1abcdef

299 if not hasattr(self, '_matrix'): 1abcdef

300 self._set_matrix() 1abcdef

301 

302 self._rank = self._matrix.rank() 1abcdef

303 

304 def _set_matrix_independent_rows(self): 1abcdef

305 """Independent rows are also independent dimensions. 

306 

307 This method also takes the opportunity to fix dimensions set by 

308 the user, specially when the dimensions are not all independent. 

309 """ 

310 

311 if not hasattr(self, '_rank'): 1abcdef

312 self._set_matrix_rank() 1abcdef

313 

314 if len(self._dimensions) > self._rank and len(self._quantities) > self._rank: 1abcdef

315 # In case the number of dimensions is larger than the rank, 

316 # the dimensions are not all independent. 

317 rref, independent_rows = self._matrix.T.rref() 1abcdef

318 rcef = rref.T[:, : len(self._dimensions)] 1abcdef

319 exponents = rcef @ Matrix(list(self._dimensions.values())) 1abcdef

320 dimensions = dict(zip(self._dimensions, exponents[:])) 1abcdef

321 independent_dimensions = {} 1abcdef

322 for i, dim in enumerate(self._dimensions): 1abcdef

323 if i in independent_rows: 1abcdef

324 independent_dimensions[dim] = self._dimensions[dim] 1abcdef

325 

326 dimensions_str = ', '.join(dim for dim in self._dimensions) 1abcdef

327 indep_dimensions_str = ', '.join(dim for dim in independent_dimensions) 1abcdef

328 _show_nodimo_warning( 1abcdef

329 f"From the dimensions ({dimensions_str}), only " 

330 f"({indep_dimensions_str}) are treated as independent" 

331 ) 

332 else: 

333 rcef = eye(len(self._dimensions)) 1abcdef

334 independent_rows = tuple(range(len(self._dimensions))) 1abcdef

335 dimensions = self._dimensions 1abcdef

336 independent_dimensions = self._dimensions.copy() 1abcdef

337 

338 self._rcef = rcef 1abcdef

339 self._independent_rows = independent_rows 1abcdef

340 self._dimensions = dimensions 1abcdef

341 self._independent_dimensions = independent_dimensions 1abcdef

342 

343 def _set_submatrices(self): 1abcdef

344 """Builds one column matrix for each quantity.""" 

345 

346 if not hasattr(self, '_matrix'): 1abcdef

347 self._set_matrix() 1abcdef

348 

349 submatrices = [] 1abcdef

350 for i, qty in enumerate(self._quantities): 1abcdef

351 submatrices.append((qty, self._matrix.col(i))) 1abcdef

352 

353 self._submatrices = dict(submatrices) 1abcdef

354 

355 def _get_submatrix(self, *quantities) -> ImmutableDenseMatrix: 1abcdef

356 """Combines the quantities' submatrices into one submatrix. 

357 

358 Parameters 

359 ---------- 

360 *quantities : Quantity 

361 Quantities used to build the submatrix. 

362 

363 Returns 

364 ------- 

365 submatrix : Matrix 

366 The submatrix built from the given quantities. 

367 

368 Raises 

369 ------ 

370 ValueError 

371 If the given quantities are not all part of the collection. 

372 """ 

373 

374 if not set(quantities).issubset(set(self._quantities)): 1abcdef

375 raise ValueError(f"'{quantities}' is not a subset of '{self._quantities}'") 1abcdef

376 elif not hasattr(self, '_submatrices'): 1abcdef

377 self._set_submatrices() 1abcdef

378 

379 submatrices = [self._submatrices[qty] for qty in quantities] 1abcdef

380 submatrix = ImmutableDenseMatrix.hstack(*submatrices) 1abcdef

381 

382 return submatrix 1abcdef

383 

384 def _key(self) -> tuple: 1abcdef

385 return (frozenset(self._quantities),) 1abcdef

386 

387 def __hash__(self) -> int: 1abcdef

388 return hash(self._key()) 1abcdef

389 

390 def __eq__(self, other) -> bool: 1abcdef

391 if self is other: 1abcdef

392 return True 1abcdef

393 elif isinstance(other, type(self)): 1abcdef

394 return self._key() == other._key() 1abcdef

395 return False 1abcdef

396 

397 def __contains__(self, item) -> bool: 1abcdef

398 return self._quantities.__contains__(item) 1abcdef

399 

400 def __len__(self) -> int: 1abcdef

401 return self._quantities.__len__() 1abcdef

402 

403 def __iter__(self): 1abcdef

404 return self._quantities.__iter__() 1abcdef

405 

406 def __next__(self): 1abcdef

407 return self.__iter__().__next__() 1abcdef

408 

409 def __str__(self) -> str: 1abcdef

410 return sstr(self) 1abcdef

411 

412 __repr__ = __str__ 1abcdef

413 

414 def __getitem__(self, indexes): 1abcdef

415 return self._quantities[indexes] 1abcdef

416 

417 def _repr_latex_(self): 1abcdef

418 """Latex representation according to IPython/Jupyter.""" 

419 

420 return f'$\\displaystyle {latex(self)}$' 1abcdef

421 

422 def _sympy_(self): 1abcdef

423 from sympy import sympify 1abcdef

424 

425 return sympify(self._quantities) 1abcdef

426 

427 def _sympyrepr(self, printer) -> str: 1abcdef

428 """Developer string representation according to Sympy.""" 

429 

430 class_name = type(self).__name__ 1abcdef

431 quantities = ', '.join( 1abcdef

432 printer._print(qty._unreduced) for qty in self._quantities 

433 ) 

434 

435 return f'{class_name}({quantities})' 1abcdef

436 

437 def _sympystr(self, printer) -> str: 1abcdef

438 """User string representation according to Sympy.""" 

439 

440 class_name = printer._print(type(self).__name__) 1abcdef

441 quantities = ', '.join( 1abcdef

442 printer._print(qty._unreduced) for qty in self._quantities 

443 ) 

444 

445 return f'{class_name}({quantities})' 1abcdef

446 

447 def _latex(self, printer) -> str: 1abcdef

448 """Latex representation according to Sympy.""" 

449 

450 class_name = printer._print(type(self).__name__) 1abcdef

451 quantities = R',\ '.join( 1abcdef

452 printer._print(qty._unreduced) for qty in self._quantities 

453 ) 

454 

455 return f'{class_name}\\left({quantities}\\right)' 1abcdef

456 

457 def _pretty(self, printer) -> prettyForm: 1abcdef

458 """Pretty representation according to Sympy.""" 

459 

460 class_name = printer._print(type(self).__name__) 1abcdef

461 

462 quantities = prettyForm('') 1abcdef

463 for i, qty in enumerate(self._quantities): 1abcdef

464 sep = ', ' if i > 0 else '' 1abcdef

465 quantities = prettyForm( 1abcdef

466 *quantities.right(sep, printer._print(qty._unreduced)) 

467 ) 

468 quantities = prettyForm(*quantities.parens()) 1abcdef

469 

470 return prettyForm(*class_name.right(quantities)) 1abcdef