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): 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). 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 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 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..fcc68b1 100644 --- a/src/frequenz/quantities/__init__.py +++ b/src/frequenz/quantities/__init__.py @@ -3,23 +3,96 @@ """Types for holding quantities with units. -TODO(cookiecutter): Add a more descriptive module description. -""" +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. -# TODO(cookiecutter): Remove this function -def delete_me(*, blow_up: bool = False) -> bool: - """Do stuff for demonstration purposes. +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' + ``` +""" - Args: - blow_up: If True, raise an exception. - Returns: - True if no exception was raised. +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 - 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}" + )