frequenz-quantities 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,541 @@
1
+ # License: MIT
2
+ # Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3
+
4
+ """Types for holding quantities with units."""
5
+
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from typing import TYPE_CHECKING, Any, NoReturn, Self, overload
11
+
12
+ if TYPE_CHECKING:
13
+ from ._percentage import Percentage
14
+
15
+
16
+ class Quantity:
17
+ """A quantity with a unit.
18
+
19
+ Quantities try to behave like float and are also immutable.
20
+ """
21
+
22
+ _base_value: float
23
+ """The value of this quantity in the base unit."""
24
+
25
+ _exponent_unit_map: dict[int, str] | None = None
26
+ """A mapping from the exponent of the base unit to the unit symbol.
27
+
28
+ If None, this quantity has no unit. None is possible only when using the base
29
+ class. Sub-classes must define this.
30
+ """
31
+
32
+ def __init__(self, value: float, exponent: int = 0) -> None:
33
+ """Initialize a new quantity.
34
+
35
+ Args:
36
+ value: The value of this quantity in a given exponent of the base unit.
37
+ exponent: The exponent of the base unit the given value is in.
38
+ """
39
+ self._base_value = value * 10.0**exponent
40
+
41
+ @classmethod
42
+ def _new(cls, value: float, *, exponent: int = 0) -> Self:
43
+ """Instantiate a new quantity subclass instance.
44
+
45
+ Args:
46
+ value: The value of this quantity in a given exponent of the base unit.
47
+ exponent: The exponent of the base unit the given value is in.
48
+
49
+ Returns:
50
+ A new quantity subclass instance.
51
+ """
52
+ self = cls.__new__(cls)
53
+ self._base_value = value * 10.0**exponent
54
+ return self
55
+
56
+ def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None:
57
+ """Initialize a new subclass of Quantity.
58
+
59
+ Args:
60
+ exponent_unit_map: A mapping from the exponent of the base unit to the unit
61
+ symbol.
62
+
63
+ Raises:
64
+ ValueError: If the given exponent_unit_map does not contain a base unit
65
+ (exponent 0).
66
+ """
67
+ if 0 not in exponent_unit_map:
68
+ raise ValueError("Expected a base unit for the type (for exponent 0)")
69
+ cls._exponent_unit_map = exponent_unit_map
70
+ super().__init_subclass__()
71
+
72
+ _zero_cache: dict[type, Quantity] = {}
73
+ """Cache for zero singletons.
74
+
75
+ This is a workaround for mypy getting confused when using @functools.cache and
76
+ @classmethod combined with returning Self. It believes the resulting type of this
77
+ method is Self and complains that members of the actual class don't exist in Self,
78
+ so we need to implement the cache ourselves.
79
+ """
80
+
81
+ @classmethod
82
+ def zero(cls) -> Self:
83
+ """Return a quantity with value 0.0.
84
+
85
+ Returns:
86
+ A quantity with value 0.0.
87
+ """
88
+ _zero = cls._zero_cache.get(cls, None)
89
+ if _zero is None:
90
+ _zero = cls.__new__(cls)
91
+ _zero._base_value = 0.0
92
+ cls._zero_cache[cls] = _zero
93
+ assert isinstance(_zero, cls)
94
+ return _zero
95
+
96
+ @classmethod
97
+ def from_string(cls, string: str) -> Self:
98
+ """Return a quantity from a string representation.
99
+
100
+ Args:
101
+ string: The string representation of the quantity.
102
+
103
+ Returns:
104
+ A quantity object with the value given in the string.
105
+
106
+ Raises:
107
+ ValueError: If the string does not match the expected format.
108
+
109
+ """
110
+ split_string = string.split(" ")
111
+
112
+ if len(split_string) != 2:
113
+ raise ValueError(
114
+ f"Expected a string of the form 'value unit', got {string}"
115
+ )
116
+
117
+ assert cls._exponent_unit_map is not None
118
+ exp_map = cls._exponent_unit_map
119
+
120
+ for exponent, unit in exp_map.items():
121
+ if unit == split_string[1]:
122
+ instance = cls.__new__(cls)
123
+ try:
124
+ instance._base_value = float(split_string[0]) * 10**exponent
125
+ except ValueError as error:
126
+ raise ValueError(f"Failed to parse string '{string}'.") from error
127
+
128
+ return instance
129
+
130
+ raise ValueError(f"Unknown unit {split_string[1]}")
131
+
132
+ @property
133
+ def base_value(self) -> float:
134
+ """Return the value of this quantity in the base unit.
135
+
136
+ Returns:
137
+ The value of this quantity in the base unit.
138
+ """
139
+ return self._base_value
140
+
141
+ def __round__(self, ndigits: int | None = None) -> Self:
142
+ """Round this quantity to the given number of digits.
143
+
144
+ Args:
145
+ ndigits: The number of digits to round to.
146
+
147
+ Returns:
148
+ The rounded quantity.
149
+ """
150
+ return self._new(round(self._base_value, ndigits))
151
+
152
+ def __pos__(self) -> Self:
153
+ """Return this quantity.
154
+
155
+ Returns:
156
+ This quantity.
157
+ """
158
+ return self
159
+
160
+ def __mod__(self, other: Self) -> Self:
161
+ """Return the remainder of this quantity and another.
162
+
163
+ Args:
164
+ other: The other quantity.
165
+
166
+ Returns:
167
+ The remainder of this quantity and another.
168
+ """
169
+ return self._new(self._base_value % other._base_value)
170
+
171
+ @property
172
+ def base_unit(self) -> str | None:
173
+ """Return the base unit of this quantity.
174
+
175
+ None if this quantity has no unit.
176
+
177
+ Returns:
178
+ The base unit of this quantity.
179
+ """
180
+ if not self._exponent_unit_map:
181
+ return None
182
+ return self._exponent_unit_map[0]
183
+
184
+ def isnan(self) -> bool:
185
+ """Return whether this quantity is NaN.
186
+
187
+ Returns:
188
+ Whether this quantity is NaN.
189
+ """
190
+ return math.isnan(self._base_value)
191
+
192
+ def isinf(self) -> bool:
193
+ """Return whether this quantity is infinite.
194
+
195
+ Returns:
196
+ Whether this quantity is infinite.
197
+ """
198
+ return math.isinf(self._base_value)
199
+
200
+ def isclose(self, other: Self, rel_tol: float = 1e-9, abs_tol: float = 0.0) -> bool:
201
+ """Return whether this quantity is close to another.
202
+
203
+ Args:
204
+ other: The quantity to compare to.
205
+ rel_tol: The relative tolerance.
206
+ abs_tol: The absolute tolerance.
207
+
208
+ Returns:
209
+ Whether this quantity is close to another.
210
+ """
211
+ return math.isclose(
212
+ self._base_value,
213
+ other._base_value, # pylint: disable=protected-access
214
+ rel_tol=rel_tol,
215
+ abs_tol=abs_tol,
216
+ )
217
+
218
+ def __repr__(self) -> str:
219
+ """Return a representation of this quantity.
220
+
221
+ Returns:
222
+ A representation of this quantity.
223
+ """
224
+ return f"{type(self).__name__}(value={self._base_value}, exponent=0)"
225
+
226
+ def __str__(self) -> str:
227
+ """Return a string representation of this quantity.
228
+
229
+ Returns:
230
+ A string representation of this quantity.
231
+ """
232
+ return self.__format__("")
233
+
234
+ # pylint: disable=too-many-branches
235
+ def __format__(self, __format_spec: str) -> str:
236
+ """Return a formatted string representation of this quantity.
237
+
238
+ If specified, must be of this form: `[0].{precision}`. If a 0 is not given, the
239
+ trailing zeros will be omitted. If no precision is given, the default is 3.
240
+
241
+ The returned string will use the unit that will result in the maximum precision,
242
+ based on the magnitude of the value.
243
+
244
+ Example:
245
+ ```python
246
+ from frequenz.quantities import Current
247
+ c = Current.from_amperes(0.2345)
248
+ assert f"{c:.2}" == "234.5 mA"
249
+ c = Current.from_amperes(1.2345)
250
+ assert f"{c:.2}" == "1.23 A"
251
+ c = Current.from_milliamperes(1.2345)
252
+ assert f"{c:.6}" == "1.2345 mA"
253
+ ```
254
+
255
+ Args:
256
+ __format_spec: The format specifier.
257
+
258
+ Returns:
259
+ A string representation of this quantity.
260
+
261
+ Raises:
262
+ ValueError: If the given format specifier is invalid.
263
+ """
264
+ keep_trailing_zeros = False
265
+ if __format_spec != "":
266
+ fspec_parts = __format_spec.split(".")
267
+ if (
268
+ len(fspec_parts) != 2
269
+ or fspec_parts[0] not in ("", "0")
270
+ or not fspec_parts[1].isdigit()
271
+ ):
272
+ raise ValueError(
273
+ "Invalid format specifier. Must be empty or `[0].{precision}`"
274
+ )
275
+ if fspec_parts[0] == "0":
276
+ keep_trailing_zeros = True
277
+ precision = int(fspec_parts[1])
278
+ else:
279
+ precision = 3
280
+ if not self._exponent_unit_map:
281
+ return f"{self._base_value:.{precision}f}"
282
+
283
+ if math.isinf(self._base_value) or math.isnan(self._base_value):
284
+ return f"{self._base_value} {self._exponent_unit_map[0]}"
285
+
286
+ if abs_value := abs(self._base_value):
287
+ precision_pow = 10 ** (precision)
288
+ # Prevent numbers like 999.999999 being rendered as 1000 V
289
+ # instead of 1 kV.
290
+ # This could happen because the str formatting function does
291
+ # rounding as well.
292
+ # This is an imperfect solution that works for _most_ cases.
293
+ # isclose parameters were chosen according to the observed cases
294
+ if math.isclose(abs_value, precision_pow, abs_tol=1e-4, rel_tol=0.01):
295
+ # If the value is close to the precision, round it
296
+ exponent = math.ceil(math.log10(precision_pow))
297
+ else:
298
+ exponent = math.floor(math.log10(abs_value))
299
+ else:
300
+ exponent = 0
301
+
302
+ unit_place = exponent - exponent % 3
303
+ if unit_place < min(self._exponent_unit_map):
304
+ unit = self._exponent_unit_map[min(self._exponent_unit_map.keys())]
305
+ unit_place = min(self._exponent_unit_map)
306
+ elif unit_place > max(self._exponent_unit_map):
307
+ unit = self._exponent_unit_map[max(self._exponent_unit_map.keys())]
308
+ unit_place = max(self._exponent_unit_map)
309
+ else:
310
+ unit = self._exponent_unit_map[unit_place]
311
+
312
+ value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}"
313
+
314
+ if value_str in ("-0", "0"):
315
+ stripped = value_str
316
+ else:
317
+ stripped = value_str.rstrip("0").rstrip(".")
318
+
319
+ if not keep_trailing_zeros:
320
+ value_str = stripped
321
+ unit_str = unit if stripped not in ("-0", "0") else self._exponent_unit_map[0]
322
+ return f"{value_str} {unit_str}"
323
+
324
+ def __add__(self, other: Self) -> Self:
325
+ """Return the sum of this quantity and another.
326
+
327
+ Args:
328
+ other: The other quantity.
329
+
330
+ Returns:
331
+ The sum of this quantity and another.
332
+ """
333
+ if not type(other) is type(self):
334
+ return NotImplemented
335
+ summe = type(self).__new__(type(self))
336
+ summe._base_value = self._base_value + other._base_value
337
+ return summe
338
+
339
+ def __sub__(self, other: Self) -> Self:
340
+ """Return the difference of this quantity and another.
341
+
342
+ Args:
343
+ other: The other quantity.
344
+
345
+ Returns:
346
+ The difference of this quantity and another.
347
+ """
348
+ if not type(other) is type(self):
349
+ return NotImplemented
350
+ difference = type(self).__new__(type(self))
351
+ difference._base_value = self._base_value - other._base_value
352
+ return difference
353
+
354
+ @overload
355
+ def __mul__(self, scalar: float, /) -> Self:
356
+ """Scale this quantity by a scalar.
357
+
358
+ Args:
359
+ scalar: The scalar by which to scale this quantity.
360
+
361
+ Returns:
362
+ The scaled quantity.
363
+ """
364
+
365
+ @overload
366
+ def __mul__(self, percent: Percentage, /) -> Self:
367
+ """Scale this quantity by a percentage.
368
+
369
+ Args:
370
+ percent: The percentage by which to scale this quantity.
371
+
372
+ Returns:
373
+ The scaled quantity.
374
+ """
375
+
376
+ def __mul__(self, value: float | Percentage, /) -> Self:
377
+ """Scale this quantity by a scalar or percentage.
378
+
379
+ Args:
380
+ value: The scalar or percentage by which to scale this quantity.
381
+
382
+ Returns:
383
+ The scaled quantity.
384
+ """
385
+ from ._percentage import Percentage # pylint: disable=import-outside-toplevel
386
+
387
+ match value:
388
+ case float():
389
+ return type(self)._new(self._base_value * value)
390
+ case Percentage():
391
+ return type(self)._new(self._base_value * value.as_fraction())
392
+ case _:
393
+ return NotImplemented
394
+
395
+ @overload
396
+ def __truediv__(self, other: float, /) -> Self:
397
+ """Divide this quantity by a scalar.
398
+
399
+ Args:
400
+ other: The scalar or percentage to divide this quantity by.
401
+
402
+ Returns:
403
+ The divided quantity.
404
+ """
405
+
406
+ @overload
407
+ def __truediv__(self, other: Self, /) -> float:
408
+ """Return the ratio of this quantity to another.
409
+
410
+ Args:
411
+ other: The other quantity.
412
+
413
+ Returns:
414
+ The ratio of this quantity to another.
415
+ """
416
+
417
+ def __truediv__(self, value: float | Self, /) -> Self | float:
418
+ """Divide this quantity by a scalar or another quantity.
419
+
420
+ Args:
421
+ value: The scalar or quantity to divide this quantity by.
422
+
423
+ Returns:
424
+ The divided quantity or the ratio of this quantity to another.
425
+ """
426
+ match value:
427
+ case float():
428
+ return type(self)._new(self._base_value / value)
429
+ case Quantity() if type(value) is type(self):
430
+ return self._base_value / value._base_value
431
+ case _:
432
+ return NotImplemented
433
+
434
+ def __gt__(self, other: Self) -> bool:
435
+ """Return whether this quantity is greater than another.
436
+
437
+ Args:
438
+ other: The other quantity.
439
+
440
+ Returns:
441
+ Whether this quantity is greater than another.
442
+ """
443
+ if not type(other) is type(self):
444
+ return NotImplemented
445
+ return self._base_value > other._base_value
446
+
447
+ def __ge__(self, other: Self) -> bool:
448
+ """Return whether this quantity is greater than or equal to another.
449
+
450
+ Args:
451
+ other: The other quantity.
452
+
453
+ Returns:
454
+ Whether this quantity is greater than or equal to another.
455
+ """
456
+ if not type(other) is type(self):
457
+ return NotImplemented
458
+ return self._base_value >= other._base_value
459
+
460
+ def __lt__(self, other: Self) -> bool:
461
+ """Return whether this quantity is less than another.
462
+
463
+ Args:
464
+ other: The other quantity.
465
+
466
+ Returns:
467
+ Whether this quantity is less than another.
468
+ """
469
+ if not type(other) is type(self):
470
+ return NotImplemented
471
+ return self._base_value < other._base_value
472
+
473
+ def __le__(self, other: Self) -> bool:
474
+ """Return whether this quantity is less than or equal to another.
475
+
476
+ Args:
477
+ other: The other quantity.
478
+
479
+ Returns:
480
+ Whether this quantity is less than or equal to another.
481
+ """
482
+ if not type(other) is type(self):
483
+ return NotImplemented
484
+ return self._base_value <= other._base_value
485
+
486
+ def __eq__(self, other: object) -> bool:
487
+ """Return whether this quantity is equal to another.
488
+
489
+ Args:
490
+ other: The other quantity.
491
+
492
+ Returns:
493
+ Whether this quantity is equal to another.
494
+ """
495
+ if not type(other) is type(self):
496
+ return NotImplemented
497
+ # The above check ensures that both quantities are the exact same type, because
498
+ # `isinstance` returns true for subclasses and superclasses. But the above check
499
+ # doesn't help mypy identify the type of other, so the below line is necessary.
500
+ assert isinstance(other, self.__class__)
501
+ return self._base_value == other._base_value
502
+
503
+ def __neg__(self) -> Self:
504
+ """Return the negation of this quantity.
505
+
506
+ Returns:
507
+ The negation of this quantity.
508
+ """
509
+ negation = type(self).__new__(type(self))
510
+ negation._base_value = -self._base_value
511
+ return negation
512
+
513
+ def __abs__(self) -> Self:
514
+ """Return the absolute value of this quantity.
515
+
516
+ Returns:
517
+ The absolute value of this quantity.
518
+ """
519
+ absolute = type(self).__new__(type(self))
520
+ absolute._base_value = abs(self._base_value)
521
+ return absolute
522
+
523
+
524
+ class NoDefaultConstructible(type):
525
+ """A metaclass that disables the default constructor."""
526
+
527
+ def __call__(cls, *_args: Any, **_kwargs: Any) -> NoReturn:
528
+ """Raise a TypeError when the default constructor is called.
529
+
530
+ Args:
531
+ *_args: ignored positional arguments.
532
+ **_kwargs: ignored keyword arguments.
533
+
534
+ Raises:
535
+ TypeError: Always.
536
+ """
537
+ raise TypeError(
538
+ "Use of default constructor NOT allowed for "
539
+ f"{cls.__module__}.{cls.__qualname__}, "
540
+ f"use one of the `{cls.__name__}.from_*()` methods instead."
541
+ )