Coverage for nodimo / quantity.py: 100%

180 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""" 

6Quantity 

7======== 

8 

9This module contains the class to create a physical quantity. 

10 

11Classes 

12------- 

13Quantity 

14 Creates a quantity. 

15Constant 

16 Creates a dimensionless constant. 

17One 

18 Creates a dimensionless number one. 

19""" 

20 

21from sympy import sstr, srepr, latex, Symbol, Mul, Pow, Number 1abcdef

22from typing import Optional, Union 1abcdef

23 

24from nodimo.dimension import Dimension 1abcdef

25from nodimo._internal import _sympify_number, _unsympify_number, _prettify_name 1abcdef

26 

27 

28class Quantity: 1abcdef

29 """Base class for quantities. 

30 

31 Most basic type of physical quantity, with attributes that are 

32 useful in describing its dimensional properties. 

33 

34 Parameters 

35 ---------- 

36 name : str 

37 Name (symbol) to be used as the quantity representation. 

38 dependent : bool, default=False 

39 If ``True``, the quantity is considered dependent in a relation 

40 of quantities 

41 scaling : bool, default=False 

42 If ``True``, the quantity can be used as scaling parameter. 

43 **dimensions : Number 

44 The dimensions of the quantity given as keyword arguments. 

45 

46 Attributes 

47 ---------- 

48 name : str 

49 Name (symbol) used as the quantity representation. 

50 dimension : Dimension 

51 The quantity dimension. 

52 is_dependent : bool 

53 If ``True``, the quantity is considered dependent in a relation 

54 of quantities 

55 is_scaling : bool 

56 If ``True``, the quantity can be used as scaling parameter. 

57 is_dimensionless : bool 

58 If ``True``, the quantity is dimensionless. 

59 

60 Methods 

61 ------- 

62 reduce() 

63 Turns an unreduced quantity into a reduced quantity. 

64 

65 Raises 

66 ------ 

67 TypeError 

68 If the quantity name is not a string. 

69 ValueError 

70 If the quantity name is invalid. 

71 ValueError 

72 If the quantity is set as both dependent and scaling. 

73 ValueError 

74 If the quantity is set as scaling, but with no dimensions. 

75 

76 Examples 

77 -------- 

78 Considering the dimensions mass ``M``, length ``L`` and time ``T``, 

79 a force ``F`` can be defined as: 

80 

81 >>> from nodimo import Quantity 

82 >>> F = Quantity('F', M=1, L=1, T=-2) 

83 

84 To define a dimensionless quantity ``A`` is sufficient to provide 

85 just its name: 

86 

87 >>> A = Quantity('A') 

88 

89 To use a greek letter in symbolic expressions, just provide its 

90 english representation as the name of the quantity: 

91 

92 >>> a = Quantity('alpha') 

93 """ 

94 

95 _is_power: bool = False 1abcdef

96 _is_product: bool = False 1abcdef

97 _is_derived: bool = False 1abcdef

98 _is_one: bool = False 1abcdef

99 

100 def __init__( 1abcdef

101 self, 

102 name: str, 

103 dependent: bool = False, 

104 scaling: bool = False, 

105 **dimensions: Number, 

106 ): 

107 self._name: str 1abcdef

108 self._dimension: Dimension = Dimension(**dimensions) 1abcdef

109 self._is_dimensionless: bool = self._dimension._is_dimensionless 1abcdef

110 self._is_dependent: bool = bool(dependent) 1abcdef

111 self._is_scaling: bool = bool(scaling) 1abcdef

112 self._symbolic: Union[Symbol, Mul, Pow, Number] 1abcdef

113 self._is_constant: bool = False 1abcdef

114 self._is_number: bool = False 1abcdef

115 self._is_quotient: bool = False 1abcdef

116 self._is_reduced: bool = True 1abcdef

117 self._unreduced: Quantity = self 1abcdef

118 self._base: Quantity 1abcdef

119 self._exponent: Number 1abcdef

120 self._factors: list[Quantity] 1abcdef

121 self._validate_quantity() 1abcdef

122 self._set_quantity_name(name) 1abcdef

123 self._set_symbolic_quantity() 1abcdef

124 

125 @property 1abcdef

126 def name(self) -> str: 1abcdef

127 return self._name 1abcdef

128 

129 @property 1abcdef

130 def dimension(self) -> Dimension: 1abcdef

131 return self._dimension 1abcdef

132 

133 @property 1abcdef

134 def is_dependent(self) -> bool: 1abcdef

135 return self._is_dependent 1abcdef

136 

137 @property 1abcdef

138 def is_scaling(self) -> bool: 1abcdef

139 return self._is_scaling 1abcdef

140 

141 @property 1abcdef

142 def is_dimensionless(self) -> bool: 1abcdef

143 return self._is_dimensionless 1abcdef

