Coverage for nodimo / collection.py: 100%
253 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-26 03:10 +0000
« 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
5"""
6Collection
7==========
9This module contains the base class for everything that is created with
10a collection of quantities.
12Classes
13-------
14Collection
15 Creates a collection of quantities.
16"""
18from sympy import sstr, latex, S, Number, Matrix, ImmutableDenseMatrix, eye 1abcdef
19from sympy.printing.pretty.stringpict import prettyForm 1abcdef
21from nodimo.dimension import Dimension 1abcdef
22from nodimo.quantity import Quantity, Constant, One 1abcdef
23from nodimo._internal import _show_object, _show_nodimo_warning 1abcdef
26class Collection: 1abcdef
27 """Collection of quantities.
29 This is the base class for all classes created with a collection of
30 quantities.
32 Parameters
33 ----------
34 *quantities : Quantity
35 Quantities that constitute the collection.
37 Attributes
38 ----------
39 quantities : list[Quantity]
40 List with the quantities that constitute the collection.
42 Methods
43 -------
44 show(use_custom_css=True, use_unicode=True)
45 Displays the collection in a pretty format.
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 """
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
60 self._disassembled_quantities: list[Quantity] 1abcdef
61 self._base_quantities: list[Quantity] 1abcdef
63 self._number_constants: list[Constant] 1abcdef
64 self._constants: list[Constant] 1abcdef
66 self._scaling_quantities: list[Quantity] 1abcdef
67 self._nonscaling_quantities: list[Quantity] 1abcdef
69 self._dependent_quantities: list[Quantity] 1abcdef
70 self._independent_quantities: list[Quantity] 1abcdef
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
79 self._set_collection_quantities(*quantities) 1abcdef
80 self._set_collection() 1abcdef
81 self._validate_collection() 1abcdef
82 self._set_collection_dimensions() 1abcdef
84 @property 1abcdef
85 def quantities(self) -> list[Quantity]: 1abcdef
86 return self._quantities 1abcdef
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
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 )
101 self._quantities = list(quantities) 1abcdef
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
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
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 )
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 """
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
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
148 self._dimensions = dimensions 1abcdef
149 self._is_dimensionless = all(dim == 0 for dim in dimensions.values()) 1abcdef
151 def _set_dimensions(self, **dimensions: Number): 1abcdef
152 """Reserved for subclasses that need dimensions setting."""
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
163 for dim in self._dimensions: 1abcdef
164 if dim not in dimension: 1abcdef
165 dimension[dim] = S.Zero 1abcdef
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
171 def _clear_null_dimensions(self): 1abcdef
172 """Removes dimensions with null exponents."""
174 dimensions = {} 1abcdef
175 for dim, exp in self._dimensions.items(): 1abcdef
176 if exp != 0: 1abcdef
177 dimensions[dim] = exp 1abcdef
179 self._dimensions = dimensions 1abcdef
181 def _clear_constants(self, only_numbers: bool = False, only_ones: bool = False): 1abcdef
182 """Removes dimensionless constants.
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 """
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
199 clear_quantities = [] 1abcdef
200 for qty in self._quantities: 1abcdef
201 if qty not in constants: 1abcdef
202 clear_quantities.append(qty) 1abcdef
204 self._quantities = clear_quantities 1abcdef
205 self._set_collection() 1abcdef
207 def _reduce_quantities(self): 1abcdef
208 self._quantities = list(qty.reduce() for qty in self._quantities) 1abcdef
210 def _set_disassembled_quantities(self): 1abcdef
211 """Determines all instances of Quantity, Constant and Power.
213 It does that by disassembling instances of Product.
214 """
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
223 self._disassembled_quantities = list(disassembled_quantities) 1abcdef
225 def _set_base_quantities(self): 1abcdef
226 """Determines all nonnumerical instances of Quantity."""
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
235 if not base_qty._symbolic.is_number and base_qty not in base_quantities: 1abcdef
236 base_quantities.append(base_qty) 1abcdef
238 self._base_quantities = list(base_quantities) 1abcdef
240 def _set_constants(self): 1abcdef
241 """Determines all nonrepetitive instances of Constant."""
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
251 self._number_constants = list(number_constants) 1abcdef
252 self._constants = list(constants) 1abcdef
254 def _set_scaling_quantities(self): 1abcdef
255 """Separates scaling and nonscaling quantities."""
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
265 self._scaling_quantities = list(scaling_quantities) 1abcdef
266 self._nonscaling_quantities = list(nonscaling_quantities) 1abcdef
268 def _set_dependent_quantities(self): 1abcdef
269 """Separates dependent and independent quantities."""
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
279 self._dependent_quantities = list(dependent_quantities) 1abcdef
280 self._independent_quantities = list(independent_quantities) 1abcdef
282 def _set_matrix(self): 1abcdef
283 """Builds basic dimensional matrix."""
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
295 self._raw_matrix = raw_matrix 1abcdef
296 self._matrix = ImmutableDenseMatrix(raw_matrix) 1abcdef
298 def _set_matrix_rank(self): 1abcdef
299 if not hasattr(self, '_matrix'): 1abcdef
300 self._set_matrix() 1abcdef
302 self._rank = self._matrix.rank() 1abcdef
304 def _set_matrix_independent_rows(self): 1abcdef
305 """Independent rows are also independent dimensions.
307 This method also takes the opportunity to fix dimensions set by
308 the user, specially when the dimensions are not all independent.
309 """
311 if not hasattr(self, '_rank'): 1abcdef
312 self._set_matrix_rank() 1abcdef
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
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
338 self._rcef = rcef 1abcdef
339 self._independent_rows = independent_rows 1abcdef
340 self._dimensions = dimensions 1abcdef
341 self._independent_dimensions = independent_dimensions 1abcdef
343 def _set_submatrices(self): 1abcdef
344 """Builds one column matrix for each quantity."""
346 if not hasattr(self, '_matrix'): 1abcdef
347 self._set_matrix() 1abcdef
349 submatrices = [] 1abcdef
350 for i, qty in enumerate(self._quantities): 1abcdef
351 submatrices.append((qty, self._matrix.col(i))) 1abcdef
353 self._submatrices = dict(submatrices) 1abcdef
355 def _get_submatrix(self, *quantities) -> ImmutableDenseMatrix: 1abcdef
356 """Combines the quantities' submatrices into one submatrix.
358 Parameters
359 ----------
360 *quantities : Quantity
361 Quantities used to build the submatrix.
363 Returns
364 -------
365 submatrix : Matrix
366 The submatrix built from the given quantities.
368 Raises
369 ------
370 ValueError
371 If the given quantities are not all part of the collection.
372 """
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
379 submatrices = [self._submatrices[qty] for qty in quantities] 1abcdef
380 submatrix = ImmutableDenseMatrix.hstack(*submatrices) 1abcdef
382 return submatrix 1abcdef
384 def _key(self) -> tuple: 1abcdef
385 return (frozenset(self._quantities),) 1abcdef
387 def __hash__(self) -> int: 1abcdef
388 return hash(self._key()) 1abcdef
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
397 def __contains__(self, item) -> bool: 1abcdef
398 return self._quantities.__contains__(item) 1abcdef
400 def __len__(self) -> int: 1abcdef
401 return self._quantities.__len__() 1abcdef
403 def __iter__(self): 1abcdef
404 return self._quantities.__iter__() 1abcdef
406 def __next__(self): 1abcdef
407 return self.__iter__().__next__() 1abcdef
409 def __str__(self) -> str: 1abcdef
410 return sstr(self) 1abcdef
412 __repr__ = __str__ 1abcdef
414 def __getitem__(self, indexes): 1abcdef
415 return self._quantities[indexes] 1abcdef
417 def _repr_latex_(self): 1abcdef
418 """Latex representation according to IPython/Jupyter."""
420 return f'$\\displaystyle {latex(self)}$' 1abcdef
422 def _sympy_(self): 1abcdef
423 from sympy import sympify 1abcdef
425 return sympify(self._quantities) 1abcdef
427 def _sympyrepr(self, printer) -> str: 1abcdef
428 """Developer string representation according to Sympy."""
430 class_name = type(self).__name__ 1abcdef
431 quantities = ', '.join( 1abcdef
432 printer._print(qty._unreduced) for qty in self._quantities
433 )
435 return f'{class_name}({quantities})' 1abcdef
437 def _sympystr(self, printer) -> str: 1abcdef
438 """User string representation according to Sympy."""
440 class_name = printer._print(type(self).__name__) 1abcdef
441 quantities = ', '.join( 1abcdef
442 printer._print(qty._unreduced) for qty in self._quantities
443 )
445 return f'{class_name}({quantities})' 1abcdef
447 def _latex(self, printer) -> str: 1abcdef
448 """Latex representation according to Sympy."""
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 )
455 return f'{class_name}\\left({quantities}\\right)' 1abcdef
457 def _pretty(self, printer) -> prettyForm: 1abcdef
458 """Pretty representation according to Sympy."""
460 class_name = printer._print(type(self).__name__) 1abcdef
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
470 return prettyForm(*class_name.right(quantities)) 1abcdef