From 37f13e026778679a04bcae6868e77a7b114d66e1 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 15:00:56 +0200 Subject: [PATCH 1/6] Import implementation from the SDK v1.0.0rc601 The only difference with the SDK is the files are split to have one per quantity type, to have more manageable files. Also `_NoDefaultConstructible` was renamed to `NoDefaultConstructible`, as it is used outside the module it is defined in (`_quantity.py`). It is not exported in `__init__.py` though. In the future it should probably be moved to some core library. The tests are also imported from the SDK but kept in one file, as there are many tests that use multiple quantity types to compose them. Signed-off-by: Leandro Lucarella --- pyproject.toml | 1 + src/frequenz/quantities/__init__.py | 38 +- src/frequenz/quantities/_current.py | 131 ++++ src/frequenz/quantities/_energy.py | 188 +++++ src/frequenz/quantities/_frequency.py | 115 +++ src/frequenz/quantities/_percentage.py | 66 ++ src/frequenz/quantities/_power.py | 244 ++++++ src/frequenz/quantities/_quantity.py | 512 +++++++++++++ src/frequenz/quantities/_temperature.py | 39 + src/frequenz/quantities/_voltage.py | 148 ++++ tests/test_quantities.py | 936 +++++++++++++++++++++++- 11 files changed, 2389 insertions(+), 29 deletions(-) create mode 100644 src/frequenz/quantities/_current.py create mode 100644 src/frequenz/quantities/_energy.py create mode 100644 src/frequenz/quantities/_frequency.py create mode 100644 src/frequenz/quantities/_percentage.py create mode 100644 src/frequenz/quantities/_power.py create mode 100644 src/frequenz/quantities/_quantity.py create mode 100644 src/frequenz/quantities/_temperature.py create mode 100644 src/frequenz/quantities/_voltage.py diff --git a/pyproject.toml b/pyproject.toml index a357c5f..2d00070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ dev-pytest = [ "pytest-mock == 3.14.0", "pytest-asyncio == 0.23.7", "async-solipsism == 0.6", + "hypothesis == 6.100.2", ] dev = [ "frequenz-quantities[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]", diff --git a/src/frequenz/quantities/__init__.py b/src/frequenz/quantities/__init__.py index 904baea..60dee18 100644 --- a/src/frequenz/quantities/__init__.py +++ b/src/frequenz/quantities/__init__.py @@ -1,25 +1,25 @@ # License: MIT # Copyright © 2024 Frequenz Energy-as-a-Service GmbH -"""Types for holding quantities with units. +"""Types for holding quantities with units.""" -TODO(cookiecutter): Add a more descriptive module description. -""" +from ._current import Current +from ._energy import Energy +from ._frequency import Frequency +from ._percentage import Percentage +from ._power import Power +from ._quantity import Quantity +from ._temperature import Temperature +from ._voltage import Voltage -# TODO(cookiecutter): Remove this function -def delete_me(*, blow_up: bool = False) -> bool: - """Do stuff for demonstration purposes. - - Args: - blow_up: If True, raise an exception. - - Returns: - True if no exception was raised. - - Raises: - RuntimeError: if blow_up is True. - """ - if blow_up: - raise RuntimeError("This function should be removed!") - return True +__all__ = [ + "Current", + "Energy", + "Frequency", + "Percentage", + "Power", + "Quantity", + "Temperature", + "Voltage", +] diff --git a/src/frequenz/quantities/_current.py b/src/frequenz/quantities/_current.py new file mode 100644 index 0000000..62e5294 --- /dev/null +++ b/src/frequenz/quantities/_current.py @@ -0,0 +1,131 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self, overload + +from ._quantity import NoDefaultConstructible, Quantity + +if TYPE_CHECKING: + from ._percentage import Percentage + from ._power import Power + from ._voltage import Voltage + + +class Current( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={ + -3: "mA", + 0: "A", + }, +): + """A current quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_amperes(cls, amperes: float) -> Self: + """Initialize a new current quantity. + + Args: + amperes: The current in amperes. + + Returns: + A new current quantity. + """ + return cls._new(amperes) + + @classmethod + def from_milliamperes(cls, milliamperes: float) -> Self: + """Initialize a new current quantity. + + Args: + milliamperes: The current in milliamperes. + + Returns: + A new current quantity. + """ + return cls._new(milliamperes, exponent=-3) + + def as_amperes(self) -> float: + """Return the current in amperes. + + Returns: + The current in amperes. + """ + return self._base_value + + def as_milliamperes(self) -> float: + """Return the current in milliamperes. + + Returns: + The current in milliamperes. + """ + return self._base_value * 1e3 + + # See comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this current by a scalar. + + Args: + scalar: The scalar by which to scale this current. + + Returns: + The scaled current. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this current by a percentage. + + Args: + percent: The percentage by which to scale this current. + + Returns: + The scaled current. + """ + + @overload + def __mul__(self, other: Voltage, /) -> Power: + """Multiply the current by a voltage to get a power. + + Args: + other: The voltage. + + Returns: + The calculated power. + """ + + def __mul__(self, other: float | Percentage | Voltage, /) -> Self | Power: + """Return a current or power from multiplying this current by the given value. + + Args: + other: The scalar, percentage or voltage to multiply by. + + Returns: + A current or power. + """ + from ._percentage import Percentage # pylint: disable=import-outside-toplevel + from ._power import Power # pylint: disable=import-outside-toplevel + from ._voltage import Voltage # pylint: disable=import-outside-toplevel + + match other: + case float() | Percentage(): + return super().__mul__(other) + case Voltage(): + return Power._new(self._base_value * other._base_value) + case _: + return NotImplemented diff --git a/src/frequenz/quantities/_energy.py b/src/frequenz/quantities/_energy.py new file mode 100644 index 0000000..a0795e9 --- /dev/null +++ b/src/frequenz/quantities/_energy.py @@ -0,0 +1,188 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Self, overload + +from ._quantity import NoDefaultConstructible, Quantity + +if TYPE_CHECKING: + from ._percentage import Percentage + from ._power import Power + + +class Energy( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={ + 0: "Wh", + 3: "kWh", + 6: "MWh", + }, +): + """An energy quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_watt_hours(cls, watt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + watt_hours: The energy in watt hours. + + Returns: + A new energy quantity. + """ + return cls._new(watt_hours) + + @classmethod + def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + kilowatt_hours: The energy in kilowatt hours. + + Returns: + A new energy quantity. + """ + return cls._new(kilowatt_hours, exponent=3) + + @classmethod + def from_megawatt_hours(cls, megawatt_hours: float) -> Self: + """Initialize a new energy quantity. + + Args: + megawatt_hours: The energy in megawatt hours. + + Returns: + A new energy quantity. + """ + return cls._new(megawatt_hours, exponent=6) + + def as_watt_hours(self) -> float: + """Return the energy in watt hours. + + Returns: + The energy in watt hours. + """ + return self._base_value + + def as_kilowatt_hours(self) -> float: + """Return the energy in kilowatt hours. + + Returns: + The energy in kilowatt hours. + """ + return self._base_value / 1e3 + + def as_megawatt_hours(self) -> float: + """Return the energy in megawatt hours. + + Returns: + The energy in megawatt hours. + """ + return self._base_value / 1e6 + + def __mul__(self, other: float | Percentage) -> Self: + """Scale this energy by a percentage. + + Args: + other: The percentage by which to scale this energy. + + Returns: + The scaled energy. + """ + from ._percentage import Percentage # pylint: disable=import-outside-toplevel + + match other: + case float(): + return self._new(self._base_value * other) + case Percentage(): + return self._new(self._base_value * other.as_fraction()) + case _: + return NotImplemented + + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this energy by a scalar. + + Args: + other: The scalar to divide this energy by. + + Returns: + The divided energy. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this energy to another. + + Args: + other: The other energy. + + Returns: + The ratio of this energy to another. + """ + + @overload + def __truediv__(self, duration: timedelta, /) -> Power: + """Return a power from dividing this energy by the given duration. + + Args: + duration: The duration to divide by. + + Returns: + A power from dividing this energy by the given duration. + """ + + @overload + def __truediv__(self, power: Power, /) -> timedelta: + """Return a duration from dividing this energy by the given power. + + Args: + power: The power to divide by. + + Returns: + A duration from dividing this energy by the given power. + """ + + def __truediv__( + self, other: float | Self | timedelta | Power, / + ) -> Self | float | Power | timedelta: + """Return a power or duration from dividing this energy by the given value. + + Args: + other: The scalar, energy, power or duration to divide by. + + Returns: + A power or duration from dividing this energy by the given value. + """ + from ._power import Power # pylint: disable=import-outside-toplevel + + match other: + case float(): + return super().__truediv__(other) + case Energy(): + return self._base_value / other._base_value + case timedelta(): + return Power._new(self._base_value / (other.total_seconds() / 3600.0)) + case Power(): + return timedelta( + seconds=(self._base_value / other._base_value) * 3600.0 + ) + case _: + return NotImplemented diff --git a/src/frequenz/quantities/_frequency.py b/src/frequenz/quantities/_frequency.py new file mode 100644 index 0000000..8c4d11c --- /dev/null +++ b/src/frequenz/quantities/_frequency.py @@ -0,0 +1,115 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from datetime import timedelta +from typing import Self + +from ._quantity import NoDefaultConstructible, Quantity + + +class Frequency( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={0: "Hz", 3: "kHz", 6: "MHz", 9: "GHz"}, +): + """A frequency quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_hertz(cls, hertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + hertz: The frequency in hertz. + + Returns: + A new frequency quantity. + """ + return cls._new(hertz) + + @classmethod + def from_kilohertz(cls, kilohertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + kilohertz: The frequency in kilohertz. + + Returns: + A new frequency quantity. + """ + return cls._new(kilohertz, exponent=3) + + @classmethod + def from_megahertz(cls, megahertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + megahertz: The frequency in megahertz. + + Returns: + A new frequency quantity. + """ + return cls._new(megahertz, exponent=6) + + @classmethod + def from_gigahertz(cls, gigahertz: float) -> Self: + """Initialize a new frequency quantity. + + Args: + gigahertz: The frequency in gigahertz. + + Returns: + A new frequency quantity. + """ + return cls._new(gigahertz, exponent=9) + + def as_hertz(self) -> float: + """Return the frequency in hertz. + + Returns: + The frequency in hertz. + """ + return self._base_value + + def as_kilohertz(self) -> float: + """Return the frequency in kilohertz. + + Returns: + The frequency in kilohertz. + """ + return self._base_value / 1e3 + + def as_megahertz(self) -> float: + """Return the frequency in megahertz. + + Returns: + The frequency in megahertz. + """ + return self._base_value / 1e6 + + def as_gigahertz(self) -> float: + """Return the frequency in gigahertz. + + Returns: + The frequency in gigahertz. + """ + return self._base_value / 1e9 + + def period(self) -> timedelta: + """Return the period of the frequency. + + Returns: + The period of the frequency. + """ + return timedelta(seconds=1.0 / self._base_value) diff --git a/src/frequenz/quantities/_percentage.py b/src/frequenz/quantities/_percentage.py new file mode 100644 index 0000000..e0aa3c4 --- /dev/null +++ b/src/frequenz/quantities/_percentage.py @@ -0,0 +1,66 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from typing import Self + +from ._quantity import NoDefaultConstructible, Quantity + + +class Percentage( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={0: "%"}, +): + """A percentage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_percent(cls, percent: float) -> Self: + """Initialize a new percentage quantity from a percent value. + + Args: + percent: The percent value, normally in the 0.0-100.0 range. + + Returns: + A new percentage quantity. + """ + return cls._new(percent) + + @classmethod + def from_fraction(cls, fraction: float) -> Self: + """Initialize a new percentage quantity from a fraction. + + Args: + fraction: The fraction, normally in the 0.0-1.0 range. + + Returns: + A new percentage quantity. + """ + return cls._new(fraction * 100) + + def as_percent(self) -> float: + """Return this quantity as a percentage. + + Returns: + This quantity as a percentage. + """ + return self._base_value + + def as_fraction(self) -> float: + """Return this quantity as a fraction. + + Returns: + This quantity as a fraction. + """ + return self._base_value / 100 diff --git a/src/frequenz/quantities/_power.py b/src/frequenz/quantities/_power.py new file mode 100644 index 0000000..716682b --- /dev/null +++ b/src/frequenz/quantities/_power.py @@ -0,0 +1,244 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING, Self, overload + +from ._quantity import NoDefaultConstructible, Quantity + +if TYPE_CHECKING: + from ._current import Current + from ._energy import Energy + from ._percentage import Percentage + from ._voltage import Voltage + + +class Power( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={ + -3: "mW", + 0: "W", + 3: "kW", + 6: "MW", + }, +): + """A power quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_watts(cls, watts: float) -> Self: + """Initialize a new power quantity. + + Args: + watts: The power in watts. + + Returns: + A new power quantity. + """ + return cls._new(watts) + + @classmethod + def from_milliwatts(cls, milliwatts: float) -> Self: + """Initialize a new power quantity. + + Args: + milliwatts: The power in milliwatts. + + Returns: + A new power quantity. + """ + return cls._new(milliwatts, exponent=-3) + + @classmethod + def from_kilowatts(cls, kilowatts: float) -> Self: + """Initialize a new power quantity. + + Args: + kilowatts: The power in kilowatts. + + Returns: + A new power quantity. + """ + return cls._new(kilowatts, exponent=3) + + @classmethod + def from_megawatts(cls, megawatts: float) -> Self: + """Initialize a new power quantity. + + Args: + megawatts: The power in megawatts. + + Returns: + A new power quantity. + """ + return cls._new(megawatts, exponent=6) + + def as_watts(self) -> float: + """Return the power in watts. + + Returns: + The power in watts. + """ + return self._base_value + + def as_kilowatts(self) -> float: + """Return the power in kilowatts. + + Returns: + The power in kilowatts. + """ + return self._base_value / 1e3 + + def as_megawatts(self) -> float: + """Return the power in megawatts. + + Returns: + The power in megawatts. + """ + return self._base_value / 1e6 + + # We need the ignore here because otherwise mypy will give this error: + # > Overloaded operator methods can't have wider argument types in overrides + # The problem seems to be when the other type implements an **incompatible** + # __rmul__ method, which is not the case here, so we should be safe. + # Please see this example: + # https://github.com/python/mypy/blob/c26f1297d4f19d2d1124a30efc97caebb8c28616/test-data/unit/check-overloading.test#L4738C1-L4769C55 + # And a discussion in a mypy issue here: + # https://github.com/python/mypy/issues/4985#issuecomment-389692396 + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this power by a scalar. + + Args: + scalar: The scalar by which to scale this power. + + Returns: + The scaled power. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this power by a percentage. + + Args: + percent: The percentage by which to scale this power. + + Returns: + The scaled power. + """ + + @overload + def __mul__(self, other: timedelta, /) -> Energy: + """Return an energy from multiplying this power by the given duration. + + Args: + other: The duration to multiply by. + + Returns: + The calculated energy. + """ + + def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy: + """Return a power or energy from multiplying this power by the given value. + + Args: + other: The scalar, percentage or duration to multiply by. + + Returns: + A power or energy. + """ + from ._energy import Energy # pylint: disable=import-outside-toplevel + from ._percentage import Percentage # pylint: disable=import-outside-toplevel + + match other: + case float() | Percentage(): + return super().__mul__(other) + case timedelta(): + return Energy._new(self._base_value * other.total_seconds() / 3600.0) + case _: + return NotImplemented + + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this power by a scalar. + + Args: + other: The scalar to divide this power by. + + Returns: + The divided power. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this power to another. + + Args: + other: The other power. + + Returns: + The ratio of this power to another. + """ + + @overload + def __truediv__(self, current: Current, /) -> Voltage: + """Return a voltage from dividing this power by the given current. + + Args: + current: The current to divide by. + + Returns: + A voltage from dividing this power by the a current. + """ + + @overload + def __truediv__(self, voltage: Voltage, /) -> Current: + """Return a current from dividing this power by the given voltage. + + Args: + voltage: The voltage to divide by. + + Returns: + A current from dividing this power by a voltage. + """ + + def __truediv__( + self, other: float | Self | Current | Voltage, / + ) -> Self | float | Voltage | Current: + """Return a current or voltage from dividing this power by the given value. + + Args: + other: The scalar, power, current or voltage to divide by. + + Returns: + A current or voltage from dividing this power by the given value. + """ + from ._current import Current # pylint: disable=import-outside-toplevel + from ._voltage import Voltage # pylint: disable=import-outside-toplevel + + match other: + case float(): + return super().__truediv__(other) + case Power(): + return self._base_value / other._base_value + case Current(): + return Voltage._new(self._base_value / other._base_value) + case Voltage(): + return Current._new(self._base_value / other._base_value) + case _: + return NotImplemented diff --git a/src/frequenz/quantities/_quantity.py b/src/frequenz/quantities/_quantity.py new file mode 100644 index 0000000..4b32996 --- /dev/null +++ b/src/frequenz/quantities/_quantity.py @@ -0,0 +1,512 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, NoReturn, Self, overload + +if TYPE_CHECKING: + from ._percentage import Percentage + + +class Quantity: + """A quantity with a unit. + + Quantities try to behave like float and are also immutable. + """ + + _base_value: float + """The value of this quantity in the base unit.""" + + _exponent_unit_map: dict[int, str] | None = None + """A mapping from the exponent of the base unit to the unit symbol. + + If None, this quantity has no unit. None is possible only when using the base + class. Sub-classes must define this. + """ + + def __init__(self, value: float, exponent: int = 0) -> None: + """Initialize a new quantity. + + Args: + value: The value of this quantity in a given exponent of the base unit. + exponent: The exponent of the base unit the given value is in. + """ + self._base_value = value * 10.0**exponent + + @classmethod + def _new(cls, value: float, *, exponent: int = 0) -> Self: + """Instantiate a new quantity subclass instance. + + Args: + value: The value of this quantity in a given exponent of the base unit. + exponent: The exponent of the base unit the given value is in. + + Returns: + A new quantity subclass instance. + """ + self = cls.__new__(cls) + self._base_value = value * 10.0**exponent + return self + + def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None: + """Initialize a new subclass of Quantity. + + Args: + exponent_unit_map: A mapping from the exponent of the base unit to the unit + symbol. + + Raises: + TypeError: If the given exponent_unit_map is not a dict. + ValueError: If the given exponent_unit_map does not contain a base unit + (exponent 0). + """ + if 0 not in exponent_unit_map: + raise ValueError("Expected a base unit for the type (for exponent 0)") + cls._exponent_unit_map = exponent_unit_map + super().__init_subclass__() + + _zero_cache: dict[type, Quantity] = {} + """Cache for zero singletons. + + This is a workaround for mypy getting confused when using @functools.cache and + @classmethod combined with returning Self. It believes the resulting type of this + method is Self and complains that members of the actual class don't exist in Self, + so we need to implement the cache ourselves. + """ + + @classmethod + def zero(cls) -> Self: + """Return a quantity with value 0.0. + + Returns: + A quantity with value 0.0. + """ + _zero = cls._zero_cache.get(cls, None) + if _zero is None: + _zero = cls.__new__(cls) + _zero._base_value = 0.0 + cls._zero_cache[cls] = _zero + assert isinstance(_zero, cls) + return _zero + + @classmethod + def from_string(cls, string: str) -> Self: + """Return a quantity from a string representation. + + Args: + string: The string representation of the quantity. + + Returns: + A quantity object with the value given in the string. + + Raises: + ValueError: If the string does not match the expected format. + + """ + split_string = string.split(" ") + + if len(split_string) != 2: + raise ValueError( + f"Expected a string of the form 'value unit', got {string}" + ) + + assert cls._exponent_unit_map is not None + exp_map = cls._exponent_unit_map + + for exponent, unit in exp_map.items(): + if unit == split_string[1]: + instance = cls.__new__(cls) + try: + instance._base_value = float(split_string[0]) * 10**exponent + except ValueError as error: + raise ValueError(f"Failed to parse string '{string}'.") from error + + return instance + + raise ValueError(f"Unknown unit {split_string[1]}") + + @property + def base_value(self) -> float: + """Return the value of this quantity in the base unit. + + Returns: + The value of this quantity in the base unit. + """ + return self._base_value + + @property + def base_unit(self) -> str | None: + """Return the base unit of this quantity. + + None if this quantity has no unit. + + Returns: + The base unit of this quantity. + """ + if not self._exponent_unit_map: + return None + return self._exponent_unit_map[0] + + def isnan(self) -> bool: + """Return whether this quantity is NaN. + + Returns: + Whether this quantity is NaN. + """ + return math.isnan(self._base_value) + + def isinf(self) -> bool: + """Return whether this quantity is infinite. + + Returns: + Whether this quantity is infinite. + """ + return math.isinf(self._base_value) + + def isclose(self, other: Self, rel_tol: float = 1e-9, abs_tol: float = 0.0) -> bool: + """Return whether this quantity is close to another. + + Args: + other: The quantity to compare to. + rel_tol: The relative tolerance. + abs_tol: The absolute tolerance. + + Returns: + Whether this quantity is close to another. + """ + return math.isclose( + self._base_value, + other._base_value, # pylint: disable=protected-access + rel_tol=rel_tol, + abs_tol=abs_tol, + ) + + def __repr__(self) -> str: + """Return a representation of this quantity. + + Returns: + A representation of this quantity. + """ + return f"{type(self).__name__}(value={self._base_value}, exponent=0)" + + def __str__(self) -> str: + """Return a string representation of this quantity. + + Returns: + A string representation of this quantity. + """ + return self.__format__("") + + # pylint: disable=too-many-branches + def __format__(self, __format_spec: str) -> str: + """Return a formatted string representation of this quantity. + + If specified, must be of this form: `[0].{precision}`. If a 0 is not given, the + trailing zeros will be omitted. If no precision is given, the default is 3. + + The returned string will use the unit that will result in the maximum precision, + based on the magnitude of the value. + + Example: + ```python + from frequenz.quantities import Current + c = Current.from_amperes(0.2345) + assert f"{c:.2}" == "234.5 mA" + c = Current.from_amperes(1.2345) + assert f"{c:.2}" == "1.23 A" + c = Current.from_milliamperes(1.2345) + assert f"{c:.6}" == "1.2345 mA" + ``` + + Args: + __format_spec: The format specifier. + + Returns: + A string representation of this quantity. + + Raises: + ValueError: If the given format specifier is invalid. + """ + keep_trailing_zeros = False + if __format_spec != "": + fspec_parts = __format_spec.split(".") + if ( + len(fspec_parts) != 2 + or fspec_parts[0] not in ("", "0") + or not fspec_parts[1].isdigit() + ): + raise ValueError( + "Invalid format specifier. Must be empty or `[0].{precision}`" + ) + if fspec_parts[0] == "0": + keep_trailing_zeros = True + precision = int(fspec_parts[1]) + else: + precision = 3 + if not self._exponent_unit_map: + return f"{self._base_value:.{precision}f}" + + if math.isinf(self._base_value) or math.isnan(self._base_value): + return f"{self._base_value} {self._exponent_unit_map[0]}" + + if abs_value := abs(self._base_value): + precision_pow = 10 ** (precision) + # Prevent numbers like 999.999999 being rendered as 1000 V + # instead of 1 kV. + # This could happen because the str formatting function does + # rounding as well. + # This is an imperfect solution that works for _most_ cases. + # isclose parameters were chosen according to the observed cases + if math.isclose(abs_value, precision_pow, abs_tol=1e-4, rel_tol=0.01): + # If the value is close to the precision, round it + exponent = math.ceil(math.log10(precision_pow)) + else: + exponent = math.floor(math.log10(abs_value)) + else: + exponent = 0 + + unit_place = exponent - exponent % 3 + if unit_place < min(self._exponent_unit_map): + unit = self._exponent_unit_map[min(self._exponent_unit_map.keys())] + unit_place = min(self._exponent_unit_map) + elif unit_place > max(self._exponent_unit_map): + unit = self._exponent_unit_map[max(self._exponent_unit_map.keys())] + unit_place = max(self._exponent_unit_map) + else: + unit = self._exponent_unit_map[unit_place] + + value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}" + + if value_str in ("-0", "0"): + stripped = value_str + else: + stripped = value_str.rstrip("0").rstrip(".") + + if not keep_trailing_zeros: + value_str = stripped + unit_str = unit if stripped not in ("-0", "0") else self._exponent_unit_map[0] + return f"{value_str} {unit_str}" + + def __add__(self, other: Self) -> Self: + """Return the sum of this quantity and another. + + Args: + other: The other quantity. + + Returns: + The sum of this quantity and another. + """ + if not type(other) is type(self): + return NotImplemented + summe = type(self).__new__(type(self)) + summe._base_value = self._base_value + other._base_value + return summe + + def __sub__(self, other: Self) -> Self: + """Return the difference of this quantity and another. + + Args: + other: The other quantity. + + Returns: + The difference of this quantity and another. + """ + if not type(other) is type(self): + return NotImplemented + difference = type(self).__new__(type(self)) + difference._base_value = self._base_value - other._base_value + return difference + + @overload + def __mul__(self, scalar: float, /) -> Self: + """Scale this quantity by a scalar. + + Args: + scalar: The scalar by which to scale this quantity. + + Returns: + The scaled quantity. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this quantity by a percentage. + + Args: + percent: The percentage by which to scale this quantity. + + Returns: + The scaled quantity. + """ + + def __mul__(self, value: float | Percentage, /) -> Self: + """Scale this quantity by a scalar or percentage. + + Args: + value: The scalar or percentage by which to scale this quantity. + + Returns: + The scaled quantity. + """ + from ._percentage import Percentage # pylint: disable=import-outside-toplevel + + match value: + case float(): + return type(self)._new(self._base_value * value) + case Percentage(): + return type(self)._new(self._base_value * value.as_fraction()) + case _: + return NotImplemented + + @overload + def __truediv__(self, other: float, /) -> Self: + """Divide this quantity by a scalar. + + Args: + other: The scalar or percentage to divide this quantity by. + + Returns: + The divided quantity. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this quantity to another. + + Args: + other: The other quantity. + + Returns: + The ratio of this quantity to another. + """ + + def __truediv__(self, value: float | Self, /) -> Self | float: + """Divide this quantity by a scalar or another quantity. + + Args: + value: The scalar or quantity to divide this quantity by. + + Returns: + The divided quantity or the ratio of this quantity to another. + """ + match value: + case float(): + return type(self)._new(self._base_value / value) + case Quantity() if type(value) is type(self): + return self._base_value / value._base_value + case _: + return NotImplemented + + def __gt__(self, other: Self) -> bool: + """Return whether this quantity is greater than another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is greater than another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value > other._base_value + + def __ge__(self, other: Self) -> bool: + """Return whether this quantity is greater than or equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is greater than or equal to another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value >= other._base_value + + def __lt__(self, other: Self) -> bool: + """Return whether this quantity is less than another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is less than another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value < other._base_value + + def __le__(self, other: Self) -> bool: + """Return whether this quantity is less than or equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is less than or equal to another. + """ + if not type(other) is type(self): + return NotImplemented + return self._base_value <= other._base_value + + def __eq__(self, other: object) -> bool: + """Return whether this quantity is equal to another. + + Args: + other: The other quantity. + + Returns: + Whether this quantity is equal to another. + """ + if not type(other) is type(self): + return NotImplemented + # The above check ensures that both quantities are the exact same type, because + # `isinstance` returns true for subclasses and superclasses. But the above check + # doesn't help mypy identify the type of other, so the below line is necessary. + assert isinstance(other, self.__class__) + return self._base_value == other._base_value + + def __neg__(self) -> Self: + """Return the negation of this quantity. + + Returns: + The negation of this quantity. + """ + negation = type(self).__new__(type(self)) + negation._base_value = -self._base_value + return negation + + def __abs__(self) -> Self: + """Return the absolute value of this quantity. + + Returns: + The absolute value of this quantity. + """ + absolute = type(self).__new__(type(self)) + absolute._base_value = abs(self._base_value) + return absolute + + +class NoDefaultConstructible(type): + """A metaclass that disables the default constructor.""" + + def __call__(cls, *_args: Any, **_kwargs: Any) -> NoReturn: + """Raise a TypeError when the default constructor is called. + + Args: + *_args: ignored positional arguments. + **_kwargs: ignored keyword arguments. + + Raises: + TypeError: Always. + """ + raise TypeError( + "Use of default constructor NOT allowed for " + f"{cls.__module__}.{cls.__qualname__}, " + f"use one of the `{cls.__name__}.from_*()` methods instead." + ) diff --git a/src/frequenz/quantities/_temperature.py b/src/frequenz/quantities/_temperature.py new file mode 100644 index 0000000..361ad1f --- /dev/null +++ b/src/frequenz/quantities/_temperature.py @@ -0,0 +1,39 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from typing import Self + +from ._quantity import NoDefaultConstructible, Quantity + + +class Temperature( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={ + 0: "°C", + }, +): + """A temperature quantity (in degrees Celsius).""" + + @classmethod + def from_celsius(cls, value: float) -> Self: + """Initialize a new temperature quantity. + + Args: + value: The temperature in degrees Celsius. + + Returns: + A new temperature quantity. + """ + return cls._new(value) + + def as_celsius(self) -> float: + """Return the temperature in degrees Celsius. + + Returns: + The temperature in degrees Celsius. + """ + return self._base_value diff --git a/src/frequenz/quantities/_voltage.py b/src/frequenz/quantities/_voltage.py new file mode 100644 index 0000000..ec6452a --- /dev/null +++ b/src/frequenz/quantities/_voltage.py @@ -0,0 +1,148 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Types for holding quantities with units.""" + + +from __future__ import annotations + +from typing import TYPE_CHECKING, Self, overload + +from ._quantity import NoDefaultConstructible, Quantity + +if TYPE_CHECKING: + from ._current import Current + from ._percentage import Percentage + from ._power import Power + + +class Voltage( + Quantity, + metaclass=NoDefaultConstructible, + exponent_unit_map={0: "V", -3: "mV", 3: "kV"}, +): + """A voltage quantity. + + Objects of this type are wrappers around `float` values and are immutable. + + The constructors accept a single `float` value, the `as_*()` methods return a + `float` value, and each of the arithmetic operators supported by this type are + actually implemented using floating-point arithmetic. + + So all considerations about floating-point arithmetic apply to this type as well. + """ + + @classmethod + def from_volts(cls, volts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + volts: The voltage in volts. + + Returns: + A new voltage quantity. + """ + return cls._new(volts) + + @classmethod + def from_millivolts(cls, millivolts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + millivolts: The voltage in millivolts. + + Returns: + A new voltage quantity. + """ + return cls._new(millivolts, exponent=-3) + + @classmethod + def from_kilovolts(cls, kilovolts: float) -> Self: + """Initialize a new voltage quantity. + + Args: + kilovolts: The voltage in kilovolts. + + Returns: + A new voltage quantity. + """ + return cls._new(kilovolts, exponent=3) + + def as_volts(self) -> float: + """Return the voltage in volts. + + Returns: + The voltage in volts. + """ + return self._base_value + + def as_millivolts(self) -> float: + """Return the voltage in millivolts. + + Returns: + The voltage in millivolts. + """ + return self._base_value * 1e3 + + def as_kilovolts(self) -> float: + """Return the voltage in kilovolts. + + Returns: + The voltage in kilovolts. + """ + return self._base_value / 1e3 + + # See comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __mul__(self, scalar: float, /) -> Self: + """Scale this voltage by a scalar. + + Args: + scalar: The scalar by which to scale this voltage. + + Returns: + The scaled voltage. + """ + + @overload + def __mul__(self, percent: Percentage, /) -> Self: + """Scale this voltage by a percentage. + + Args: + percent: The percentage by which to scale this voltage. + + Returns: + The scaled voltage. + """ + + @overload + def __mul__(self, other: Current, /) -> Power: + """Multiply the voltage by the current to get the power. + + Args: + other: The current to multiply the voltage with. + + Returns: + The calculated power. + """ + + def __mul__(self, other: float | Percentage | Current, /) -> Self | Power: + """Return a voltage or power from multiplying this voltage by the given value. + + Args: + other: The scalar, percentage or current to multiply by. + + Returns: + The calculated voltage or power. + """ + from ._current import Current # pylint: disable=import-outside-toplevel + from ._percentage import Percentage # pylint: disable=import-outside-toplevel + from ._power import Power # pylint: disable=import-outside-toplevel + + match other: + case float() | Percentage(): + return super().__mul__(other) + case Current(): + return Power._new(self._base_value * other._base_value) + case _: + return NotImplemented diff --git a/tests/test_quantities.py b/tests/test_quantities.py index 547a0af..7b69897 100644 --- a/tests/test_quantities.py +++ b/tests/test_quantities.py @@ -1,18 +1,934 @@ # License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH -"""Tests for the frequenz.quantities package.""" +"""Tests for quantity types.""" + +import inspect +from datetime import timedelta +from typing import Callable + +import hypothesis import pytest +from hypothesis import strategies as st + +from frequenz import quantities +from frequenz.quantities import ( + Current, + Energy, + Frequency, + Percentage, + Power, + Quantity, + Temperature, + Voltage, +) + + +class Fz1( + Quantity, + exponent_unit_map={ + 0: "Hz", + 3: "kHz", + }, +): + """Frequency quantity with narrow exponent unit map.""" + + +class Fz2( + Quantity, + exponent_unit_map={ + -6: "uHz", + -3: "mHz", + 0: "Hz", + 3: "kHz", + 6: "MHz", + 9: "GHz", + }, +): + """Frequency quantity with broad exponent unit map.""" + + +_CtorType = Callable[[float], Quantity] + +# This is the current number of subclasses. This probably will get outdated, but it will +# provide at least some safety against something going really wrong and end up testing +# an empty list. With this we should at least make sure we are not testing less classes +# than before. We don't get the actual number using len(_QUANTITY_SUBCLASSES) because it +# would defeat the purpose of the test. +_SANITFY_NUM_CLASSES = 7 + +_QUANTITY_SUBCLASSES = [ + cls + for _, cls in inspect.getmembers( + quantities, + lambda m: inspect.isclass(m) and issubclass(m, Quantity) and m is not Quantity, + ) +] + +# A very basic sanity check that are messing up the introspection +assert len(_QUANTITY_SUBCLASSES) >= _SANITFY_NUM_CLASSES + +_QUANTITY_BASE_UNIT_STRINGS = [ + cls._new(0).base_unit # pylint: disable=protected-access + for cls in _QUANTITY_SUBCLASSES +] +for unit in _QUANTITY_BASE_UNIT_STRINGS: + assert unit is not None + +_QUANTITY_CTORS = [ + method + for cls in _QUANTITY_SUBCLASSES + for _, method in inspect.getmembers( + cls, + lambda m: inspect.ismethod(m) + and m.__name__.startswith("from_") + and m.__name__ != ("from_string"), + ) +] +# A very basic sanity check that are messing up the introspection. There are actually +# many more constructors than classes, but this still works as a very basic check. +assert len(_QUANTITY_CTORS) >= _SANITFY_NUM_CLASSES + + +def test_zero() -> None: + """Test the zero value for quantity.""" + assert Quantity(0.0) == Quantity.zero() + assert Quantity(0.0, exponent=100) == Quantity.zero() + assert Quantity.zero() is Quantity.zero() # It is a "singleton" + assert Quantity.zero().base_value == 0.0 + + # Test the singleton is immutable + one = Quantity.zero() + one += Quantity(1.0) + assert one != Quantity.zero() + assert Quantity.zero() == Quantity(0.0) + + assert Power.from_watts(0.0) == Power.zero() + assert Power.from_kilowatts(0.0) == Power.zero() + assert isinstance(Power.zero(), Power) + assert Power.zero().as_watts() == 0.0 + assert Power.zero().as_kilowatts() == 0.0 + assert Power.zero() is Power.zero() # It is a "singleton" + + assert Current.from_amperes(0.0) == Current.zero() + assert Current.from_milliamperes(0.0) == Current.zero() + assert isinstance(Current.zero(), Current) + assert Current.zero().as_amperes() == 0.0 + assert Current.zero().as_milliamperes() == 0.0 + assert Current.zero() is Current.zero() # It is a "singleton" + + assert Voltage.from_volts(0.0) == Voltage.zero() + assert Voltage.from_kilovolts(0.0) == Voltage.zero() + assert isinstance(Voltage.zero(), Voltage) + assert Voltage.zero().as_volts() == 0.0 + assert Voltage.zero().as_kilovolts() == 0.0 + assert Voltage.zero() is Voltage.zero() # It is a "singleton" + + assert Energy.from_kilowatt_hours(0.0) == Energy.zero() + assert Energy.from_megawatt_hours(0.0) == Energy.zero() + assert isinstance(Energy.zero(), Energy) + assert Energy.zero().as_kilowatt_hours() == 0.0 + assert Energy.zero().as_megawatt_hours() == 0.0 + assert Energy.zero() is Energy.zero() # It is a "singleton" + + assert Frequency.from_hertz(0.0) == Frequency.zero() + assert Frequency.from_megahertz(0.0) == Frequency.zero() + assert isinstance(Frequency.zero(), Frequency) + assert Frequency.zero().as_hertz() == 0.0 + assert Frequency.zero().as_megahertz() == 0.0 + assert Frequency.zero() is Frequency.zero() # It is a "singleton" + + assert Percentage.from_percent(0.0) == Percentage.zero() + assert Percentage.from_fraction(0.0) == Percentage.zero() + assert isinstance(Percentage.zero(), Percentage) + assert Percentage.zero().as_percent() == 0.0 + assert Percentage.zero().as_fraction() == 0.0 + assert Percentage.zero() is Percentage.zero() # It is a "singleton" + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS) +def test_base_value_from_ctor_is_float(quantity_ctor: _CtorType) -> None: + """Test that the base value always is a float.""" + quantity = quantity_ctor(1) + assert isinstance(quantity.base_value, float) + + +@pytest.mark.parametrize("quantity_type", _QUANTITY_SUBCLASSES + [Quantity]) +def test_base_value_from_zero_is_float(quantity_type: type[Quantity]) -> None: + """Test that the base value always is a float.""" + quantity = quantity_type.zero() + assert isinstance(quantity.base_value, float) + + +@pytest.mark.parametrize( + "quantity_type, unit", zip(_QUANTITY_SUBCLASSES, _QUANTITY_BASE_UNIT_STRINGS) +) +def test_base_value_from_string_is_float( + quantity_type: type[Quantity], unit: str +) -> None: + """Test that the base value always is a float.""" + quantity = quantity_type.from_string(f"1 {unit}") + assert isinstance(quantity.base_value, float) + + +def test_string_representation() -> None: + """Test the string representation of the quantities.""" + assert str(Quantity(1.024445, exponent=0)) == "1.024" + assert ( + repr(Quantity(1.024445, exponent=0)) == "Quantity(value=1.024445, exponent=0)" + ) + assert f"{Quantity(0.50001, exponent=0):.0}" == "1" + assert f"{Quantity(1.024445, exponent=0)}" == "1.024" + assert f"{Quantity(1.024445, exponent=0):.0}" == "1" + assert f"{Quantity(0.124445, exponent=0):.0}" == "0" + assert f"{Quantity(0.50001, exponent=0):.0}" == "1" + assert f"{Quantity(1.024445, exponent=0):.6}" == "1.024445" + + assert f"{Quantity(1.024445, exponent=3)}" == "1024.445" + + assert str(Fz1(1.024445, exponent=0)) == "1.024 Hz" + assert repr(Fz1(1.024445, exponent=0)) == "Fz1(value=1.024445, exponent=0)" + assert f"{Fz1(1.024445, exponent=0)}" == "1.024 Hz" + assert f"{Fz1(1.024445, exponent=0):.0}" == "1 Hz" + assert f"{Fz1(1.024445, exponent=0):.1}" == "1 Hz" + assert f"{Fz1(1.024445, exponent=0):.2}" == "1.02 Hz" + assert f"{Fz1(1.024445, exponent=0):.9}" == "1.024445 Hz" + assert f"{Fz1(1.024445, exponent=0):0.0}" == "1 Hz" + assert f"{Fz1(1.024445, exponent=0):0.1}" == "1.0 Hz" + assert f"{Fz1(1.024445, exponent=0):0.2}" == "1.02 Hz" + assert f"{Fz1(1.024445, exponent=0):0.9}" == "1.024445000 Hz" + + assert f"{Fz1(1.024445, exponent=3)}" == "1.024 kHz" + assert f"{Fz2(1.024445, exponent=3)}" == "1.024 kHz" + + assert f"{Fz1(1.024445, exponent=6)}" == "1024.445 kHz" + assert f"{Fz2(1.024445, exponent=6)}" == "1.024 MHz" + assert f"{Fz1(1.024445, exponent=9)}" == "1024445 kHz" + assert f"{Fz2(1.024445, exponent=9)}" == "1.024 GHz" + + assert f"{Fz1(1.024445, exponent=-3)}" == "0.001 Hz" + assert f"{Fz2(1.024445, exponent=-3)}" == "1.024 mHz" + + assert f"{Fz1(1.024445, exponent=-6)}" == "0 Hz" + assert f"{Fz1(1.024445, exponent=-6):.6}" == "0.000001 Hz" + assert f"{Fz2(1.024445, exponent=-6)}" == "1.024 uHz" + + assert f"{Fz1(1.024445, exponent=-12)}" == "0 Hz" + assert f"{Fz2(1.024445, exponent=-12)}" == "0 Hz" + + assert f"{Fz1(0)}" == "0 Hz" + + assert f"{Fz1(-20)}" == "-20 Hz" + assert f"{Fz1(-20000)}" == "-20 kHz" + + assert f"{Power.from_watts(0.000124445):.0}" == "0 W" + assert f"{Energy.from_watt_hours(0.124445):.0}" == "0 Wh" + assert f"{Power.from_watts(-0.0):.0}" == "-0 W" + assert f"{Power.from_watts(0.0):.0}" == "0 W" + assert f"{Voltage.from_volts(999.9999850988388)}" == "1 kV" + + +def test_isclose() -> None: + """Test the isclose method of the quantities.""" + assert Fz1(1.024445).isclose(Fz1(1.024445)) + assert not Fz1(1.024445).isclose(Fz1(1.0)) + + +def test_addition_subtraction() -> None: + """Test the addition and subtraction of the quantities.""" + assert Quantity(1) + Quantity(1, exponent=0) == Quantity(2, exponent=0) + assert Quantity(1) + Quantity(1, exponent=3) == Quantity(1001, exponent=0) + assert Quantity(1) - Quantity(1, exponent=0) == Quantity(0, exponent=0) + + assert Fz1(1) + Fz1(1) == Fz1(2) + with pytest.raises(TypeError) as excinfo: + assert Fz1(1) + Fz2(1) # type: ignore + assert excinfo.value.args[0] == "unsupported operand type(s) for +: 'Fz1' and 'Fz2'" + with pytest.raises(TypeError) as excinfo: + assert Fz1(1) - Fz2(1) # type: ignore + assert excinfo.value.args[0] == "unsupported operand type(s) for -: 'Fz1' and 'Fz2'" + + fz1 = Fz1(1.0) + fz1 += Fz1(4.0) + assert fz1 == Fz1(5.0) + fz1 -= Fz1(9.0) + assert fz1 == Fz1(-4.0) + + with pytest.raises(TypeError) as excinfo: + fz1 += Fz2(1.0) # type: ignore + + +def test_comparison() -> None: + """Test the comparison of the quantities.""" + assert Quantity(1.024445, exponent=0) == Quantity(1.024445, exponent=0) + assert Quantity(1.024445, exponent=0) != Quantity(1.024445, exponent=3) + assert Quantity(1.024445, exponent=0) < Quantity(1.024445, exponent=3) + assert Quantity(1.024445, exponent=0) <= Quantity(1.024445, exponent=3) + assert Quantity(1.024445, exponent=0) <= Quantity(1.024445, exponent=0) + assert Quantity(1.024445, exponent=0) > Quantity(1.024445, exponent=-3) + assert Quantity(1.024445, exponent=0) >= Quantity(1.024445, exponent=-3) + assert Quantity(1.024445, exponent=0) >= Quantity(1.024445, exponent=0) + + assert Fz1(1.024445, exponent=0) == Fz1(1.024445, exponent=0) + assert Fz1(1.024445, exponent=0) != Fz1(1.024445, exponent=3) + assert Fz1(1.024445, exponent=0) < Fz1(1.024445, exponent=3) + assert Fz1(1.024445, exponent=0) <= Fz1(1.024445, exponent=3) + assert Fz1(1.024445, exponent=0) <= Fz1(1.024445, exponent=0) + assert Fz1(1.024445, exponent=0) > Fz1(1.024445, exponent=-3) + assert Fz1(1.024445, exponent=0) >= Fz1(1.024445, exponent=-3) + assert Fz1(1.024445, exponent=0) >= Fz1(1.024445, exponent=0) + + assert Fz1(1.024445, exponent=0) != Fz2(1.024445, exponent=0) + with pytest.raises(TypeError) as excinfo: + # unfortunately, mypy does not identify this as an error, when comparing a child + # type against a base type, but they should still fail, because base-type + # instances are being used as dimension-less quantities, whereas all child types + # have dimensions/units. + assert Fz1(1.024445, exponent=0) <= Quantity(1.024445, exponent=0) + assert ( + excinfo.value.args[0] + == "'<=' not supported between instances of 'Fz1' and 'Quantity'" + ) + with pytest.raises(TypeError) as excinfo: + assert Quantity(1.024445, exponent=0) <= Fz1(1.024445, exponent=0) + assert ( + excinfo.value.args[0] + == "'<=' not supported between instances of 'Quantity' and 'Fz1'" + ) + with pytest.raises(TypeError) as excinfo: + assert Fz1(1.024445, exponent=0) < Fz2(1.024445, exponent=3) # type: ignore + assert ( + excinfo.value.args[0] + == "'<' not supported between instances of 'Fz1' and 'Fz2'" + ) + with pytest.raises(TypeError) as excinfo: + assert Fz1(1.024445, exponent=0) <= Fz2(1.024445, exponent=3) # type: ignore + assert ( + excinfo.value.args[0] + == "'<=' not supported between instances of 'Fz1' and 'Fz2'" + ) + with pytest.raises(TypeError) as excinfo: + assert Fz1(1.024445, exponent=0) > Fz2(1.024445, exponent=-3) # type: ignore + assert ( + excinfo.value.args[0] + == "'>' not supported between instances of 'Fz1' and 'Fz2'" + ) + with pytest.raises(TypeError) as excinfo: + assert Fz1(1.024445, exponent=0) >= Fz2(1.024445, exponent=-3) # type: ignore + assert ( + excinfo.value.args[0] + == "'>=' not supported between instances of 'Fz1' and 'Fz2'" + ) + + +def test_power() -> None: + """Test the power class.""" + power = Power.from_milliwatts(0.0000002) + assert f"{power:.9}" == "0.0000002 mW" + power = Power.from_kilowatts(10000000.2) + assert f"{power}" == "10000 MW" + + power = Power.from_kilowatts(1.2) + assert power.as_watts() == 1200.0 + assert power.as_megawatts() == 0.0012 + assert power.as_kilowatts() == 1.2 + assert power == Power.from_milliwatts(1200000.0) + assert power == Power.from_megawatts(0.0012) + assert power != Power.from_watts(1000.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Power(1.0, exponent=0) + + +def test_current() -> None: + """Test the current class.""" + current = Current.from_milliamperes(0.0000002) + assert f"{current:.9}" == "0.0000002 mA" + current = Current.from_amperes(600000.0) + assert f"{current}" == "600000 A" + + current = Current.from_amperes(6.0) + assert current.as_amperes() == 6.0 + assert current.as_milliamperes() == 6000.0 + assert current == Current.from_milliamperes(6000.0) + assert current == Current.from_amperes(6.0) + assert current != Current.from_amperes(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Current(1.0, exponent=0) + + +def test_voltage() -> None: + """Test the voltage class.""" + voltage = Voltage.from_millivolts(0.0000002) + assert f"{voltage:.9}" == "0.0000002 mV" + voltage = Voltage.from_kilovolts(600000.0) + assert f"{voltage}" == "600000 kV" + + voltage = Voltage.from_volts(6.0) + assert voltage.as_volts() == 6.0 + assert voltage.as_millivolts() == 6000.0 + assert voltage.as_kilovolts() == 0.006 + assert voltage == Voltage.from_millivolts(6000.0) + assert voltage == Voltage.from_kilovolts(0.006) + assert voltage == Voltage.from_volts(6.0) + assert voltage != Voltage.from_volts(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Voltage(1.0, exponent=0) + + +def test_energy() -> None: + """Test the energy class.""" + energy = Energy.from_watt_hours(0.0000002) + assert f"{energy:.9}" == "0.0000002 Wh" + energy = Energy.from_megawatt_hours(600000.0) + assert f"{energy}" == "600000 MWh" + + energy = Energy.from_kilowatt_hours(6.0) + assert energy.as_watt_hours() == 6000.0 + assert energy.as_kilowatt_hours() == 6.0 + assert energy.as_megawatt_hours() == 0.006 + assert energy == Energy.from_megawatt_hours(0.006) + assert energy == Energy.from_kilowatt_hours(6.0) + assert energy != Energy.from_kilowatt_hours(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Energy(1.0, exponent=0) + + +def test_temperature() -> None: + """Test the temperature class.""" + temp = Temperature.from_celsius(30.4) + assert f"{temp}" == "30.4 °C" + + assert temp.as_celsius() == 30.4 + assert temp != Temperature.from_celsius(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Temperature(1.0, exponent=0) + + +def test_quantity_compositions() -> None: + """Test the composition of quantities.""" + power = Power.from_watts(1000.0) + voltage = Voltage.from_volts(230.0) + current = Current.from_amperes(4.3478260869565215) + energy = Energy.from_kilowatt_hours(6.2) + + assert power / voltage == current + assert power / current == voltage + assert power == voltage * current + assert power == current * voltage + + assert energy / power == timedelta(hours=6.2) + assert energy / timedelta(hours=6.2) == power + assert energy == power * timedelta(hours=6.2) + + +def test_frequency() -> None: + """Test the frequency class.""" + freq = Frequency.from_hertz(0.0000002) + assert f"{freq:.9}" == "0.0000002 Hz" + freq = Frequency.from_kilohertz(600_000.0) + assert f"{freq}" == "600 MHz" + + freq = Frequency.from_hertz(6.0) + assert freq.as_hertz() == 6.0 + assert freq.as_kilohertz() == 0.006 + assert freq == Frequency.from_kilohertz(0.006) + assert freq == Frequency.from_hertz(6.0) + assert freq != Frequency.from_hertz(5.0) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Frequency(1.0, exponent=0) + + +def test_percentage() -> None: + """Test the percentage class.""" + pct = Percentage.from_fraction(0.204) + assert f"{pct}" == "20.4 %" + pct = Percentage.from_percent(20.4) + assert f"{pct}" == "20.4 %" + assert pct.as_percent() == 20.4 + assert pct.as_fraction() == 0.204 + + +def test_neg() -> None: + """Test the negation of quantities.""" + power = Power.from_watts(1000.0) + assert -power == Power.from_watts(-1000.0) + assert -(-power) == power + + voltage = Voltage.from_volts(230.0) + assert -voltage == Voltage.from_volts(-230.0) + assert -(-voltage) == voltage + + current = Current.from_amperes(2) + assert -current == Current.from_amperes(-2) + assert -(-current) == current + + energy = Energy.from_kilowatt_hours(6.2) + assert -energy == Energy.from_kilowatt_hours(-6.2) + + freq = Frequency.from_hertz(50) + assert -freq == Frequency.from_hertz(-50) + assert -(-freq) == freq + + pct = Percentage.from_fraction(30) + assert -pct == Percentage.from_fraction(-30) + assert -(-pct) == pct + + +def test_inf() -> None: + """Test proper formating when using inf in quantities.""" + assert f"{Power.from_watts(float('inf'))}" == "inf W" + assert f"{Power.from_watts(float('-inf'))}" == "-inf W" + + assert f"{Voltage.from_volts(float('inf'))}" == "inf V" + assert f"{Voltage.from_volts(float('-inf'))}" == "-inf V" + + assert f"{Current.from_amperes(float('inf'))}" == "inf A" + assert f"{Current.from_amperes(float('-inf'))}" == "-inf A" + + assert f"{Energy.from_watt_hours(float('inf'))}" == "inf Wh" + assert f"{Energy.from_watt_hours(float('-inf'))}" == "-inf Wh" + + assert f"{Frequency.from_hertz(float('inf'))}" == "inf Hz" + assert f"{Frequency.from_hertz(float('-inf'))}" == "-inf Hz" + + assert f"{Percentage.from_fraction(float('inf'))}" == "inf %" + assert f"{Percentage.from_fraction(float('-inf'))}" == "-inf %" + + +def test_nan() -> None: + """Test proper formating when using nan in quantities.""" + assert f"{Power.from_watts(float('nan'))}" == "nan W" + assert f"{Voltage.from_volts(float('nan'))}" == "nan V" + assert f"{Current.from_amperes(float('nan'))}" == "nan A" + assert f"{Energy.from_watt_hours(float('nan'))}" == "nan Wh" + assert f"{Frequency.from_hertz(float('nan'))}" == "nan Hz" + assert f"{Percentage.from_fraction(float('nan'))}" == "nan %" + + +def test_abs() -> None: + """Test the absolute value of quantities.""" + power = Power.from_watts(1000.0) + assert abs(power) == Power.from_watts(1000.0) + assert abs(-power) == Power.from_watts(1000.0) + + voltage = Voltage.from_volts(230.0) + assert abs(voltage) == Voltage.from_volts(230.0) + assert abs(-voltage) == Voltage.from_volts(230.0) + + current = Current.from_amperes(2) + assert abs(current) == Current.from_amperes(2) + assert abs(-current) == Current.from_amperes(2) + + energy = Energy.from_kilowatt_hours(6.2) + assert abs(energy) == Energy.from_kilowatt_hours(6.2) + assert abs(-energy) == Energy.from_kilowatt_hours(6.2) + + freq = Frequency.from_hertz(50) + assert abs(freq) == Frequency.from_hertz(50) + assert abs(-freq) == Frequency.from_hertz(50) + + pct = Percentage.from_fraction(30) + assert abs(pct) == Percentage.from_fraction(30) + assert abs(-pct) == Percentage.from_fraction(30) + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + percent=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False), +) +def test_quantity_multiplied_with_precentage( + quantity_ctor: type[Quantity], quantity_value: float, percent: float +) -> None: + """Test the multiplication of all quantities with percentage.""" + percentage = Percentage.from_percent(percent) + quantity = quantity_ctor(quantity_value) + expected_value = quantity.base_value * (percent / 100.0) + print(f"{quantity=}, {percentage=}, {expected_value=}") + + product = quantity * percentage + print(f"{product=}") + assert product.base_value == expected_value + + quantity *= percentage + print(f"*{quantity=}") + assert quantity.base_value == expected_value + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + scalar=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False), +) +def test_quantity_multiplied_with_float( + quantity_ctor: type[Quantity], quantity_value: float, scalar: float +) -> None: + """Test the multiplication of all quantities with a float.""" + quantity = quantity_ctor(quantity_value) + expected_value = quantity.base_value * scalar + print(f"{quantity=}, {expected_value=}") + + product = quantity * scalar + print(f"{product=}") + assert product.base_value == expected_value + + quantity *= scalar + print(f"*{quantity=}") + assert quantity.base_value == expected_value + + +def test_invalid_multiplications() -> None: + """Test the multiplication of quantities with invalid quantities.""" + power = Power.from_watts(1000.0) + voltage = Voltage.from_volts(230.0) + current = Current.from_amperes(2) + energy = Energy.from_kilowatt_hours(12) + + for quantity in [power, voltage, current, energy]: + with pytest.raises(TypeError): + _ = power * quantity # type: ignore + with pytest.raises(TypeError): + power *= quantity # type: ignore + + for quantity in [voltage, power, energy]: + with pytest.raises(TypeError): + _ = voltage * quantity # type: ignore + with pytest.raises(TypeError): + voltage *= quantity # type: ignore + + for quantity in [current, power, energy]: + with pytest.raises(TypeError): + _ = current * quantity # type: ignore + with pytest.raises(TypeError): + current *= quantity # type: ignore + + for quantity in [energy, power, voltage, current]: + with pytest.raises(TypeError): + _ = energy * quantity # type: ignore + with pytest.raises(TypeError): + energy *= quantity # type: ignore + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + scalar=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False), +) +def test_quantity_divided_by_float( + quantity_ctor: type[Quantity], quantity_value: float, scalar: float +) -> None: + """Test the division of all quantities by a float.""" + hypothesis.assume(scalar != 0.0) + quantity = quantity_ctor(quantity_value) + expected_value = quantity.base_value / scalar + print(f"{quantity=}, {expected_value=}") + + quotient = quantity / scalar + print(f"{quotient=}") + assert quotient.base_value == expected_value + + quantity /= scalar + print(f"*{quantity=}") + assert quantity.base_value == expected_value + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + divisor_value=st.floats( + allow_infinity=False, allow_nan=False, allow_subnormal=False + ), +) +def test_quantity_divided_by_self( + quantity_ctor: type[Quantity], quantity_value: float, divisor_value: float +) -> None: + """Test the division of all quantities by a float.""" + hypothesis.assume(divisor_value != 0.0) + # We need to have float here because quantity /= divisor will return a float + quantity: Quantity | float = quantity_ctor(quantity_value) + divisor = quantity_ctor(divisor_value) + assert isinstance(quantity, Quantity) + expected_value = quantity.base_value / divisor.base_value + print(f"{quantity=}, {expected_value=}") + + quotient = quantity / divisor + print(f"{quotient=}") + assert isinstance(quotient, float) + assert quotient == expected_value + + quantity /= divisor + print(f"*{quantity=}") + assert isinstance(quantity, float) + assert quantity == expected_value + + +@pytest.mark.parametrize( + "divisor", + [ + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_current_divisions(divisor: Quantity) -> None: + """Test the divisions of current with invalid quantities.""" + current = Current.from_amperes(2) + + with pytest.raises(TypeError): + _ = current / divisor # type: ignore + with pytest.raises(TypeError): + current /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Frequency.from_hertz(50), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_energy_divisions(divisor: Quantity) -> None: + """Test the divisions of energy with invalid quantities.""" + energy = Energy.from_kilowatt_hours(500.0) + + with pytest.raises(TypeError): + _ = energy / divisor # type: ignore + with pytest.raises(TypeError): + energy /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_frequency_divisions(divisor: Quantity) -> None: + """Test the divisions of frequency with invalid quantities.""" + frequency = Frequency.from_hertz(50) + + with pytest.raises(TypeError): + _ = frequency / divisor # type: ignore + with pytest.raises(TypeError): + frequency /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_percentage_divisions(divisor: Quantity) -> None: + """Test the divisions of percentage with invalid quantities.""" + percentage = Percentage.from_percent(50.0) + + with pytest.raises(TypeError): + _ = percentage / divisor # type: ignore + with pytest.raises(TypeError): + percentage /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Quantity(30.0), + Temperature.from_celsius(30), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_power_divisions(divisor: Quantity) -> None: + """Test the divisions of power with invalid quantities.""" + power = Power.from_watts(1000.0) + + with pytest.raises(TypeError): + _ = power / divisor # type: ignore + with pytest.raises(TypeError): + power /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_quantity_divisions(divisor: Quantity) -> None: + """Test the divisions of quantity with invalid quantities.""" + quantity = Quantity(30.0) + + with pytest.raises(TypeError): + _ = quantity / divisor + with pytest.raises(TypeError): + quantity /= divisor # type: ignore[assignment] + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_temperature_divisions(divisor: Quantity) -> None: + """Test the divisions of temperature with invalid quantities.""" + temperature = Temperature.from_celsius(30) + + with pytest.raises(TypeError): + _ = temperature / divisor # type: ignore + with pytest.raises(TypeError): + temperature /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_voltage_divisions(divisor: Quantity) -> None: + """Test the divisions of voltage with invalid quantities.""" + voltage = Voltage.from_volts(230.0) + + with pytest.raises(TypeError): + _ = voltage / divisor # type: ignore + with pytest.raises(TypeError): + voltage /= divisor # type: ignore + -from frequenz.quantities import delete_me +# We can't use _QUANTITY_TYPES here, because it will break the tests, as hypothesis +# will generate more values, some of which are unsupported by the quantities. See the +# test comment for more details. +@pytest.mark.parametrize("quantity_type", [Power, Voltage, Current, Energy, Frequency]) +@pytest.mark.parametrize("exponent", [0, 3, 6, 9]) +@hypothesis.settings( + max_examples=1000 +) # Set to have a decent amount of examples (default is 100) +@hypothesis.seed(42) # Seed that triggers a lot of problematic edge cases +@hypothesis.given(value=st.floats(min_value=-1.0, max_value=1.0)) +def test_to_and_from_string( + quantity_type: type[Quantity], exponent: int, value: float +) -> None: + """Test string parsing and formatting. + The parameters for this test are constructed to stay deterministic. -def test_quantities_succeeds() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function succeeds.""" - assert delete_me() is True + With a different (or random) seed or different max_examples the + test will show failing examples. + Fixing those cases was considered an unreasonable amount of work + at the time of writing. -def test_quantities_fails() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function fails.""" - with pytest.raises(RuntimeError, match="This function should be removed!"): - delete_me(blow_up=True) + For the future, one idea was to parse the string number after the first + generation and regenerate it with the more appropriate unit and precision. + """ + quantity = quantity_type.__new__(quantity_type) + quantity._base_value = value * 10**exponent # pylint: disable=protected-access + # The above should be replaced with: + # quantity = quantity_type._new( # pylint: disable=protected-access + # value, exponent=exponent + # ) + # But we can't do that now, because, you guessed it, it will also break the tests + # (_new() will use 10.0**exponent instead of 10**exponent, which seems to have some + # effect on the tests. + quantity_str = f"{quantity:.{exponent}}" + from_string = quantity_type.from_string(quantity_str) + try: + assert f"{from_string:.{exponent}}" == quantity_str + except AssertionError as error: + pytest.fail( + f"Failed for {quantity.base_value} != from_string({from_string.base_value}) " + + f"with exponent {exponent} and source value '{value}': {error}" + ) From 367b6b032a0abfbbf172f808f4814b693bee06d8 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 16:13:04 +0200 Subject: [PATCH 2/6] Add an introduction and example to the package documentation Signed-off-by: Leandro Lucarella --- src/frequenz/quantities/__init__.py | 75 ++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/frequenz/quantities/__init__.py b/src/frequenz/quantities/__init__.py index 60dee18..fcc68b1 100644 --- a/src/frequenz/quantities/__init__.py +++ b/src/frequenz/quantities/__init__.py @@ -1,7 +1,80 @@ # License: MIT # Copyright © 2024 Frequenz Energy-as-a-Service GmbH -"""Types for holding quantities with units.""" +"""Types for holding quantities with units. + +This library provide types for holding quantities with units. The main goal is to avoid +mistakes while working with different types of quantities, for example avoiding adding +a length to a time. + +It also prevents mistakes when operating between the same quantity but in different +units, like adding a power in Joules to a power in Watts without converting one of them. + +Quantities store the value in a base unit, and then provide methods to get that quantity +as a particular unit. They can only be constructed using special constructors with the +form `Quantity.from_`, for example +[`Power.from_watts(10.0)`][frequenz.quantities.Power.from_watts]. + +Internally quantities store values as `float`s, so regular [float issues and limitations +apply](https://docs.python.org/3/tutorial/floatingpoint.html), although some of them are +tried to be mitigated. + +Quantities are also immutable, so operations between quantities return a new instance of +the quantity. + +This library provides the following types: + +- [Current][frequenz.quantities.Current]: A quantity representing an electric current. +- [Energy][frequenz.quantities.Energy]: A quantity representing energy. +- [Frequency][frequenz.quantities.Frequency]: A quantity representing frequency. +- [Percentage][frequenz.quantities.Percentage]: A quantity representing a percentage. +- [Power][frequenz.quantities.Power]: A quantity representing power. +- [Temperature][frequenz.quantities.Temperature]: A quantity representing temperature. +- [Voltage][frequenz.quantities.Voltage]: A quantity representing electric voltage. + +There is also the unitless [Quantity][frequenz.quantities.Quantity] class. All +quantities are subclasses of this class and it can be used as a base to create new +quantities. Using the `Quantity` class directly is discouraged, as it doesn't provide +any unit conversion methods. + +Example: + ```python + from datetime import timedelta + from frequenz.quantities import Power, Voltage, Current, Energy + + # Create a power quantity + power = Power.from_watts(230.0) + + # Printing uses a unit to make the string as short as possible + print(f"Power: {power}") # Power: 230.0 W + # The precision can be changed + print(f"Power: {power:0.3}") # Power: 230.000 W + # The conversion methods can be used to get the value in a particular unit + print(f"Power in MW: {power.as_megawatt()}") # Power in MW: 0.00023 MW + + # Create a voltage quantity + voltage = Voltage.from_volts(230.0) + + # Calculate the current + current = power / voltage + assert isinstance(current, Current) + print(f"Current: {current}") # Current: 1.0 A + assert current.isclose(Current.from_amperes(1.0)) + + # Calculate the energy + energy = power * timedelta(hours=1) + assert isinstance(energy, Energy) + print(f"Energy: {energy}") # Energy: 230.0 Wh + print(f"Energy in kWh: {energy.as_kilowatt_hours()}") # Energy in kWh: 0.23 + + # Invalid operations are not permitted + # (when using a type hinting linter like mypy, this will be caught at linting time) + try: + power + voltage + except TypeError as e: + print(f"Error: {e}") # Error: unsupported operand type(s) for +: 'Power' and 'Voltage' + ``` +""" from ._current import Current From 29841aff4b167b1234ed893c8e52b4a1dbe5f1ac Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 16:13:21 +0200 Subject: [PATCH 3/6] Include the package documentation in the website home Signed-off-by: Leandro Lucarella --- docs/index.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 612c7a5..8ef0995 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1 +1,9 @@ ---8<-- "README.md" +# Frequenz Quantities Library + +::: frequenz.quantities + options: + members: [] + show_bases: false + show_root_heading: false + show_root_toc_entry: false + show_source: false From 097e7b63164007d71b0acaba79572f250297592f Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 16:13:38 +0200 Subject: [PATCH 4/6] Add a link to the documentation in the README Signed-off-by: Leandro Lucarella --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 35955c3..c720231 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ converting one of them. Quantities store the value in a base unit, and then provide methods to get that quantity as a particular unit. +## Documentation + +For more information on how to use this library and examples, please check the +[Documentation website](https://frequenz-floss.github.io/frequenz-quantities-python/). + ## Supported Platforms The following platforms are officially supported (tested): From 7e87ee855a56587c0e1ba365cfcf8e04b7c4c583 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 16:28:37 +0200 Subject: [PATCH 5/6] mkdocs: Update edit URL to v1.x.x We'll release v1.0.0 soon, so we should update the edit URL to point to the v1.x.x branch instead of the v0.x.x branch. Signed-off-by: Leandro Lucarella --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 3368245..ba48cf4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,7 +8,7 @@ site_author: "Frequenz Energy-as-a-Service GmbH" copyright: "Copyright © 2024 Frequenz Energy-as-a-Service GmbH" repo_name: "frequenz-quantities-python" repo_url: "https://github.com/frequenz-floss/frequenz-quantities-python" -edit_uri: "edit/v0.x.x/docs/" +edit_uri: "edit/v1.x.x/docs/" strict: true # Treat warnings as errors # Build directories From 33b38dc4e6f3a90dba0b53e715ff37ce8cf64b38 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 13 Jun 2024 16:34:01 +0200 Subject: [PATCH 6/6] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f0b649c..19cc6e9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,16 +2,4 @@ ## Summary - - -## Upgrading - - - -## New Features - - - -## Bug Fixes - - +This is the initial release, extracted from the [SDK v1.0.0rc601](https://github.com/frequenz-floss/frequenz-sdk-python/releases/tag/v1.0.0-rc601).