144 

145 def _validate_quantity(self): 1abcdef

146 if self._is_dependent and self._is_constant: 1abcdef

147 raise ValueError("A constant can not be dependent") 1abcdef

148 elif self._is_scaling and self._is_constant: 1abcdef

149 raise ValueError("A constant can not be scaling") 1abcdef

150 elif self._is_dependent and self._is_scaling: 1abcdef

151 raise ValueError("A quantity can not be both dependent and scaling") 1abcdef

152 elif self._is_scaling and self._is_dimensionless: 1abcdef

153 raise ValueError("A quantity can not be both scaling and dimensionless") 1abcdef

154 

155 def _set_quantity_name(self, name: str): 1abcdef

156 if not isinstance(name, str): 1abcdef

157 raise TypeError("Quantity name must be a string") 1abcdef

158 elif name.strip() == '': 1abcdef

159 raise ValueError("Invalid quantity name") 1abcdef

160 

161 self._name = name 1abcdef

162 

163 def _set_symbolic_quantity(self): 1abcdef

164 self._symbolic = Symbol(self._name) 1abcdef

165 

166 def _copy(self): 1abcdef

167 qty_copy = eval(srepr(self)) 1abcdef

168 qty_copy._unreduced = self._unreduced 1abcdef

169 return qty_copy 1abcdef

170 

171 def reduce(self): 1abcdef

172 if self._is_reduced: 1abcdef

173 return self 1abcdef

174 elif self._is_power: 1abcdef

175 from nodimo.power import Power 1abcdef

176 

177 base = self.base.reduce() 1abcdef

178 exponent = self.exponent 1abcdef

179 reduced_power = Power( 1abcdef

180 base, 

181 exponent, 

182 name=self._name, 

183 dependent=self._is_dependent, 

184 scaling=self._is_scaling, 

185 ) 

186 reduced_power._unreduced = self 1abcdef

187 return reduced_power 1abcdef

188 elif self._is_product: 1abcdef

189 from nodimo.product import Product 1abcdef

190 

191 factors = [qty.reduce() for qty in self.factors] 1abcdef

192 reduced_product = Product( 1abcdef

193 *factors, 

194 name=self._name, 

195 dependent=self._is_dependent, 

196 scaling=self._is_scaling, 

197 ) 

198 reduced_product._unreduced = self 1abcdef

199 return reduced_product 1abcdef

200 

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

202 return (self._name, frozenset(self._dimension.items())) 1abcdef

203 

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

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

206 

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

208 if self is other: 1abcdef

209 return True 1abcdef

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

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

212 return False 1abcdef

213 

214 def __mul__(self, other): 1abcdef

215 if not isinstance(other, Quantity): 1abcdef

216 raise NotImplementedError(f"{self} and {other} cannot be multiplied") 1abcdef

217 

218 from nodimo.product import Product 1abcdef

219 

220 return Product(self, other) 1abcdef

221 

222 def __pow__(self, exponent: Number): 1abcdef

223 from nodimo.power import Power 1abcdef

224 

225 return Power(self, exponent) 1abcdef

226 

227 def __truediv__(self, other): 1abcdef

228 if not isinstance(other, Quantity): 1abcdef

229 raise NotImplementedError(f"{other} cannot divide {self}") 1abcdef

230 

231 return self * other**-1 1abcdef

232 

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

234 return sstr(self) 1abcdef

235 

236 def _repr_latex_(self): 1abcdef

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

238 

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

240 

241 def _sympy_(self): 1abcdef

242 return self._symbolic 1abcdef

243 

244 def _sympyrepr(self, printer): 1abcdef

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

246 

247 class_name = type(self).__name__ 1abcdef

248 name = f"'{self.name}'" if self.name is not None else '' 1abcdef

249 dimensions = '' 1abcdef

250 if not self._is_dimensionless: 1abcdef

251 dim_exp = [] 1abcdef

252 for dim, exp in self._dimension.items(): 1abcdef

253 exp_ = _unsympify_number(exp) 1abcdef

254 dim_exp.append(f'{dim}={repr(exp_)}') 1abcdef

255 dimensions = f", {', '.join(dim_exp)}" 1abcdef

256 dependent = f', dependent=True' if self._is_dependent else '' 1abcdef

257 scaling = f', scaling=True' if self._is_scaling else '' 1abcdef

258 

259 return f'{class_name}({name}{dimensions}{dependent}{scaling})' 1abcdef

260 

261 def _sympystr(self, printer): 1abcdef

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

263 

264 if self._symbolic.is_number: 1abcdef

265 printer._settings['root_notation'] = True 1abcdef

266 else: 

267 printer._settings['root_notation'] = False 1abcdef

268 

269 return printer._print(self._symbolic) 1abcdef

270 

271 _pretty = _latex = _sympystr 1abcdef

