diff --git a/src/frequenz/quantities/_apparent_power.py b/src/frequenz/quantities/_apparent_power.py index e5df26a..0c8dc5b 100644 --- a/src/frequenz/quantities/_apparent_power.py +++ b/src/frequenz/quantities/_apparent_power.py @@ -5,9 +5,10 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._current import Current @@ -16,7 +17,7 @@ class ApparentPower( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ -3: "mVA", @@ -37,7 +38,7 @@ class ApparentPower( """ @classmethod - def from_volt_amperes(cls, value: float) -> Self: + def from_volt_amperes(cls, value: BaseValueT) -> Self: """Initialize a new apparent power quantity. Args: @@ -49,7 +50,7 @@ def from_volt_amperes(cls, value: float) -> Self: return cls._new(value) @classmethod - def from_milli_volt_amperes(cls, mva: float) -> Self: + def from_milli_volt_amperes(cls, mva: BaseValueT) -> Self: """Initialize a new apparent power quantity. Args: @@ -61,7 +62,7 @@ def from_milli_volt_amperes(cls, mva: float) -> Self: return cls._new(mva, exponent=-3) @classmethod - def from_kilo_volt_amperes(cls, kva: float) -> Self: + def from_kilo_volt_amperes(cls, kva: BaseValueT) -> Self: """Initialize a new apparent power quantity. Args: @@ -73,7 +74,7 @@ def from_kilo_volt_amperes(cls, kva: float) -> Self: return cls._new(kva, exponent=3) @classmethod - def from_mega_volt_amperes(cls, mva: float) -> Self: + def from_mega_volt_amperes(cls, mva: BaseValueT) -> Self: """Initialize a new apparent power quantity. Args: @@ -84,7 +85,7 @@ def from_mega_volt_amperes(cls, mva: float) -> Self: """ return cls._new(mva, exponent=6) - def as_volt_amperes(self) -> float: + def as_volt_amperes(self) -> BaseValueT: """Return the apparent power in volt-amperes (VA). Returns: @@ -92,32 +93,32 @@ def as_volt_amperes(self) -> float: """ return self._base_value - def as_milli_volt_amperes(self) -> float: + def as_milli_volt_amperes(self) -> BaseValueT: """Return the apparent power in millivolt-amperes (mVA). Returns: The apparent power in millivolt-amperes (mVA). """ - return self._base_value * 1e3 + return self._base_value * self._base_value.__class__(1e3) - def as_kilo_volt_amperes(self) -> float: + def as_kilo_volt_amperes(self) -> BaseValueT: """Return the apparent power in kilovolt-amperes (kVA). Returns: The apparent power in kilovolt-amperes (kVA). """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) - def as_mega_volt_amperes(self) -> float: + def as_mega_volt_amperes(self) -> BaseValueT: """Return the apparent power in megavolt-amperes (MVA). Returns: The apparent power in megavolt-amperes (MVA). """ - return self._base_value / 1e6 + return self._base_value / self._base_value.__class__(1e6) @overload - def __mul__(self, scalar: float, /) -> Self: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this power by a scalar. Args: @@ -128,7 +129,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this power by a percentage. Args: @@ -138,7 +139,7 @@ def __mul__(self, percent: Percentage, /) -> Self: The scaled power. """ - def __mul__(self, other: float | Percentage, /) -> Self: + def __mul__(self, other: BaseValueT | Percentage[BaseValueT], /) -> Self: """Return a power or energy from multiplying this power by the given value. Args: @@ -150,8 +151,8 @@ def __mul__(self, other: float | Percentage, /) -> Self: from ._percentage import Percentage # pylint: disable=import-outside-toplevel match other: - case float() | Percentage(): - return super().__mul__(other) + case float() | Percentage() | Decimal(): + return super().__mul__(other) # type: ignore[operator] case _: return NotImplemented @@ -164,7 +165,7 @@ def __mul__(self, other: float | Percentage, /) -> Self: # And a discussion in a mypy issue here: # https://github.com/python/mypy/issues/4985#issuecomment-389692396 @overload # type: ignore[override] - def __truediv__(self, other: float, /) -> Self: + def __truediv__(self, other: BaseValueT, /) -> Self: """Divide this power by a scalar. Args: @@ -175,7 +176,7 @@ def __truediv__(self, other: float, /) -> Self: """ @overload - def __truediv__(self, other: Self, /) -> float: + def __truediv__(self, other: Self, /) -> BaseValueT: """Return the ratio of this power to another. Args: @@ -186,7 +187,7 @@ def __truediv__(self, other: Self, /) -> float: """ @overload - def __truediv__(self, current: Current, /) -> Voltage: + def __truediv__(self, current: Current[BaseValueT], /) -> Voltage[BaseValueT]: """Return a voltage from dividing this power by the given current. Args: @@ -197,7 +198,7 @@ def __truediv__(self, current: Current, /) -> Voltage: """ @overload - def __truediv__(self, voltage: Voltage, /) -> Current: + def __truediv__(self, voltage: Voltage[BaseValueT], /) -> Current[BaseValueT]: """Return a current from dividing this power by the given voltage. Args: @@ -208,8 +209,16 @@ def __truediv__(self, voltage: Voltage, /) -> Current: """ def __truediv__( - self, other: float | Self | Current | Voltage, / - ) -> Self | float | Voltage | Current: + self, + other: ( + BaseValueT + | Self + | Current[BaseValueT] + | Voltage[BaseValueT] + | ApparentPower[BaseValueT] + ), + /, + ) -> Self | BaseValueT | Voltage[BaseValueT] | Current[BaseValueT]: """Return a current or voltage from dividing this power by the given value. Args: @@ -222,8 +231,8 @@ def __truediv__( from ._voltage import Voltage # pylint: disable=import-outside-toplevel match other: - case float(): - return super().__truediv__(other) + case float() | Decimal(): + return super().__truediv__(other) # type: ignore[operator] case ApparentPower(): return self._base_value / other._base_value case Current(): diff --git a/src/frequenz/quantities/_current.py b/src/frequenz/quantities/_current.py index 62e5294..a85741b 100644 --- a/src/frequenz/quantities/_current.py +++ b/src/frequenz/quantities/_current.py @@ -6,9 +6,10 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._percentage import Percentage @@ -17,7 +18,7 @@ class Current( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ -3: "mA", @@ -36,7 +37,7 @@ class Current( """ @classmethod - def from_amperes(cls, amperes: float) -> Self: + def from_amperes(cls, amperes: BaseValueT) -> Self: """Initialize a new current quantity. Args: @@ -48,7 +49,7 @@ def from_amperes(cls, amperes: float) -> Self: return cls._new(amperes) @classmethod - def from_milliamperes(cls, milliamperes: float) -> Self: + def from_milliamperes(cls, milliamperes: BaseValueT) -> Self: """Initialize a new current quantity. Args: @@ -59,7 +60,7 @@ def from_milliamperes(cls, milliamperes: float) -> Self: """ return cls._new(milliamperes, exponent=-3) - def as_amperes(self) -> float: + def as_amperes(self) -> BaseValueT: """Return the current in amperes. Returns: @@ -67,17 +68,17 @@ def as_amperes(self) -> float: """ return self._base_value - def as_milliamperes(self) -> float: + def as_milliamperes(self) -> BaseValueT: """Return the current in milliamperes. Returns: The current in milliamperes. """ - return self._base_value * 1e3 + return self._base_value * self._base_value.__class__(1e3) # See comment for Power.__mul__ for why we need the ignore here. @overload # type: ignore[override] - def __mul__(self, scalar: float, /) -> Self: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this current by a scalar. Args: @@ -88,7 +89,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this current by a percentage. Args: @@ -99,7 +100,7 @@ def __mul__(self, percent: Percentage, /) -> Self: """ @overload - def __mul__(self, other: Voltage, /) -> Power: + def __mul__(self, other: Voltage[BaseValueT], /) -> Power[BaseValueT]: """Multiply the current by a voltage to get a power. Args: @@ -109,7 +110,9 @@ def __mul__(self, other: Voltage, /) -> Power: The calculated power. """ - def __mul__(self, other: float | Percentage | Voltage, /) -> Self | Power: + def __mul__( + self, other: BaseValueT | Percentage[BaseValueT] | Voltage[BaseValueT], / + ) -> Self | Power[BaseValueT]: """Return a current or power from multiplying this current by the given value. Args: @@ -123,8 +126,8 @@ def __mul__(self, other: float | Percentage | Voltage, /) -> Self | Power: from ._voltage import Voltage # pylint: disable=import-outside-toplevel match other: - case float() | Percentage(): - return super().__mul__(other) + case float() | Decimal() | Percentage(): + return super().__mul__(other) # type: ignore[operator] case Voltage(): return Power._new(self._base_value * other._base_value) case _: diff --git a/src/frequenz/quantities/_energy.py b/src/frequenz/quantities/_energy.py index a0795e9..3410c4f 100644 --- a/src/frequenz/quantities/_energy.py +++ b/src/frequenz/quantities/_energy.py @@ -7,9 +7,10 @@ from __future__ import annotations from datetime import timedelta +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._percentage import Percentage @@ -17,7 +18,7 @@ class Energy( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ 0: "Wh", @@ -37,7 +38,7 @@ class Energy( """ @classmethod - def from_watt_hours(cls, watt_hours: float) -> Self: + def from_watt_hours(cls, watt_hours: BaseValueT) -> Self: """Initialize a new energy quantity. Args: @@ -49,7 +50,7 @@ def from_watt_hours(cls, watt_hours: float) -> Self: return cls._new(watt_hours) @classmethod - def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self: + def from_kilowatt_hours(cls, kilowatt_hours: BaseValueT) -> Self: """Initialize a new energy quantity. Args: @@ -61,7 +62,7 @@ def from_kilowatt_hours(cls, kilowatt_hours: float) -> Self: return cls._new(kilowatt_hours, exponent=3) @classmethod - def from_megawatt_hours(cls, megawatt_hours: float) -> Self: + def from_megawatt_hours(cls, megawatt_hours: BaseValueT) -> Self: """Initialize a new energy quantity. Args: @@ -72,7 +73,7 @@ def from_megawatt_hours(cls, megawatt_hours: float) -> Self: """ return cls._new(megawatt_hours, exponent=6) - def as_watt_hours(self) -> float: + def as_watt_hours(self) -> BaseValueT: """Return the energy in watt hours. Returns: @@ -80,23 +81,23 @@ def as_watt_hours(self) -> float: """ return self._base_value - def as_kilowatt_hours(self) -> float: + def as_kilowatt_hours(self) -> BaseValueT: """Return the energy in kilowatt hours. Returns: The energy in kilowatt hours. """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) - def as_megawatt_hours(self) -> float: + def as_megawatt_hours(self) -> BaseValueT: """Return the energy in megawatt hours. Returns: The energy in megawatt hours. """ - return self._base_value / 1e6 + return self._base_value / self._base_value.__class__(1e6) - def __mul__(self, other: float | Percentage) -> Self: + def __mul__(self, other: BaseValueT | Percentage[BaseValueT]) -> Self: """Scale this energy by a percentage. Args: @@ -108,7 +109,7 @@ def __mul__(self, other: float | Percentage) -> Self: from ._percentage import Percentage # pylint: disable=import-outside-toplevel match other: - case float(): + case float() | Decimal(): return self._new(self._base_value * other) case Percentage(): return self._new(self._base_value * other.as_fraction()) @@ -117,7 +118,7 @@ def __mul__(self, other: float | Percentage) -> Self: # See the comment for Power.__mul__ for why we need the ignore here. @overload # type: ignore[override] - def __truediv__(self, other: float, /) -> Self: + def __truediv__(self, other: BaseValueT, /) -> Self: """Divide this energy by a scalar. Args: @@ -128,7 +129,7 @@ def __truediv__(self, other: float, /) -> Self: """ @overload - def __truediv__(self, other: Self, /) -> float: + def __truediv__(self, other: Self, /) -> BaseValueT: """Return the ratio of this energy to another. Args: @@ -139,7 +140,7 @@ def __truediv__(self, other: Self, /) -> float: """ @overload - def __truediv__(self, duration: timedelta, /) -> Power: + def __truediv__(self, duration: timedelta, /) -> Power[BaseValueT]: """Return a power from dividing this energy by the given duration. Args: @@ -150,7 +151,7 @@ def __truediv__(self, duration: timedelta, /) -> Power: """ @overload - def __truediv__(self, power: Power, /) -> timedelta: + def __truediv__(self, power: Power[BaseValueT], /) -> timedelta: """Return a duration from dividing this energy by the given power. Args: @@ -161,8 +162,8 @@ def __truediv__(self, power: Power, /) -> timedelta: """ def __truediv__( - self, other: float | Self | timedelta | Power, / - ) -> Self | float | Power | timedelta: + self, other: BaseValueT | Energy[BaseValueT] | timedelta | Power[BaseValueT], / + ) -> Self | BaseValueT | Power[BaseValueT] | timedelta: """Return a power or duration from dividing this energy by the given value. Args: @@ -174,15 +175,18 @@ def __truediv__( from ._power import Power # pylint: disable=import-outside-toplevel match other: - case float(): - return super().__truediv__(other) + case float() | Decimal(): + return super().__truediv__(other) # type: ignore[operator] case Energy(): return self._base_value / other._base_value case timedelta(): - return Power._new(self._base_value / (other.total_seconds() / 3600.0)) + return Power._new( + self._base_value + / self._base_value.__class__(other.total_seconds() / 3600.0) + ) case Power(): return timedelta( - seconds=(self._base_value / other._base_value) * 3600.0 + seconds=float(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 index 8c4d11c..f8ebd47 100644 --- a/src/frequenz/quantities/_frequency.py +++ b/src/frequenz/quantities/_frequency.py @@ -7,11 +7,11 @@ from datetime import timedelta from typing import Self -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity class Frequency( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={0: "Hz", 3: "kHz", 6: "MHz", 9: "GHz"}, ): @@ -27,7 +27,7 @@ class Frequency( """ @classmethod - def from_hertz(cls, hertz: float) -> Self: + def from_hertz(cls, hertz: BaseValueT) -> Self: """Initialize a new frequency quantity. Args: @@ -39,7 +39,7 @@ def from_hertz(cls, hertz: float) -> Self: return cls._new(hertz) @classmethod - def from_kilohertz(cls, kilohertz: float) -> Self: + def from_kilohertz(cls, kilohertz: BaseValueT) -> Self: """Initialize a new frequency quantity. Args: @@ -51,7 +51,7 @@ def from_kilohertz(cls, kilohertz: float) -> Self: return cls._new(kilohertz, exponent=3) @classmethod - def from_megahertz(cls, megahertz: float) -> Self: + def from_megahertz(cls, megahertz: BaseValueT) -> Self: """Initialize a new frequency quantity. Args: @@ -63,7 +63,7 @@ def from_megahertz(cls, megahertz: float) -> Self: return cls._new(megahertz, exponent=6) @classmethod - def from_gigahertz(cls, gigahertz: float) -> Self: + def from_gigahertz(cls, gigahertz: BaseValueT) -> Self: """Initialize a new frequency quantity. Args: @@ -74,7 +74,7 @@ def from_gigahertz(cls, gigahertz: float) -> Self: """ return cls._new(gigahertz, exponent=9) - def as_hertz(self) -> float: + def as_hertz(self) -> BaseValueT: """Return the frequency in hertz. Returns: @@ -82,29 +82,29 @@ def as_hertz(self) -> float: """ return self._base_value - def as_kilohertz(self) -> float: + def as_kilohertz(self) -> BaseValueT: """Return the frequency in kilohertz. Returns: The frequency in kilohertz. """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) - def as_megahertz(self) -> float: + def as_megahertz(self) -> BaseValueT: """Return the frequency in megahertz. Returns: The frequency in megahertz. """ - return self._base_value / 1e6 + return self._base_value / self._base_value.__class__(1e6) - def as_gigahertz(self) -> float: + def as_gigahertz(self) -> BaseValueT: """Return the frequency in gigahertz. Returns: The frequency in gigahertz. """ - return self._base_value / 1e9 + return self._base_value / self._base_value.__class__(1e9) def period(self) -> timedelta: """Return the period of the frequency. @@ -112,4 +112,4 @@ def period(self) -> timedelta: Returns: The period of the frequency. """ - return timedelta(seconds=1.0 / self._base_value) + return timedelta(seconds=1.0 / float(self._base_value)) diff --git a/src/frequenz/quantities/_percentage.py b/src/frequenz/quantities/_percentage.py index e0aa3c4..51a3368 100644 --- a/src/frequenz/quantities/_percentage.py +++ b/src/frequenz/quantities/_percentage.py @@ -6,11 +6,11 @@ from typing import Self -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity class Percentage( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={0: "%"}, ): @@ -26,7 +26,7 @@ class Percentage( """ @classmethod - def from_percent(cls, percent: float) -> Self: + def from_percent(cls, percent: BaseValueT) -> Self: """Initialize a new percentage quantity from a percent value. Args: @@ -38,7 +38,7 @@ def from_percent(cls, percent: float) -> Self: return cls._new(percent) @classmethod - def from_fraction(cls, fraction: float) -> Self: + def from_fraction(cls, fraction: BaseValueT) -> Self: """Initialize a new percentage quantity from a fraction. Args: @@ -49,7 +49,7 @@ def from_fraction(cls, fraction: float) -> Self: """ return cls._new(fraction * 100) - def as_percent(self) -> float: + def as_percent(self) -> BaseValueT: """Return this quantity as a percentage. Returns: @@ -57,7 +57,7 @@ def as_percent(self) -> float: """ return self._base_value - def as_fraction(self) -> float: + def as_fraction(self) -> BaseValueT: """Return this quantity as a fraction. Returns: diff --git a/src/frequenz/quantities/_power.py b/src/frequenz/quantities/_power.py index 716682b..5227f20 100644 --- a/src/frequenz/quantities/_power.py +++ b/src/frequenz/quantities/_power.py @@ -7,9 +7,10 @@ from __future__ import annotations from datetime import timedelta +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._current import Current @@ -19,7 +20,7 @@ class Power( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ -3: "mW", @@ -40,7 +41,7 @@ class Power( """ @classmethod - def from_watts(cls, watts: float) -> Self: + def from_watts(cls, watts: BaseValueT) -> Self: """Initialize a new power quantity. Args: @@ -52,7 +53,7 @@ def from_watts(cls, watts: float) -> Self: return cls._new(watts) @classmethod - def from_milliwatts(cls, milliwatts: float) -> Self: + def from_milliwatts(cls, milliwatts: BaseValueT) -> Self: """Initialize a new power quantity. Args: @@ -64,7 +65,7 @@ def from_milliwatts(cls, milliwatts: float) -> Self: return cls._new(milliwatts, exponent=-3) @classmethod - def from_kilowatts(cls, kilowatts: float) -> Self: + def from_kilowatts(cls, kilowatts: BaseValueT) -> Self: """Initialize a new power quantity. Args: @@ -76,7 +77,7 @@ def from_kilowatts(cls, kilowatts: float) -> Self: return cls._new(kilowatts, exponent=3) @classmethod - def from_megawatts(cls, megawatts: float) -> Self: + def from_megawatts(cls, megawatts: BaseValueT) -> Self: """Initialize a new power quantity. Args: @@ -87,7 +88,7 @@ def from_megawatts(cls, megawatts: float) -> Self: """ return cls._new(megawatts, exponent=6) - def as_watts(self) -> float: + def as_watts(self) -> BaseValueT: """Return the power in watts. Returns: @@ -95,21 +96,21 @@ def as_watts(self) -> float: """ return self._base_value - def as_kilowatts(self) -> float: + def as_kilowatts(self) -> BaseValueT: """Return the power in kilowatts. Returns: The power in kilowatts. """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) - def as_megawatts(self) -> float: + def as_megawatts(self) -> BaseValueT: """Return the power in megawatts. Returns: The power in megawatts. """ - return self._base_value / 1e6 + return self._base_value / self._base_value.__class__(1e6) # We need the ignore here because otherwise mypy will give this error: # > Overloaded operator methods can't have wider argument types in overrides @@ -120,7 +121,7 @@ def as_megawatts(self) -> float: # 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: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this power by a scalar. Args: @@ -131,7 +132,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this power by a percentage. Args: @@ -142,7 +143,7 @@ def __mul__(self, percent: Percentage, /) -> Self: """ @overload - def __mul__(self, other: timedelta, /) -> Energy: + def __mul__(self, other: timedelta, /) -> Energy[BaseValueT]: """Return an energy from multiplying this power by the given duration. Args: @@ -152,7 +153,9 @@ def __mul__(self, other: timedelta, /) -> Energy: The calculated energy. """ - def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy: + def __mul__( + self, other: BaseValueT | Percentage[BaseValueT] | timedelta, / + ) -> Self | Energy[BaseValueT]: """Return a power or energy from multiplying this power by the given value. Args: @@ -165,16 +168,23 @@ def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy: from ._percentage import Percentage # pylint: disable=import-outside-toplevel match other: - case float() | Percentage(): - return super().__mul__(other) + case float() | Decimal(): + return self._new(self._base_value * other) + case Percentage(): + return self._new( + self._base_value * self._base_value.__class__(other.as_fraction()) + ) case timedelta(): - return Energy._new(self._base_value * other.total_seconds() / 3600.0) + return Energy[BaseValueT]._new( + self._base_value + * self._base_value.__class__(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: + def __truediv__(self, other: BaseValueT, /) -> Self: """Divide this power by a scalar. Args: @@ -185,7 +195,7 @@ def __truediv__(self, other: float, /) -> Self: """ @overload - def __truediv__(self, other: Self, /) -> float: + def __truediv__(self, other: Self, /) -> BaseValueT: """Return the ratio of this power to another. Args: @@ -196,7 +206,7 @@ def __truediv__(self, other: Self, /) -> float: """ @overload - def __truediv__(self, current: Current, /) -> Voltage: + def __truediv__(self, current: Current[BaseValueT], /) -> Voltage[BaseValueT]: """Return a voltage from dividing this power by the given current. Args: @@ -207,7 +217,7 @@ def __truediv__(self, current: Current, /) -> Voltage: """ @overload - def __truediv__(self, voltage: Voltage, /) -> Current: + def __truediv__(self, voltage: Voltage[BaseValueT], /) -> Current[BaseValueT]: """Return a current from dividing this power by the given voltage. Args: @@ -218,8 +228,8 @@ def __truediv__(self, voltage: Voltage, /) -> Current: """ def __truediv__( - self, other: float | Self | Current | Voltage, / - ) -> Self | float | Voltage | Current: + self, other: BaseValueT | Self | Current[BaseValueT] | Voltage[BaseValueT], / + ) -> Self | BaseValueT | Voltage[BaseValueT] | Current[BaseValueT]: """Return a current or voltage from dividing this power by the given value. Args: @@ -232,13 +242,13 @@ def __truediv__( from ._voltage import Voltage # pylint: disable=import-outside-toplevel match other: - case float(): - return super().__truediv__(other) + case float() | Decimal(): + return super().__truediv__(other) # type: ignore[operator] case Power(): - return self._base_value / other._base_value + return self._base_value.__class__(self._base_value / other._base_value) case Current(): - return Voltage._new(self._base_value / other._base_value) + return Voltage[BaseValueT]._new(self._base_value / other._base_value) case Voltage(): - return Current._new(self._base_value / other._base_value) + return Current[BaseValueT]._new(self._base_value / other._base_value) case _: return NotImplemented diff --git a/src/frequenz/quantities/_quantity.py b/src/frequenz/quantities/_quantity.py index 595ce46..4aecd7a 100644 --- a/src/frequenz/quantities/_quantity.py +++ b/src/frequenz/quantities/_quantity.py @@ -7,19 +7,22 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING, Any, NoReturn, Self, overload +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Generic, NoReturn, Self, TypeVar, overload if TYPE_CHECKING: from ._percentage import Percentage +BaseValueT = TypeVar("BaseValueT", float, Decimal) -class Quantity: + +class Quantity(Generic[BaseValueT]): """A quantity with a unit. Quantities try to behave like float and are also immutable. """ - _base_value: float + _base_value: BaseValueT """The value of this quantity in the base unit.""" _exponent_unit_map: dict[int, str] | None = None @@ -29,17 +32,19 @@ class Quantity: class. Sub-classes must define this. """ - def __init__(self, value: float, exponent: int = 0) -> None: + def __init__(self, value: BaseValueT, 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 + if isinstance(value, int): + value = float(value) + self._base_value = value * value.__class__(10.0) ** exponent @classmethod - def _new(cls, value: float, *, exponent: int = 0) -> Self: + def _new(cls, value: BaseValueT, *, exponent: int = 0) -> Self: """Instantiate a new quantity subclass instance. Args: @@ -50,7 +55,9 @@ def _new(cls, value: float, *, exponent: int = 0) -> Self: A new quantity subclass instance. """ self = cls.__new__(cls) - self._base_value = value * 10.0**exponent + if isinstance(value, int): + value = float(value) + self._base_value = value * value.__class__(10.0) ** exponent return self def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None: @@ -69,30 +76,6 @@ def __init_subclass__(cls, exponent_unit_map: dict[int, str]) -> None: 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. @@ -130,7 +113,7 @@ def from_string(cls, string: str) -> Self: raise ValueError(f"Unknown unit {split_string[1]}") @property - def base_value(self) -> float: + def base_value(self) -> BaseValueT: """Return the value of this quantity in the base unit. Returns: @@ -147,7 +130,7 @@ def __round__(self, ndigits: int | None = None) -> Self: Returns: The rounded quantity. """ - return self._new(round(self._base_value, ndigits)) + return self._new(self._base_value.__class__(round(self._base_value, ndigits))) def __pos__(self) -> Self: """Return this quantity. @@ -284,7 +267,7 @@ def __format__(self, __format_spec: str) -> str: return f"{self._base_value} {self._exponent_unit_map[0]}" if abs_value := abs(self._base_value): - precision_pow = 10 ** (precision) + precision_pow = self._base_value.__class__(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 @@ -309,7 +292,8 @@ def __format__(self, __format_spec: str) -> str: else: unit = self._exponent_unit_map[unit_place] - value_str = f"{self._base_value / 10 ** unit_place:.{precision}f}" + value = self._base_value / self._base_value.__class__(10) ** unit_place + value_str = f"{value:.{precision}f}" if value_str in ("-0", "0"): stripped = value_str @@ -352,7 +336,7 @@ def __sub__(self, other: Self) -> Self: return difference @overload - def __mul__(self, scalar: float, /) -> Self: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this quantity by a scalar. Args: @@ -363,7 +347,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this quantity by a percentage. Args: @@ -373,7 +357,7 @@ def __mul__(self, percent: Percentage, /) -> Self: The scaled quantity. """ - def __mul__(self, value: float | Percentage, /) -> Self: + def __mul__(self, value: BaseValueT | Percentage[BaseValueT], /) -> Self: """Scale this quantity by a scalar or percentage. Args: @@ -385,15 +369,17 @@ def __mul__(self, value: float | Percentage, /) -> Self: from ._percentage import Percentage # pylint: disable=import-outside-toplevel match value: - case float(): + case float() | Decimal(): return type(self)._new(self._base_value * value) case Percentage(): - return type(self)._new(self._base_value * value.as_fraction()) + return type(self)._new( + self._base_value * self._base_value.__class__(value.as_fraction()) + ) case _: return NotImplemented @overload - def __truediv__(self, other: float, /) -> Self: + def __truediv__(self, other: BaseValueT, /) -> Self: """Divide this quantity by a scalar. Args: @@ -404,7 +390,7 @@ def __truediv__(self, other: float, /) -> Self: """ @overload - def __truediv__(self, other: Self, /) -> float: + def __truediv__(self, other: Self, /) -> BaseValueT: """Return the ratio of this quantity to another. Args: @@ -414,7 +400,7 @@ def __truediv__(self, other: Self, /) -> float: The ratio of this quantity to another. """ - def __truediv__(self, value: float | Self, /) -> Self | float: + def __truediv__(self, value: BaseValueT | Self, /) -> Self | BaseValueT: """Divide this quantity by a scalar or another quantity. Args: @@ -424,10 +410,10 @@ def __truediv__(self, value: float | Self, /) -> Self | float: The divided quantity or the ratio of this quantity to another. """ match value: - case float(): + case float() | Decimal(): return type(self)._new(self._base_value / value) case Quantity() if type(value) is type(self): - return self._base_value / value._base_value + return self._base_value.__class__(self._base_value / value._base_value) case _: return NotImplemented @@ -516,9 +502,7 @@ def __abs__(self) -> Self: Returns: The absolute value of this quantity. """ - absolute = type(self).__new__(type(self)) - absolute._base_value = abs(self._base_value) - return absolute + return self._new(self._base_value.__class__(abs(self._base_value))) class NoDefaultConstructible(type): diff --git a/src/frequenz/quantities/_reactive_power.py b/src/frequenz/quantities/_reactive_power.py index 8a43f47..a4ae2fc 100644 --- a/src/frequenz/quantities/_reactive_power.py +++ b/src/frequenz/quantities/_reactive_power.py @@ -6,9 +6,10 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._current import Current @@ -17,7 +18,7 @@ class ReactivePower( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ -3: "mVAR", @@ -38,7 +39,7 @@ class ReactivePower( """ @classmethod - def from_volt_amperes_reactive(cls, value: float) -> Self: + def from_volt_amperes_reactive(cls, value: BaseValueT) -> Self: """Initialize a new reactive power quantity. Args: @@ -50,7 +51,7 @@ def from_volt_amperes_reactive(cls, value: float) -> Self: return cls._new(value) @classmethod - def from_milli_volt_amperes_reactive(cls, mvars: float) -> Self: + def from_milli_volt_amperes_reactive(cls, mvars: BaseValueT) -> Self: """Initialize a new reactive power quantity. Args: @@ -62,7 +63,7 @@ def from_milli_volt_amperes_reactive(cls, mvars: float) -> Self: return cls._new(mvars, exponent=-3) @classmethod - def from_kilo_volt_amperes_reactive(cls, kvars: float) -> Self: + def from_kilo_volt_amperes_reactive(cls, kvars: BaseValueT) -> Self: """Initialize a new reactive power quantity. Args: @@ -74,7 +75,7 @@ def from_kilo_volt_amperes_reactive(cls, kvars: float) -> Self: return cls._new(kvars, exponent=3) @classmethod - def from_mega_volt_amperes_reactive(cls, mvars: float) -> Self: + def from_mega_volt_amperes_reactive(cls, mvars: BaseValueT) -> Self: """Initialize a new reactive power quantity. Args: @@ -85,7 +86,7 @@ def from_mega_volt_amperes_reactive(cls, mvars: float) -> Self: """ return cls._new(mvars, exponent=6) - def as_volt_amperes_reactive(self) -> float: + def as_volt_amperes_reactive(self) -> BaseValueT: """Return the reactive power in volt-amperes reactive (VAR). Returns: @@ -93,32 +94,32 @@ def as_volt_amperes_reactive(self) -> float: """ return self._base_value - def as_milli_volt_amperes_reactive(self) -> float: + def as_milli_volt_amperes_reactive(self) -> BaseValueT: """Return the reactive power in millivolt-amperes reactive (mVAR). Returns: The reactive power in millivolt-amperes reactive (mVAR). """ - return self._base_value * 1e3 + return self._base_value * self._base_value.__class__(1e3) - def as_kilo_volt_amperes_reactive(self) -> float: + def as_kilo_volt_amperes_reactive(self) -> BaseValueT: """Return the reactive power in kilovolt-amperes reactive (kVAR). Returns: The reactive power in kilovolt-amperes reactive (kVAR). """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) - def as_mega_volt_amperes_reactive(self) -> float: + def as_mega_volt_amperes_reactive(self) -> BaseValueT: """Return the reactive power in megavolt-amperes reactive (MVAR). Returns: The reactive power in megavolt-amperes reactive (MVAR). """ - return self._base_value / 1e6 + return self._base_value / self._base_value.__class__(1e6) @overload - def __mul__(self, scalar: float, /) -> Self: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this power by a scalar. Args: @@ -129,7 +130,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this power by a percentage. Args: @@ -139,7 +140,7 @@ def __mul__(self, percent: Percentage, /) -> Self: The scaled power. """ - def __mul__(self, other: float | Percentage, /) -> Self: + def __mul__(self, other: BaseValueT | Percentage[BaseValueT], /) -> Self: """Return a power or energy from multiplying this power by the given value. Args: @@ -151,8 +152,8 @@ def __mul__(self, other: float | Percentage, /) -> Self: from ._percentage import Percentage # pylint: disable=import-outside-toplevel match other: - case float() | Percentage(): - return super().__mul__(other) + case float() | Decimal() | Percentage(): + return super().__mul__(other) # type: ignore[operator] case _: return NotImplemented @@ -165,7 +166,7 @@ def __mul__(self, other: float | Percentage, /) -> Self: # And a discussion in a mypy issue here: # https://github.com/python/mypy/issues/4985#issuecomment-389692396 @overload # type: ignore[override] - def __truediv__(self, other: float, /) -> Self: + def __truediv__(self, other: BaseValueT, /) -> Self: """Divide this power by a scalar. Args: @@ -176,7 +177,7 @@ def __truediv__(self, other: float, /) -> Self: """ @overload - def __truediv__(self, other: Self, /) -> float: + def __truediv__(self, other: Self, /) -> BaseValueT: """Return the ratio of this power to another. Args: @@ -187,7 +188,7 @@ def __truediv__(self, other: Self, /) -> float: """ @overload - def __truediv__(self, current: Current, /) -> Voltage: + def __truediv__(self, current: Current[BaseValueT], /) -> Voltage[BaseValueT]: """Return a voltage from dividing this power by the given current. Args: @@ -198,7 +199,7 @@ def __truediv__(self, current: Current, /) -> Voltage: """ @overload - def __truediv__(self, voltage: Voltage, /) -> Current: + def __truediv__(self, voltage: Voltage[BaseValueT], /) -> Current[BaseValueT]: """Return a current from dividing this power by the given voltage. Args: @@ -209,8 +210,16 @@ def __truediv__(self, voltage: Voltage, /) -> Current: """ def __truediv__( - self, other: float | Self | Current | Voltage, / - ) -> Self | float | Voltage | Current: + self, + other: ( + BaseValueT + | Self + | Current[BaseValueT] + | Voltage[BaseValueT] + | ReactivePower[BaseValueT] + ), + /, + ) -> Self | BaseValueT | Voltage[BaseValueT] | Current[BaseValueT]: """Return a current or voltage from dividing this power by the given value. Args: @@ -223,8 +232,8 @@ def __truediv__( from ._voltage import Voltage # pylint: disable=import-outside-toplevel match other: - case float(): - return super().__truediv__(other) + case float() | Decimal(): + return super().__truediv__(other) # type: ignore[operator] case ReactivePower(): return self._base_value / other._base_value case Current(): diff --git a/src/frequenz/quantities/_temperature.py b/src/frequenz/quantities/_temperature.py index 361ad1f..0bdc3ed 100644 --- a/src/frequenz/quantities/_temperature.py +++ b/src/frequenz/quantities/_temperature.py @@ -6,11 +6,11 @@ from typing import Self -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity class Temperature( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={ 0: "°C", @@ -19,7 +19,7 @@ class Temperature( """A temperature quantity (in degrees Celsius).""" @classmethod - def from_celsius(cls, value: float) -> Self: + def from_celsius(cls, value: BaseValueT) -> Self: """Initialize a new temperature quantity. Args: @@ -30,7 +30,7 @@ def from_celsius(cls, value: float) -> Self: """ return cls._new(value) - def as_celsius(self) -> float: + def as_celsius(self) -> BaseValueT: """Return the temperature in degrees Celsius. Returns: diff --git a/src/frequenz/quantities/_voltage.py b/src/frequenz/quantities/_voltage.py index ec6452a..832143f 100644 --- a/src/frequenz/quantities/_voltage.py +++ b/src/frequenz/quantities/_voltage.py @@ -6,9 +6,10 @@ from __future__ import annotations +from decimal import Decimal from typing import TYPE_CHECKING, Self, overload -from ._quantity import NoDefaultConstructible, Quantity +from ._quantity import BaseValueT, NoDefaultConstructible, Quantity if TYPE_CHECKING: from ._current import Current @@ -17,7 +18,7 @@ class Voltage( - Quantity, + Quantity[BaseValueT], metaclass=NoDefaultConstructible, exponent_unit_map={0: "V", -3: "mV", 3: "kV"}, ): @@ -33,7 +34,7 @@ class Voltage( """ @classmethod - def from_volts(cls, volts: float) -> Self: + def from_volts(cls, volts: BaseValueT) -> Self: """Initialize a new voltage quantity. Args: @@ -45,7 +46,7 @@ def from_volts(cls, volts: float) -> Self: return cls._new(volts) @classmethod - def from_millivolts(cls, millivolts: float) -> Self: + def from_millivolts(cls, millivolts: BaseValueT) -> Self: """Initialize a new voltage quantity. Args: @@ -57,7 +58,7 @@ def from_millivolts(cls, millivolts: float) -> Self: return cls._new(millivolts, exponent=-3) @classmethod - def from_kilovolts(cls, kilovolts: float) -> Self: + def from_kilovolts(cls, kilovolts: BaseValueT) -> Self: """Initialize a new voltage quantity. Args: @@ -68,7 +69,7 @@ def from_kilovolts(cls, kilovolts: float) -> Self: """ return cls._new(kilovolts, exponent=3) - def as_volts(self) -> float: + def as_volts(self) -> BaseValueT: """Return the voltage in volts. Returns: @@ -76,25 +77,25 @@ def as_volts(self) -> float: """ return self._base_value - def as_millivolts(self) -> float: + def as_millivolts(self) -> BaseValueT: """Return the voltage in millivolts. Returns: The voltage in millivolts. """ - return self._base_value * 1e3 + return self._base_value * self._base_value.__class__(1e3) - def as_kilovolts(self) -> float: + def as_kilovolts(self) -> BaseValueT: """Return the voltage in kilovolts. Returns: The voltage in kilovolts. """ - return self._base_value / 1e3 + return self._base_value / self._base_value.__class__(1e3) # See comment for Power.__mul__ for why we need the ignore here. @overload # type: ignore[override] - def __mul__(self, scalar: float, /) -> Self: + def __mul__(self, scalar: BaseValueT, /) -> Self: """Scale this voltage by a scalar. Args: @@ -105,7 +106,7 @@ def __mul__(self, scalar: float, /) -> Self: """ @overload - def __mul__(self, percent: Percentage, /) -> Self: + def __mul__(self, percent: Percentage[BaseValueT], /) -> Self: """Scale this voltage by a percentage. Args: @@ -116,7 +117,7 @@ def __mul__(self, percent: Percentage, /) -> Self: """ @overload - def __mul__(self, other: Current, /) -> Power: + def __mul__(self, other: Current[BaseValueT], /) -> Power[BaseValueT]: """Multiply the voltage by the current to get the power. Args: @@ -126,7 +127,9 @@ def __mul__(self, other: Current, /) -> Power: The calculated power. """ - def __mul__(self, other: float | Percentage | Current, /) -> Self | Power: + def __mul__( + self, other: BaseValueT | Percentage[BaseValueT] | Current[BaseValueT], / + ) -> Self | Power[BaseValueT]: """Return a voltage or power from multiplying this voltage by the given value. Args: @@ -140,9 +143,13 @@ def __mul__(self, other: float | Percentage | Current, /) -> Self | Power: from ._power import Power # pylint: disable=import-outside-toplevel match other: - case float() | Percentage(): - return super().__mul__(other) + case float() | Decimal(): + return self._new(self._base_value * other) + case Percentage(): + return self._new( + self._base_value * self._base_value.__class__(other.as_fraction()) + ) case Current(): - return Power._new(self._base_value * other._base_value) + return Power[BaseValueT]._new(self._base_value * other._base_value) case _: return NotImplemented diff --git a/src/frequenz/quantities/experimental/marshmallow.py b/src/frequenz/quantities/experimental/marshmallow.py index 483bbdf..0028e2e 100644 --- a/src/frequenz/quantities/experimental/marshmallow.py +++ b/src/frequenz/quantities/experimental/marshmallow.py @@ -14,7 +14,7 @@ even in minor or patch releases. """ -from typing import Any, Type +from typing import Any from marshmallow import Schema, ValidationError, fields @@ -54,11 +54,11 @@ class _QuantityField(fields.Field): mapping and are used for the TYPE_MAPPING in the `QuantitySchema`. """ - field_type: Type[Quantity] | None = None + field_type: type[Quantity[float]] | None = None """The specific Quantity subclass.""" def _serialize( - self, value: Quantity, attr: str | None, obj: Any, **kwargs: Any + self, value: Quantity[float], attr: str | None, obj: Any, **kwargs: Any ) -> Any: """Serialize the Quantity object based on per-field configuration.""" if self.field_type is None or not issubclass(self.field_type, Quantity): @@ -85,7 +85,7 @@ def _serialize( def _deserialize( self, value: Any, attr: str | None, data: Any, **kwargs: Any - ) -> Quantity: + ) -> Quantity[float]: """Deserialize the Quantity object from float or string.""" if self.field_type is None or not issubclass(self.field_type, Quantity): raise TypeError( @@ -177,16 +177,16 @@ class VoltageField(_QuantityField): field_type = Voltage -QUANTITY_FIELD_CLASSES: dict[type[Quantity], type[fields.Field]] = { - ApparentPower: ApparentPowerField, - Current: CurrentField, - Energy: EnergyField, - Frequency: FrequencyField, - Percentage: PercentageField, - Power: PowerField, - ReactivePower: ReactivePowerField, - Temperature: TemperatureField, - Voltage: VoltageField, +QUANTITY_FIELD_CLASSES: dict[type[Quantity[float]], type[fields.Field]] = { + ApparentPower[float]: ApparentPowerField, + Current[float]: CurrentField, + Energy[float]: EnergyField, + Frequency[float]: FrequencyField, + Percentage[float]: PercentageField, + Power[float]: PowerField, + ReactivePower[float]: ReactivePowerField, + Temperature[float]: TemperatureField, + Voltage[float]: VoltageField, } """Mapping of Quantity subclasses to their corresponding QuantityField subclasses. @@ -254,7 +254,9 @@ def load(cls, config: dict[str, Any]) -> "Config": ``` """ - TYPE_MAPPING: dict[type[Quantity], type[fields.Field]] = QUANTITY_FIELD_CLASSES + TYPE_MAPPING: dict[type[Quantity[float]], type[fields.Field]] = ( + QUANTITY_FIELD_CLASSES + ) def __init__( self, *args: Any, serialize_as_string_default: bool = False, **kwargs: Any diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a5e4c88 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the quantities module.""" diff --git a/tests/experimental/test_marshmallow.py b/tests/experimental/test_marshmallow.py index d67eabf..019ba2f 100644 --- a/tests/experimental/test_marshmallow.py +++ b/tests/experimental/test_marshmallow.py @@ -25,8 +25,8 @@ class Config: """Configuration test class.""" - my_percent_field: Percentage = field( - default_factory=lambda: Percentage.from_percent(25.0), + my_percent_field: Percentage[float] = field( + default_factory=lambda: Percentage[float].from_percent(25.0), metadata={ "metadata": { "description": "A percentage field", @@ -34,8 +34,8 @@ class Config: }, ) - my_apparent_power_field: ApparentPower = field( - default_factory=lambda: ApparentPower.from_volt_amperes(120.0), + my_apparent_power_field: ApparentPower[float] = field( + default_factory=lambda: ApparentPower[float].from_volt_amperes(120.0), metadata={ "metadata": { "description": "An apparent power field", @@ -43,8 +43,8 @@ class Config: }, ) - my_power_field: Power = field( - default_factory=lambda: Power.from_watts(100.0), + my_power_field: Power[float] = field( + default_factory=lambda: Power[float].from_watts(100.0), metadata={ "metadata": { "description": "A power field", @@ -52,8 +52,8 @@ class Config: }, ) - my_reactive_power_field: ReactivePower = field( - default_factory=lambda: ReactivePower.from_volt_amperes_reactive(130.0), + my_reactive_power_field: ReactivePower[float] = field( + default_factory=lambda: ReactivePower[float].from_volt_amperes_reactive(130.0), metadata={ "metadata": { "description": "A reactive power field", @@ -61,8 +61,8 @@ class Config: }, ) - my_energy_field: Energy = field( - default_factory=lambda: Energy.from_watt_hours(100.0), + my_energy_field: Energy[float] = field( + default_factory=lambda: Energy[float].from_watt_hours(100.0), metadata={ "metadata": { "description": "An energy field", @@ -70,8 +70,8 @@ class Config: }, ) - voltage_always_string: Voltage = field( - default_factory=lambda: Voltage.from_kilovolts(200.0), + voltage_always_string: Voltage[float] = field( + default_factory=lambda: Voltage[float].from_kilovolts(200.0), metadata={ "metadata": { "description": "A voltage field that is always serialized as a string", @@ -80,8 +80,8 @@ class Config: }, ) - temp_never_string: Temperature = field( - default_factory=lambda: Temperature.from_celsius(100.0), + temp_never_string: Temperature[float] = field( + default_factory=lambda: Temperature[float].from_celsius(100.0), metadata={ "metadata": { "description": "A temperature field that is never serialized as a string", @@ -118,30 +118,34 @@ def test_config_schema_load() -> None: } ) - assert config.my_percent_field == Percentage.from_percent(50.0) - assert config.my_apparent_power_field == ApparentPower.from_volt_amperes(150.0) - assert config.my_power_field == Power.from_watts(200.0) - assert config.my_reactive_power_field == ReactivePower.from_volt_amperes_reactive( - 250.0 + assert config.my_percent_field == Percentage[float].from_percent(50.0) + assert config.my_apparent_power_field == ApparentPower[float].from_volt_amperes( + 150.0 ) - assert config.my_energy_field == Energy.from_watt_hours(200.0) - assert config.voltage_always_string == Voltage.from_kilovolts(250.0) - assert config.temp_never_string == Temperature.from_celsius(100.0) + assert config.my_power_field == Power[float].from_watts(200.0) + assert config.my_reactive_power_field == ReactivePower[ + float + ].from_volt_amperes_reactive(250.0) + assert config.my_energy_field == Energy[float].from_watt_hours(200.0) + assert config.voltage_always_string == Voltage[float].from_kilovolts(250.0) + assert config.temp_never_string == Temperature[float].from_celsius(100.0) def test_config_schema_load_defaults() -> None: """Test that the defaults are correctly loaded.""" config = Config.load({}) - assert config.my_percent_field == Percentage.from_percent(25.0) - assert config.my_apparent_power_field == ApparentPower.from_volt_amperes(120.0) - assert config.my_power_field == Power.from_watts(100.0) - assert config.my_reactive_power_field == ReactivePower.from_volt_amperes_reactive( - 130.0 + assert config.my_percent_field == Percentage[float].from_percent(25.0) + assert config.my_apparent_power_field == ApparentPower[float].from_volt_amperes( + 120.0 ) - assert config.my_energy_field == Energy.from_watt_hours(100.0) - assert config.voltage_always_string == Voltage.from_kilovolts(200) - assert config.temp_never_string == Temperature.from_celsius(100.0) + assert config.my_power_field == Power[float].from_watts(100.0) + assert config.my_reactive_power_field == ReactivePower[ + float + ].from_volt_amperes_reactive(130.0) + assert config.my_energy_field == Energy[float].from_watt_hours(100.0) + assert config.voltage_always_string == Voltage[float].from_kilovolts(200) + assert config.temp_never_string == Temperature[float].from_celsius(100.0) def test_config_schema_load_from_string() -> None: @@ -158,16 +162,17 @@ def test_config_schema_load_from_string() -> None: } ) - assert config.my_percent_field == Percentage.from_percent(50.0) - assert config.my_apparent_power_field == ApparentPower.from_mega_volt_amperes(150.0) - assert config.my_power_field == Power.from_watts(200.0) - assert ( - config.my_reactive_power_field - == ReactivePower.from_milli_volt_amperes_reactive(250.0) - ) - assert config.my_energy_field == Energy.from_watt_hours(200.0) - assert config.voltage_always_string == Voltage.from_kilovolts(250.0) - assert config.temp_never_string == Temperature.from_celsius(10.0) + assert config.my_percent_field == Percentage[float].from_percent(50.0) + assert config.my_apparent_power_field == ApparentPower[ + float + ].from_mega_volt_amperes(150.0) + assert config.my_power_field == Power[float].from_watts(200.0) + assert config.my_reactive_power_field == ReactivePower[ + float + ].from_milli_volt_amperes_reactive(250.0) + assert config.my_energy_field == Energy[float].from_watt_hours(200.0) + assert config.voltage_always_string == Voltage[float].from_kilovolts(250.0) + assert config.temp_never_string == Temperature[float].from_celsius(10.0) def test_config_schema_load_from_mixed() -> None: @@ -184,28 +189,29 @@ def test_config_schema_load_from_mixed() -> None: } ) - assert config.my_percent_field == Percentage.from_percent(50.0) - assert config.my_apparent_power_field == ApparentPower.from_volt_amperes(150.0) - assert config.my_power_field == Power.from_watts(200.0) - assert ( - config.my_reactive_power_field - == ReactivePower.from_milli_volt_amperes_reactive(250.0) + assert config.my_percent_field == Percentage[float].from_percent(50.0) + assert config.my_apparent_power_field == ApparentPower[float].from_volt_amperes( + 150.0 ) - assert config.my_energy_field == Energy.from_watt_hours(200.0) - assert config.voltage_always_string == Voltage.from_kilovolts(250.0) - assert config.temp_never_string == Temperature.from_celsius(10.0) + assert config.my_power_field == Power[float].from_watts(200.0) + assert config.my_reactive_power_field == ReactivePower[ + float + ].from_milli_volt_amperes_reactive(250.0) + assert config.my_energy_field == Energy[float].from_watt_hours(200.0) + assert config.voltage_always_string == Voltage[float].from_kilovolts(250.0) + assert config.temp_never_string == Temperature[float].from_celsius(10.0) def test_config_schema_dump_default_float() -> None: """Test that the values are correctly dumped.""" config = Config( - my_percent_field=Percentage.from_percent(50.0), - my_apparent_power_field=ApparentPower.from_volt_amperes(150.0), - my_power_field=Power.from_watts(200.0), - my_reactive_power_field=ReactivePower.from_volt_amperes_reactive(250.0), - my_energy_field=Energy.from_watt_hours(200.0), - voltage_always_string=Voltage.from_kilovolts(250.0), - temp_never_string=Temperature.from_celsius(10.0), + my_percent_field=Percentage[float].from_percent(50.0), + my_apparent_power_field=ApparentPower[float].from_volt_amperes(150.0), + my_power_field=Power[float].from_watts(200.0), + my_reactive_power_field=ReactivePower[float].from_volt_amperes_reactive(250.0), + my_energy_field=Energy[float].from_watt_hours(200.0), + voltage_always_string=Voltage[float].from_kilovolts(250.0), + temp_never_string=Temperature[float].from_celsius(10.0), ) dumped = config.dump(serialize_as_string_default=False) @@ -224,13 +230,13 @@ def test_config_schema_dump_default_float() -> None: def test_config_schema_dump_default_string() -> None: """Test that the values are correctly dumped.""" config = Config( - my_percent_field=Percentage.from_percent(50.0), - my_apparent_power_field=ApparentPower.from_volt_amperes(150.0), - my_power_field=Power.from_watts(200.0), - my_reactive_power_field=ReactivePower.from_volt_amperes_reactive(250.0), - my_energy_field=Energy.from_watt_hours(200.0), - voltage_always_string=Voltage.from_kilovolts(250.0), - temp_never_string=Temperature.from_celsius(10.0), + my_percent_field=Percentage[float].from_percent(50.0), + my_apparent_power_field=ApparentPower[float].from_volt_amperes(150.0), + my_power_field=Power[float].from_watts(200.0), + my_reactive_power_field=ReactivePower[float].from_volt_amperes_reactive(250.0), + my_energy_field=Energy[float].from_watt_hours(200.0), + voltage_always_string=Voltage[float].from_kilovolts(250.0), + temp_never_string=Temperature[float].from_celsius(10.0), ) dumped = config.dump(serialize_as_string_default=True) diff --git a/tests/test_quantities.py b/tests/test_quantities.py index f316e04..1b1efb6 100644 --- a/tests/test_quantities.py +++ b/tests/test_quantities.py @@ -4,15 +4,12 @@ """Tests for quantity types.""" # pylint: disable=too-many-lines -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 ( ApparentPower, Current, @@ -26,162 +23,28 @@ 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: # pylint: disable=too-many-statements - """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 ReactivePower.from_volt_amperes_reactive(0.0) == ReactivePower.zero() - assert ReactivePower.from_kilo_volt_amperes_reactive(0.0) == ReactivePower.zero() - assert isinstance(ReactivePower.zero(), ReactivePower) - assert ReactivePower.zero().as_volt_amperes_reactive() == 0.0 - assert ReactivePower.zero().as_kilo_volt_amperes_reactive() == 0.0 - assert ReactivePower.zero() is ReactivePower.zero() # It is a "singleton" - - assert ApparentPower.from_volt_amperes(0.0) == ApparentPower.zero() - assert ApparentPower.from_kilo_volt_amperes(0.0) == ApparentPower.zero() - assert isinstance(ApparentPower.zero(), ApparentPower) - assert ApparentPower.zero().as_volt_amperes() == 0.0 - assert ApparentPower.zero().as_kilo_volt_amperes() == 0.0 - assert ApparentPower.zero() is ApparentPower.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" +from .utils import ( + _QUANTITY_BASE_UNIT_STRINGS, + _QUANTITY_CTORS, + _QUANTITY_SUBCLASSES, + Fz1, + Fz2, + _CtorType, +) @pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS) -def test_base_value_from_ctor_is_float(quantity_ctor: _CtorType) -> None: +def test_base_value_from_ctor_is_float(quantity_ctor: _CtorType[float]) -> 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 + quantity_type: type[Quantity[float]], unit: str ) -> None: """Test that the base value always is a float.""" quantity = quantity_type.from_string(f"1 {unit}") @@ -238,17 +101,19 @@ def test_string_representation() -> None: 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"{ReactivePower.from_volt_amperes_reactive(0.000124445):.0}" == "0 VAR" - assert f"{ApparentPower.from_volt_amperes(0.000124445):.0}" == "0 VA" - assert f"{Power.from_watts(-0.0):.0}" == "-0 W" - assert f"{Power.from_watts(0.0):.0}" == "0 W" - assert f"{ReactivePower.from_volt_amperes_reactive(-0.0):.0}" == "-0 VAR" - assert f"{ReactivePower.from_volt_amperes_reactive(0.0):.0}" == "0 VAR" - assert f"{ApparentPower.from_volt_amperes(-0.0):.0}" == "-0 VA" - assert f"{ApparentPower.from_volt_amperes(0.0):.0}" == "0 VA" - assert f"{Voltage.from_volts(999.9999850988388)}" == "1 kV" + assert f"{Power[float].from_watts(0.000124445):.0}" == "0 W" + assert f"{Energy[float].from_watt_hours(0.124445):.0}" == "0 Wh" + assert ( + f"{ReactivePower[float].from_volt_amperes_reactive(0.000124445):.0}" == "0 VAR" + ) + assert f"{ApparentPower[float].from_volt_amperes(0.000124445):.0}" == "0 VA" + assert f"{Power[float].from_watts(-0.0):.0}" == "-0 W" + assert f"{Power[float].from_watts(0.0):.0}" == "0 W" + assert f"{ReactivePower[float].from_volt_amperes_reactive(-0.0):.0}" == "-0 VAR" + assert f"{ReactivePower[float].from_volt_amperes_reactive(0.0):.0}" == "0 VAR" + assert f"{ApparentPower[float].from_volt_amperes(-0.0):.0}" == "-0 VA" + assert f"{ApparentPower[float].from_volt_amperes(0.0):.0}" == "0 VA" + assert f"{Voltage[float].from_volts(999.9999850988388)}" == "1 kV" def test_isclose() -> None: @@ -700,10 +565,10 @@ def test_abs() -> None: 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 + quantity_ctor: type[Quantity[float]], quantity_value: float, percent: float ) -> None: """Test the multiplication of all quantities with percentage.""" - percentage = Percentage.from_percent(percent) + percentage = Percentage[float].from_percent(percent) quantity = quantity_ctor(quantity_value) expected_value = quantity.base_value * (percent / 100.0) print(f"{quantity=}, {percentage=}, {expected_value=}") @@ -733,7 +598,7 @@ def test_quantity_multiplied_with_precentage( 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 + quantity_ctor: type[Quantity[float]], quantity_value: float, scalar: float ) -> None: """Test the multiplication of all quantities with a float.""" quantity = quantity_ctor(quantity_value) @@ -751,10 +616,10 @@ def test_quantity_multiplied_with_float( 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) + power = Power[float].from_watts(1000.0) + voltage = Voltage[float].from_volts(230.0) + current = Current[float].from_amperes(2) + energy = Energy[float].from_kilowatt_hours(12) for quantity in [power, voltage, current, energy]: with pytest.raises(TypeError): @@ -797,7 +662,7 @@ def test_invalid_multiplications() -> None: 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 + quantity_ctor: type[Quantity[float]], quantity_value: float, scalar: float ) -> None: """Test the division of all quantities by a float.""" hypothesis.assume(scalar != 0.0) @@ -832,12 +697,12 @@ def test_quantity_divided_by_float( ), ) def test_quantity_divided_by_self( - quantity_ctor: type[Quantity], quantity_value: float, divisor_value: float + quantity_ctor: type[Quantity[float]], 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) + quantity: Quantity[float] | float = quantity_ctor(quantity_value) divisor = quantity_ctor(divisor_value) assert isinstance(quantity, Quantity) expected_value = quantity.base_value / divisor.base_value @@ -857,20 +722,20 @@ def test_quantity_divided_by_self( @pytest.mark.parametrize( "divisor", [ - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Power.from_watts(1000.0), - ApparentPower.from_volt_amperes(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - Quantity(30.0), - Temperature.from_celsius(30), - Voltage.from_volts(230.0), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Power[float].from_watts(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + Quantity[float](30.0), + Temperature[float].from_celsius(30), + Voltage[float].from_volts(230.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_current_divisions(divisor: Quantity) -> None: +def test_invalid_current_divisions(divisor: Quantity[float]) -> None: """Test the divisions of current with invalid quantities.""" - current = Current.from_amperes(2) + current = Current[float].from_amperes(2) with pytest.raises(TypeError): _ = current / divisor # type: ignore @@ -881,19 +746,19 @@ def test_invalid_current_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Frequency.from_hertz(50), - Quantity(30.0), - Temperature.from_celsius(30), - Voltage.from_volts(230.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - ApparentPower.from_volt_amperes(1000.0), + Current[float].from_amperes(2), + Frequency[float].from_hertz(50), + Quantity[float](30.0), + Temperature[float].from_celsius(30), + Voltage[float].from_volts(230.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_energy_divisions(divisor: Quantity) -> None: +def test_invalid_energy_divisions(divisor: Quantity[float]) -> None: """Test the divisions of energy with invalid quantities.""" - energy = Energy.from_kilowatt_hours(500.0) + energy = Energy[float].from_kilowatt_hours(500.0) with pytest.raises(TypeError): _ = energy / divisor # type: ignore @@ -904,20 +769,20 @@ def test_invalid_energy_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Energy.from_kilowatt_hours(500.0), - Power.from_watts(1000.0), - ApparentPower.from_volt_amperes(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - Quantity(30.0), - Temperature.from_celsius(30), - Voltage.from_volts(230.0), + Current[float].from_amperes(2), + Energy[float].from_kilowatt_hours(500.0), + Power[float].from_watts(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + Quantity[float](30.0), + Temperature[float].from_celsius(30), + Voltage[float].from_volts(230.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_frequency_divisions(divisor: Quantity) -> None: +def test_invalid_frequency_divisions(divisor: Quantity[float]) -> None: """Test the divisions of frequency with invalid quantities.""" - frequency = Frequency.from_hertz(50) + frequency = Frequency[float].from_hertz(50) with pytest.raises(TypeError): _ = frequency / divisor # type: ignore @@ -928,21 +793,21 @@ def test_invalid_frequency_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Power.from_watts(1000.0), - ApparentPower.from_volt_amperes(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - Quantity(30.0), - Temperature.from_celsius(30), - Voltage.from_volts(230.0), + Current[float].from_amperes(2), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Power[float].from_watts(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + Quantity[float](30.0), + Temperature[float].from_celsius(30), + Voltage[float].from_volts(230.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_percentage_divisions(divisor: Quantity) -> None: +def test_invalid_percentage_divisions(divisor: Quantity[float]) -> None: """Test the divisions of percentage with invalid quantities.""" - percentage = Percentage.from_percent(50.0) + percentage = Percentage[float].from_percent(50.0) with pytest.raises(TypeError): _ = percentage / divisor # type: ignore @@ -953,18 +818,18 @@ def test_invalid_percentage_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Quantity(30.0), - Temperature.from_celsius(30), - ReactivePower.from_volt_amperes_reactive(1000.0), - ApparentPower.from_volt_amperes(1000.0), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Quantity[float](30.0), + Temperature[float].from_celsius(30), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_power_divisions(divisor: Quantity) -> None: +def test_invalid_power_divisions(divisor: Quantity[float]) -> None: """Test the divisions of power with invalid quantities.""" - power = Power.from_watts(1000.0) + power = Power[float].from_watts(1000.0) with pytest.raises(TypeError): _ = power / divisor # type: ignore @@ -975,18 +840,18 @@ def test_invalid_power_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Power.from_watts(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - ApparentPower.from_volt_amperes(1000.0), - Temperature.from_celsius(30), - Voltage.from_volts(230.0), + Current[float].from_amperes(2), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Power[float].from_watts(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + Temperature[float].from_celsius(30), + Voltage[float].from_volts(230.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_quantity_divisions(divisor: Quantity) -> None: +def test_invalid_quantity_divisions(divisor: Quantity[float]) -> None: """Test the divisions of quantity with invalid quantities.""" quantity = Quantity(30.0) @@ -999,18 +864,18 @@ def test_invalid_quantity_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Power.from_watts(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - ApparentPower.from_volt_amperes(1000.0), - Quantity(30.0), - Voltage.from_volts(230.0), + Current[float].from_amperes(2), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Power[float].from_watts(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + Quantity[float](30.0), + Voltage[float].from_volts(230.0), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_temperature_divisions(divisor: Quantity) -> None: +def test_invalid_temperature_divisions(divisor: Quantity[float]) -> None: """Test the divisions of temperature with invalid quantities.""" temperature = Temperature.from_celsius(30) @@ -1023,20 +888,20 @@ def test_invalid_temperature_divisions(divisor: Quantity) -> None: @pytest.mark.parametrize( "divisor", [ - Current.from_amperes(2), - Energy.from_kilowatt_hours(500.0), - Frequency.from_hertz(50), - Power.from_watts(1000.0), - ReactivePower.from_volt_amperes_reactive(1000.0), - ApparentPower.from_volt_amperes(1000.0), - Quantity(30.0), - Temperature.from_celsius(30), + Current[float].from_amperes(2), + Energy[float].from_kilowatt_hours(500.0), + Frequency[float].from_hertz(50), + Power[float].from_watts(1000.0), + ReactivePower[float].from_volt_amperes_reactive(1000.0), + ApparentPower[float].from_volt_amperes(1000.0), + Quantity[float](30.0), + Temperature[float].from_celsius(30), ], ids=lambda q: q.__class__.__name__, ) -def test_invalid_voltage_divisions(divisor: Quantity) -> None: +def test_invalid_voltage_divisions(divisor: Quantity[float]) -> None: """Test the divisions of voltage with invalid quantities.""" - voltage = Voltage.from_volts(230.0) + voltage = Voltage[float].from_volts(230.0) with pytest.raises(TypeError): _ = voltage / divisor # type: ignore @@ -1058,7 +923,7 @@ def test_invalid_voltage_divisions(divisor: Quantity) -> None: @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 + quantity_type: type[Quantity[float]], exponent: int, value: float ) -> None: """Test string parsing and formatting. diff --git a/tests/test_quantities_decimal.py b/tests/test_quantities_decimal.py new file mode 100644 index 0000000..46da9db --- /dev/null +++ b/tests/test_quantities_decimal.py @@ -0,0 +1,1012 @@ +# License: MIT +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH + +"""Tests for quantity types.""" + +# pylint: disable=too-many-lines + + +from datetime import timedelta +from decimal import Decimal + +import hypothesis +import pytest +from hypothesis import strategies as st + +from frequenz.quantities import ( + ApparentPower, + Current, + Energy, + Frequency, + Percentage, + Power, + Quantity, + ReactivePower, + Temperature, + Voltage, +) + +from .utils import ( + _QUANTITY_CTORS, + Fz1, + Fz2, + _CtorType, +) + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS) +def test_base_value_from_ctor_is_decimal(quantity_ctor: _CtorType[Decimal]) -> None: + """Test that the base value always is a Decimal.""" + quantity = quantity_ctor(Decimal("1")) + assert isinstance(quantity.base_value, Decimal) + + +def test_string_representation() -> None: + """Test the string representation of the quantities.""" + assert str(Quantity(Decimal("1.024445"), exponent=0)) == "1.024" + assert ( + repr(Quantity(Decimal("1.024445"), exponent=0)) + == "Quantity(value=1.024445, exponent=0)" + ) + assert f"{Quantity(Decimal('0.50001'), exponent=0):.0}" == "1" + assert f"{Quantity(Decimal('1.024445'), exponent=0)}" == "1.024" + assert f"{Quantity(Decimal('1.024445'), exponent=0):.0}" == "1" + assert f"{Quantity(Decimal('0.124445'), exponent=0):.0}" == "0" + assert f"{Quantity(Decimal('0.50001'), exponent=0):.0}" == "1" + assert f"{Quantity(Decimal('1.024445'), exponent=0):.6}" == "1.024445" + + assert f"{Quantity(Decimal('1.024445'), exponent=3)}" == "1024.445" + + assert str(Fz1(Decimal("1.024445"), exponent=0)) == "1.024 Hz" + assert ( + repr(Fz1(Decimal("1.024445"), exponent=0)) == "Fz1(value=1.024445, exponent=0)" + ) + assert f"{Fz1(Decimal('1.024445'), exponent=0)}" == "1.024 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):.0}" == "1 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):.1}" == "1 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):.2}" == "1.02 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):.9}" == "1.024445 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):0.0}" == "1 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):0.1}" == "1.0 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):0.2}" == "1.02 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=0):0.9}" == "1.024445000 Hz" + + assert f"{Fz1(Decimal('1.024445'), exponent=3)}" == "1.024 kHz" + assert f"{Fz2(Decimal('1.024445'), exponent=3)}" == "1.024 kHz" + + assert f"{Fz1(Decimal('1.024445'), exponent=6)}" == "1024.445 kHz" + assert f"{Fz2(Decimal('1.024445'), exponent=6)}" == "1.024 MHz" + assert f"{Fz1(Decimal('1.024445'), exponent=9)}" == "1024445 kHz" + assert f"{Fz2(Decimal('1.024445'), exponent=9)}" == "1.024 GHz" + + assert f"{Fz1(Decimal('1.024445'), exponent=-3)}" == "0.001 Hz" + assert f"{Fz2(Decimal('1.024445'), exponent=-3)}" == "1.024 mHz" + + assert f"{Fz1(Decimal('1.024445'), exponent=-6)}" == "0 Hz" + assert f"{Fz1(Decimal('1.024445'), exponent=-6):.6}" == "0.000001 Hz" + assert f"{Fz2(Decimal('1.024445'), exponent=-6)}" == "1.024 uHz" + + assert f"{Fz1(Decimal('1.024445'), exponent=-12)}" == "0 Hz" + assert f"{Fz2(Decimal('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[Decimal].from_watts(Decimal('0.000124445')):.0}" == "0 W" + assert f"{Energy[Decimal].from_watt_hours(Decimal('0.124445')):.0}" == "0 Wh" + assert ( + f"{ReactivePower[Decimal].from_volt_amperes_reactive(Decimal('0.000124445')):.0}" + == "0 VAR" + ) + assert ( + f"{ApparentPower[Decimal].from_volt_amperes(Decimal('0.000124445')):.0}" + == "0 VA" + ) + assert f"{Power[Decimal].from_watts(Decimal('-0.0')):.0}" == "-0 W" + assert f"{Power[Decimal].from_watts(Decimal('0.0')):.0}" == "0 W" + assert ( + f"{ReactivePower[Decimal].from_volt_amperes_reactive(Decimal('-0.0')):.0}" + == "-0 VAR" + ) + assert ( + f"{ReactivePower[Decimal].from_volt_amperes_reactive(Decimal('0.0')):.0}" + == "0 VAR" + ) + assert f"{ApparentPower[Decimal].from_volt_amperes(Decimal('-0.0')):.0}" == "-0 VA" + assert f"{ApparentPower[Decimal].from_volt_amperes(Decimal('0.0')):.0}" == "0 VA" + assert f"{Voltage[Decimal].from_volts(Decimal('999.9999850988388'))}" == "1 kV" + + +def test_isclose() -> None: + """Test the isclose method of the quantities.""" + assert Fz1(Decimal("1.024445")).isclose(Fz1(Decimal("1.024445"))) + assert not Fz1(Decimal("1.024445")).isclose(Fz1(Decimal("1.0"))) + + +@hypothesis.given( + value=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=1e-5, + max_value=1e5, + ) +) +@pytest.mark.parametrize("ndigits", [0, 1, 2, 3]) +def test_round(value: float, ndigits: int) -> None: + """Test the rounding of the quantities.""" + assert round(Quantity(value), ndigits) == Quantity(round(value, ndigits)) + + +@hypothesis.given( + dividend=st.floats( + allow_infinity=False, + min_value=1e-5, + max_value=1e5, + allow_nan=False, + ), + divisor=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=1e-5, + max_value=1e5, + exclude_min=True, + ), +) +def test_mod(dividend: float, divisor: float) -> None: + """Test the modulo operation of the quantities.""" + dividend_decimal = Decimal(str(dividend)) + divisor_decimal = Decimal(str(divisor)) + assert Quantity(dividend_decimal) % Quantity(divisor_decimal) == Quantity( + dividend_decimal % divisor_decimal + ) + + +def test_addition_subtraction() -> None: + """Test the addition and subtraction of the quantities.""" + assert Quantity(Decimal("1")) + Quantity(Decimal("1"), exponent=0) == Quantity( + Decimal("2"), exponent=0 + ) + assert Quantity(Decimal("1")) + Quantity(Decimal("1"), exponent=3) == Quantity( + Decimal("1001"), exponent=0 + ) + assert Quantity(Decimal("1")) - Quantity(Decimal("1"), exponent=0) == Quantity( + Decimal("0"), exponent=0 + ) + + assert Fz1(Decimal("1")) + Fz1(Decimal("1")) == Fz1(Decimal("2")) + with pytest.raises(TypeError) as excinfo: + assert Fz1(Decimal("1")) + Fz2(Decimal("1")) # type: ignore + assert excinfo.value.args[0] == "unsupported operand type(s) for +: 'Fz1' and 'Fz2'" + with pytest.raises(TypeError) as excinfo: + assert Fz1(Decimal("1")) - Fz2(Decimal("1")) # type: ignore + assert excinfo.value.args[0] == "unsupported operand type(s) for -: 'Fz1' and 'Fz2'" + + fz1 = Fz1(Decimal("1.0")) + fz1 += Fz1(Decimal("4.0")) + assert fz1 == Fz1(Decimal("5.0")) + fz1 -= Fz1(Decimal("9.0")) + assert fz1 == Fz1(Decimal("-4.0")) + + with pytest.raises(TypeError) as excinfo: + fz1 += Fz2(Decimal("1.0")) # type: ignore + + +def test_comparison() -> None: + """Test the comparison of the quantities.""" + assert Quantity(Decimal("1.024445"), exponent=0) == Quantity( + Decimal("1.024445"), exponent=0 + ) + assert Quantity(Decimal("1.024445"), exponent=0) != Quantity( + Decimal("1.024445"), exponent=3 + ) + assert Quantity(Decimal("1.024445"), exponent=0) < Quantity( + Decimal("1.024445"), exponent=3 + ) + assert Quantity(Decimal("1.024445"), exponent=0) <= Quantity( + Decimal("1.024445"), exponent=3 + ) + assert Quantity(Decimal("1.024445"), exponent=0) <= Quantity( + Decimal("1.024445"), exponent=0 + ) + assert Quantity(Decimal("1.024445"), exponent=0) > Quantity( + Decimal("1.024445"), exponent=-3 + ) + assert Quantity(Decimal("1.024445"), exponent=0) >= Quantity( + Decimal("1.024445"), exponent=-3 + ) + assert Quantity(Decimal("1.024445"), exponent=0) >= Quantity( + Decimal("1.024445"), exponent=0 + ) + + assert Fz1(Decimal("1.024445"), exponent=0) == Fz1(Decimal("1.024445"), exponent=0) + assert Fz1(Decimal("1.024445"), exponent=0) != Fz1(Decimal("1.024445"), exponent=3) + assert Fz1(Decimal("1.024445"), exponent=0) < Fz1(Decimal("1.024445"), exponent=3) + assert Fz1(Decimal("1.024445"), exponent=0) <= Fz1(Decimal("1.024445"), exponent=3) + assert Fz1(Decimal("1.024445"), exponent=0) <= Fz1(Decimal("1.024445"), exponent=0) + assert Fz1(Decimal("1.024445"), exponent=0) > Fz1(Decimal("1.024445"), exponent=-3) + assert Fz1(Decimal("1.024445"), exponent=0) >= Fz1(Decimal("1.024445"), exponent=-3) + assert Fz1(Decimal("1.024445"), exponent=0) >= Fz1(Decimal("1.024445"), exponent=0) + + assert Fz1(Decimal("1.024445"), exponent=0) != Fz2(Decimal("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(Decimal("1.024445"), exponent=0) <= Quantity( + Decimal("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(Decimal("1.024445"), exponent=0) <= Fz1( + Decimal("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(Decimal("1.024445"), exponent=0) < Fz2( + Decimal("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(Decimal("1.024445"), exponent=0) <= Fz2( + Decimal("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(Decimal("1.024445"), exponent=0) > Fz2( + Decimal("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(Decimal("1.024445"), exponent=0) >= Fz2( + Decimal("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[Decimal].from_milliwatts(Decimal("0.0000002")) + assert f"{power:.9}" == "0.0000002 mW" + power = Power[Decimal].from_kilowatts(Decimal("10000000.2")) + assert f"{power}" == "10000 MW" + + power = Power[Decimal].from_kilowatts(Decimal("1.2")) + assert power.as_watts() == Decimal("1200.0") + assert power.as_megawatts() == Decimal("0.0012") + assert power.as_kilowatts() == Decimal("1.2") + assert power == Power[Decimal].from_milliwatts(Decimal("1200000.0")) + assert power == Power[Decimal].from_megawatts(Decimal("0.0012")) + assert power != Power[Decimal].from_watts(Decimal("1000.0")) + + with pytest.raises(TypeError): + # using the default constructor should raise. + Power(1.0, exponent=0) + + +def test_reactive_power() -> None: + """Test the reactive power class.""" + power = ReactivePower[Decimal].from_milli_volt_amperes_reactive( + Decimal("0.0000002") + ) + assert f"{power:.9}" == "0.0000002 mVAR" + power = ReactivePower[Decimal].from_kilo_volt_amperes_reactive( + Decimal("10000000.2") + ) + assert f"{power}" == "10000 MVAR" + + power = ReactivePower[Decimal].from_kilo_volt_amperes_reactive(Decimal("1.2")) + assert power.as_volt_amperes_reactive() == Decimal("1200.0") + assert power.as_mega_volt_amperes_reactive() == Decimal("0.0012") + assert power.as_kilo_volt_amperes_reactive() == Decimal("1.2") + assert power == ReactivePower[Decimal].from_milli_volt_amperes_reactive( + Decimal("1200000.0") + ) + assert power == ReactivePower[Decimal].from_mega_volt_amperes_reactive( + Decimal("0.0012") + ) + assert power != ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")) + + with pytest.raises(TypeError): + # using the default constructor should raise. + ReactivePower(1.0, exponent=0) + + +def test_apparent_power() -> None: + """Test the apparent power class.""" + power = ApparentPower[Decimal].from_milli_volt_amperes(Decimal("0.0000002")) + assert f"{power:.9}" == "0.0000002 mVA" + power = ApparentPower[Decimal].from_kilo_volt_amperes(Decimal("10000000.2")) + assert f"{power}" == "10000 MVA" + + power = ApparentPower[Decimal].from_kilo_volt_amperes(Decimal("1.2")) + assert power.as_volt_amperes() == Decimal("1200.0") + assert power.as_mega_volt_amperes() == Decimal("0.0012") + assert power.as_kilo_volt_amperes() == Decimal("1.2") + assert power == ApparentPower[Decimal].from_milli_volt_amperes(Decimal("1200000.0")) + assert power == ApparentPower[Decimal].from_mega_volt_amperes(Decimal("0.0012")) + assert power != ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")) + + with pytest.raises(TypeError): + # using the default constructor should raise. + ApparentPower(1.0, exponent=0) + + +def test_current() -> None: + """Test the current class.""" + current = Current[Decimal].from_milliamperes(Decimal("0.0000002")) + assert f"{current:.9}" == "0.0000002 mA" + current = Current[Decimal].from_amperes(Decimal("600000.0")) + assert f"{current}" == "600000 A" + + current = Current[Decimal].from_amperes(Decimal("6.0")) + assert current.as_amperes() == Decimal("6.0") + assert current.as_milliamperes() == Decimal("6000.0") + assert current == Current[Decimal].from_milliamperes(Decimal("6000.0")) + assert current == Current[Decimal].from_amperes(Decimal("6.0")) + assert current != Current[Decimal].from_amperes(Decimal("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[Decimal].from_millivolts(Decimal("0.0000002")) + assert f"{voltage:.9}" == "0.0000002 mV" + voltage = Voltage[Decimal].from_kilovolts(Decimal("600000.0")) + assert f"{voltage}" == "600000 kV" + + voltage = Voltage[Decimal].from_volts(Decimal("6.0")) + assert voltage.as_volts() == Decimal("6.0") + assert voltage.as_millivolts() == Decimal("6000.0") + assert voltage.as_kilovolts() == Decimal("0.006") + assert voltage == Voltage[Decimal].from_millivolts(Decimal("6000.0")) + assert voltage == Voltage[Decimal].from_kilovolts(Decimal("0.006")) + assert voltage == Voltage[Decimal].from_volts(Decimal("6.0")) + assert voltage != Voltage[Decimal].from_volts(Decimal("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[Decimal].from_watt_hours(Decimal("0.0000002")) + assert f"{energy:.9}" == "0.0000002 Wh" + energy = Energy[Decimal].from_megawatt_hours(Decimal("600000.0")) + assert f"{energy}" == "600000 MWh" + + energy = Energy[Decimal].from_kilowatt_hours(Decimal("6.0")) + assert energy.as_watt_hours() == Decimal("6000.0") + assert energy.as_kilowatt_hours() == Decimal("6.0") + assert energy.as_megawatt_hours() == Decimal("0.006") + assert energy == Energy[Decimal].from_megawatt_hours(Decimal("0.006")) + assert energy == Energy[Decimal].from_kilowatt_hours(Decimal("6.0")) + assert energy != Energy[Decimal].from_kilowatt_hours(Decimal("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[Decimal].from_celsius(Decimal("30.4")) + assert f"{temp}" == "30.4 °C" + + assert temp.as_celsius() == Decimal("30.4") + assert temp != Temperature[Decimal].from_celsius(Decimal("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[Decimal].from_watts(Decimal("1000.0")) + voltage = Voltage[Decimal].from_volts(Decimal("250.0")) + current = Current[Decimal].from_amperes(Decimal("4.0")) + energy = Energy[Decimal].from_kilowatt_hours(Decimal("6.5")) + + assert power / voltage == current + assert power / current == voltage + assert power == voltage * current + assert power == current * voltage + + assert energy / power == timedelta(hours=6.5) + assert energy == power * timedelta(hours=6.5) + assert energy / timedelta(hours=6.5) == power + + +def test_frequency() -> None: + """Test the frequency class.""" + freq = Frequency[Decimal].from_hertz(Decimal("0.0000002")) + assert f"{freq:.9}" == "0.0000002 Hz" + freq = Frequency[Decimal].from_kilohertz(Decimal("600_000.0")) + assert f"{freq}" == "600 MHz" + + freq = Frequency[Decimal].from_hertz(Decimal("6.0")) + assert freq.as_hertz() == Decimal("6.0") + assert freq.as_kilohertz() == Decimal("0.006") + assert freq == Frequency[Decimal].from_kilohertz(Decimal("0.006")) + assert freq == Frequency[Decimal].from_hertz(Decimal("6.0")) + assert freq != Frequency[Decimal].from_hertz(Decimal("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[Decimal].from_fraction(Decimal("0.204")) + assert f"{pct}" == "20.4 %" + pct = Percentage[Decimal].from_percent(Decimal("20.4")) + assert f"{pct}" == "20.4 %" + assert pct.as_percent() == Decimal("20.4") + assert pct.as_fraction() == Decimal("0.204") + + +def test_neg() -> None: + """Test the negation of quantities.""" + power = Power[Decimal].from_watts(Decimal("1000.0")) + assert -power == Power[Decimal].from_watts(Decimal("-1000.0")) + assert -(-power) == power + + reactive_power = ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("1000.0") + ) + assert -reactive_power == ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("-1000.0") + ) + assert -(-reactive_power) == reactive_power + + apparent_power = ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")) + assert -apparent_power == ApparentPower[Decimal].from_volt_amperes( + Decimal("-1000.0") + ) + assert -(-apparent_power) == apparent_power + + voltage = Voltage[Decimal].from_volts(Decimal("230.0")) + assert -voltage == Voltage[Decimal].from_volts(Decimal("-230.0")) + assert -(-voltage) == voltage + + current = Current[Decimal].from_amperes(Decimal("2")) + assert -current == Current[Decimal].from_amperes(Decimal("-2")) + assert -(-current) == current + + energy = Energy[Decimal].from_kilowatt_hours(Decimal("6.2")) + assert -energy == Energy[Decimal].from_kilowatt_hours(Decimal("-6.2")) + + freq = Frequency[Decimal].from_hertz(Decimal("50")) + assert -freq == Frequency[Decimal].from_hertz(Decimal("-50")) + assert -(-freq) == freq + + pct = Percentage[Decimal].from_fraction(Decimal("30")) + assert -pct == Percentage[Decimal].from_fraction(Decimal("-30")) + assert -(-pct) == pct + + +def test_pos() -> None: + """Test the positive sign of quantities.""" + power = Power[Decimal].from_watts(Decimal("1000.0")) + assert +power == power + assert +(+power) == power + + reactive_power = ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("1000.0") + ) + assert +reactive_power == reactive_power + assert +(+reactive_power) == reactive_power + + apparent_power = ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")) + assert +apparent_power == apparent_power + assert +(+apparent_power) == apparent_power + + voltage = Voltage[Decimal].from_volts(Decimal("230.0")) + assert +voltage == voltage + assert +(+voltage) == voltage + + current = Current[Decimal].from_amperes(Decimal("2")) + assert +current == current + assert +(+current) == current + + energy = Energy[Decimal].from_kilowatt_hours(Decimal("6.2")) + assert +energy == energy + assert +(+energy) == energy + + freq = Frequency[Decimal].from_hertz(Decimal("50")) + assert +freq == freq + assert +(+freq) == freq + + pct = Percentage[Decimal].from_fraction(Decimal("30")) + assert +pct == pct + assert +(+pct) == pct + + +def test_abs() -> None: + """Test the absolute value of quantities.""" + power = Power[Decimal].from_watts(Decimal("1000.0")) + assert abs(power) == Power[Decimal].from_watts(Decimal("1000.0")) + assert abs(-power) == Power[Decimal].from_watts(Decimal("1000.0")) + + reactive_power = ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("1000.0") + ) + assert abs(reactive_power) == ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("1000.0") + ) + assert abs(-reactive_power) == ReactivePower[Decimal].from_volt_amperes_reactive( + Decimal("1000.0") + ) + + apparent_power = ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")) + assert abs(apparent_power) == ApparentPower[Decimal].from_volt_amperes( + Decimal("1000.0") + ) + assert abs(-apparent_power) == ApparentPower[Decimal].from_volt_amperes( + Decimal("1000.0") + ) + + voltage = Voltage[Decimal].from_volts(Decimal("230.0")) + assert abs(voltage) == Voltage[Decimal].from_volts(Decimal("230.0")) + assert abs(-voltage) == Voltage[Decimal].from_volts(Decimal("230.0")) + + current = Current[Decimal].from_amperes(Decimal("2")) + assert abs(current) == Current[Decimal].from_amperes(Decimal("2")) + assert abs(-current) == Current[Decimal].from_amperes(Decimal("2")) + + energy = Energy[Decimal].from_kilowatt_hours(Decimal("6.2")) + assert abs(energy) == Energy[Decimal].from_kilowatt_hours(Decimal("6.2")) + assert abs(-energy) == Energy[Decimal].from_kilowatt_hours(Decimal("6.2")) + + freq = Frequency[Decimal].from_hertz(Decimal("50")) + assert abs(freq) == Frequency[Decimal].from_hertz(Decimal("50")) + assert abs(-freq) == Frequency[Decimal].from_hertz(Decimal("50")) + + pct = Percentage[Decimal].from_fraction(Decimal("30")) + assert abs(pct) == Percentage[Decimal].from_fraction(Decimal("30")) + assert abs(-pct) == Percentage[Decimal].from_fraction(Decimal("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 Decimal 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[Decimal]], quantity_value: float, percent: float +) -> None: + """Test the multiplication of all quantities with percentage.""" + quantity_value_decimal = Decimal(str(quantity_value)) + percent_decimal = Decimal(str(percent)) + percentage = Percentage[Decimal].from_percent(percent_decimal) + quantity = quantity_ctor(quantity_value_decimal) + expected_value = quantity.base_value * (percent_decimal / Decimal("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 Decimal 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_decimal( + quantity_ctor: type[Quantity[Decimal]], quantity_value: float, scalar: float +) -> None: + """Test the multiplication of all quantities with a Decimal.""" + quantity_value_decimal = Decimal(str(quantity_value)) + scalar_decimal = Decimal(str(scalar)) + quantity = quantity_ctor(quantity_value_decimal) + expected_value = quantity.base_value * scalar_decimal + print(f"{quantity=}, {expected_value=}") + + product = quantity * scalar_decimal + print(f"{product=}") + assert product.base_value == expected_value + + quantity *= scalar_decimal + print(f"*{quantity=}") + assert quantity.base_value == expected_value + + +def test_invalid_multiplications() -> None: + """Test the multiplication of quantities with invalid quantities.""" + power = Power[Decimal].from_watts(Decimal("1000.0")) + voltage = Voltage[Decimal].from_volts(Decimal("230.0")) + current = Current[Decimal].from_amperes(Decimal("2")) + energy = Energy[Decimal].from_kilowatt_hours(Decimal("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 Decimal 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_decimal( + quantity_ctor: type[Quantity[Decimal]], quantity_value: float, scalar: float +) -> None: + """Test the division of all quantities by a Decimal.""" + quantity_value_decimal = Decimal(str(quantity_value)) + scalar_decimal = Decimal(str(scalar)) + hypothesis.assume(scalar != 0.0) + quantity = quantity_ctor(quantity_value_decimal) + expected_value = quantity.base_value / scalar_decimal + print(f"{quantity=}, {expected_value=}") + + quotient = quantity / scalar_decimal + print(f"{quotient=}") + assert quotient.base_value == expected_value + + quantity /= scalar_decimal + 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 Decimal 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[Decimal]], + quantity_value: float, + divisor_value: float, +) -> None: + """Test the division of all quantities by a Decimal.""" + quantity_value_decimal = Decimal(str(quantity_value)) + divisor_value_decimal = Decimal(str(divisor_value)) + hypothesis.assume(divisor_value != 0.0) + # We need to have Decimal here because quantity /= divisor will return a Decimal + quantity: Quantity[Decimal] | Decimal = quantity_ctor(quantity_value_decimal) + divisor = quantity_ctor(divisor_value_decimal) + 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, Decimal) + assert quotient == expected_value + + quantity /= divisor + print(f"*{quantity=}") + assert isinstance(quantity, Decimal) + assert quantity == expected_value + + +@pytest.mark.parametrize( + "divisor", + [ + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Power[Decimal].from_watts(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_current_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of current with invalid quantities.""" + current = Current[Decimal].from_amperes(Decimal("2")) + + with pytest.raises(TypeError): + _ = current / divisor # type: ignore + with pytest.raises(TypeError): + current /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Frequency[Decimal].from_hertz(Decimal("50")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_energy_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of energy with invalid quantities.""" + energy = Energy[Decimal].from_kilowatt_hours(Decimal("500.0")) + + with pytest.raises(TypeError): + _ = energy / divisor # type: ignore + with pytest.raises(TypeError): + energy /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Power[Decimal].from_watts(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_frequency_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of frequency with invalid quantities.""" + frequency = Frequency[Decimal].from_hertz(Decimal("50")) + + with pytest.raises(TypeError): + _ = frequency / divisor # type: ignore + with pytest.raises(TypeError): + frequency /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Power[Decimal].from_watts(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_percentage_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of percentage with invalid quantities.""" + percentage = Percentage[Decimal].from_percent(Decimal("50.0")) + + with pytest.raises(TypeError): + _ = percentage / divisor # type: ignore + with pytest.raises(TypeError): + percentage /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_power_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of power with invalid quantities.""" + power = Power[Decimal].from_watts(Decimal("1000.0")) + + with pytest.raises(TypeError): + _ = power / divisor # type: ignore + with pytest.raises(TypeError): + power /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Power[Decimal].from_watts(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_quantity_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of quantity with invalid quantities.""" + quantity = Quantity(Decimal("30.0")) + + with pytest.raises(TypeError): + _ = quantity / divisor + with pytest.raises(TypeError): + quantity /= divisor # type: ignore[assignment] + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Power[Decimal].from_watts(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + Quantity[Decimal](Decimal("30.0")), + Voltage[Decimal].from_volts(Decimal("230.0")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_temperature_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of temperature with invalid quantities.""" + temperature = Temperature[Decimal].from_celsius(Decimal("30")) + + with pytest.raises(TypeError): + _ = temperature / divisor # type: ignore + with pytest.raises(TypeError): + temperature /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current[Decimal].from_amperes(Decimal("2")), + Energy[Decimal].from_kilowatt_hours(Decimal("500.0")), + Frequency[Decimal].from_hertz(Decimal("50")), + Power[Decimal].from_watts(Decimal("1000.0")), + ReactivePower[Decimal].from_volt_amperes_reactive(Decimal("1000.0")), + ApparentPower[Decimal].from_volt_amperes(Decimal("1000.0")), + Quantity[Decimal](Decimal("30.0")), + Temperature[Decimal].from_celsius(Decimal("30")), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_voltage_divisions(divisor: Quantity[Decimal]) -> None: + """Test the divisions of voltage with invalid quantities.""" + voltage = Voltage[Decimal].from_volts(Decimal("230.0")) + + with pytest.raises(TypeError): + _ = voltage / divisor # type: ignore + with pytest.raises(TypeError): + voltage /= divisor # type: ignore + + +# 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, ReactivePower, ApparentPower], +) +@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[Decimal]], exponent: int, value: Decimal +) -> None: + """Test string parsing and formatting. + + The parameters for this test are constructed to stay deterministic. + + 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. + + 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}" + ) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..3de9f65 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,77 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Utils for testing the quantities with.""" + +import inspect +from collections.abc import Callable + +from frequenz import quantities +from frequenz.quantities import Quantity +from frequenz.quantities._quantity import BaseValueT + + +class Fz1( + Quantity[BaseValueT], + exponent_unit_map={ + 0: "Hz", + 3: "kHz", + }, +): + """Frequency quantity with narrow exponent unit map.""" + + +class Fz2( + Quantity[BaseValueT], + exponent_unit_map={ + -6: "uHz", + -3: "mHz", + 0: "Hz", + 3: "kHz", + 6: "MHz", + 9: "GHz", + }, +): + """Frequency quantity with broad exponent unit map.""" + + +_CtorType = Callable[[BaseValueT], Quantity[BaseValueT]] + +# 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