272 __repr__ = __str__ 1abcdef

273 

274 

275# Alias for the class Quantity to facilitate the instantiation. 

276Q = Quantity 1abcdef

277 

278 

279class Constant(Quantity): 1abcdef

280 """Dimensionless constant. 

281 

282 Constants are most commonly just dimensionless numbers. Here, they 

283 can be represented by letters, which are be provided as the value 

284 parameter. If the value is a string of characters, the constant is 

285 printed in bold on the screen to avoid confusion with instances of 

286 Quantity. 

287 

288 Constants were implemented to allow the creation of expressions that 

289 contain dimensionless numbers. In general, these numbers are only 

290 useful in the mathematical equations that represent the model. In 

291 Nodimo, constants are used only for aesthetic purposes, mainly in 

292 product of quantities. 

293 

294 Examples 

295 -------- 

296 * Drag coefficient 

297 

298 Dimensions: mass ``M``, length ``L`` and time ``T``. 

299 

300 Quantities: drag force ``Fd``, fluid density ``rho``, 

301 velocity ``V``, reference area ``A``, ``half`` and 

302 drag coefficient ``Cd``. 

303 

304 In this example, the ``reduce`` parameter set to ``False`` is 

305 important to keep the constant in the denominator of the expression. 

306 

307 >>> from nodimo import Quantity, Power, Product 

308 >>> from nodimo.quantity import Constant 

309 >>> Fd = Quantity('F_D', M=1, L=1, T=-2) 

310 >>> rho = Quantity('rho', M=1, L=-3) 

311 >>> V = Quantity('V', L=1, T=-1) 

312 >>> A = Quantity('A', L=2) 

313 >>> half = Constant(1/2) 

314 >>> half_inv = Power(half, -1, reduce=False) 

315 >>> Cd = Product(Fd, half_inv, rho**-1, V**-2, A**-1, reduce=False) 

316 """ 

317 

318 def __new__(cls, value: Union[str, Number]): 1abcdef

319 converted_value = cls._convert_value(value) 1abcdef

320 if converted_value == 1: 1abcdef

321 return One() 1abcdef

322 return super().__new__(cls) 1abcdef

323 

324 def __init__(self, value: Union[str, Number]): 1abcdef

325 self._constant_name: str 1abcdef

326 converted_value = self._convert_value(value) 1abcdef

327 super().__init__(str(converted_value)) 1abcdef

328 self._set_constant(converted_value) 1abcdef

329 

330 @classmethod 1abcdef

331 def _convert_value(self, value: Union[str, Number]) -> Union[str, Number]: 1abcdef

332 converted_value: Union[str, Number] 

333 try: 1abcdef

334 converted_value = _sympify_number(value) 1abcdef

335 except: 1abcdef

336 if not isinstance(value, str): 1abcdef

337 raise TypeError(f"Constant value must be a string or a number") 1abcdef

338 converted_value = value 1abcdef

339 

340 return converted_value 1abcdef

341 

342 def _set_constant(self, value: Union[str, Number]): 1abcdef

343 self._is_constant = True 1abcdef

344 self._constant_name = str(value) 1abcdef

345 if isinstance(value, str): 1abcdef

346 self._name = _prettify_name(value, bold=True) 1abcdef

347 self._set_symbolic_quantity() 1abcdef

348 else: 

349 self._symbolic = value 1abcdef

350 self._is_number = True 1abcdef

351 if value.is_Rational and not value.is_Integer: 1abcdef

352 self._is_quotient = True 1abcdef

353 elif value.is_Mul: 1abcdef

354 for num in value.args: 1abcdef

355 if num.is_Rational and not num.is_Integer: 1abcdef

356 self._is_quotient = True 1abcdef

357 break 1abcdef

358 elif num.is_Pow and Pow(num, 1).exp < 0: 1abcdef

359 self._is_quotient = True 1abcdef

360 break 1abcdef

361 

362 def _sympyrepr(self, printer): 1abcdef

363 class_name = type(self).__name__ 1abcdef

364 if self._is_number and self._symbolic.is_Number: 1abcdef

365 value = _unsympify_number(self._symbolic) 1abcdef

366 else: 

367 value = f"'{self._constant_name}'" 1abcdef

368 

369 return f'{class_name}({value})' 1abcdef

370 

371 

372class One(Constant): 1abcdef

373 """Dimensionless one.""" 

374 

375 _is_one = True 1abcdef

376 

377 def __new__(cls, *args, **kwargs): 1abcdef

378 return super(Constant, cls).__new__(cls) 1abcdef

379 

380 def __init__(self, *args, **kwargs): 1abcdef

381 super().__init__(1) 1abcdef

382 

383 def _sympyrepr(self, printer): 1abcdef

384 return f'{type(self).__name__}()' 1abcdef