From 15ac8c84553a5fa64eb1a42037b0c2991e0b4ba0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 13 Feb 2024 13:44:56 +0100 Subject: [PATCH] Allow all quantities division by float | Self Dividing by `float` is only applying a factor so it should be safe for any `Quantity`. Dividing by `Self` produces a unit-less ration (`float`), so it should be safe too. To be able to use nicer names in overloads, we use positional-only arguments for `__truediv__()`, which in practice shouldn't be much of a breaking change as these methods should be used mostly via operators. We also use `match` syntax in the changed methods. Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 3 +- src/frequenz/sdk/timeseries/_quantities.py | 157 ++++++++++--- tests/timeseries/test_quantities.py | 247 +++++++++++++++++++++ 3 files changed, 374 insertions(+), 33 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 58f4b0851..9942fc8dd 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,8 @@ ## New Features -- Allow multiplying any `Quantity` by a `float` too. This just scales the `Quantity` value. +- Allow multiplying and dividing any `Quantity` by a `float`. This just scales the `Quantity` value. +- Allow dividing any `Quantity` by another quaintity of the same type. This just returns a ration between both quantities. ## Bug Fixes diff --git a/src/frequenz/sdk/timeseries/_quantities.py b/src/frequenz/sdk/timeseries/_quantities.py index 48ebeb819..1e824515f 100644 --- a/src/frequenz/sdk/timeseries/_quantities.py +++ b/src/frequenz/sdk/timeseries/_quantities.py @@ -373,6 +373,45 @@ def __mul__(self, value: float | Percentage, /) -> Self: case _: return NotImplemented + @overload + def __truediv__(self, other: float, /) -> Self: + """Divide this quantity by a scalar. + + Args: + other: The scalar or percentage to divide this quantity by. + + Returns: + The divided quantity. + """ + + @overload + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this quantity to another. + + Args: + other: The other quantity. + + Returns: + The ratio of this quantity to another. + """ + + def __truediv__(self, value: float | Self, /) -> Self | float: + """Divide this quantity by a scalar or another quantity. + + Args: + value: The scalar or quantity to divide this quantity by. + + Returns: + The divided quantity or the ratio of this quantity to another. + """ + match value: + case float(): + return type(self)._new(self._base_value / value) + case Quantity() if type(value) is type(self): + return self._base_value / value._base_value + case _: + return NotImplemented + def __gt__(self, other: Self) -> bool: """Return whether this quantity is greater than another. @@ -664,47 +703,73 @@ def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy: case _: return NotImplemented + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this power by a scalar. + + Args: + other: The scalar to divide this power by. + + Returns: + The divided power. + """ + @overload - def __truediv__(self, other: Current) -> Voltage: + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this power to another. + + Args: + other: The other power. + + Returns: + The ratio of this power to another. + """ + + @overload + def __truediv__(self, current: Current, /) -> Voltage: """Return a voltage from dividing this power by the given current. Args: - other: The current to divide by. + current: The current to divide by. Returns: A voltage from dividing this power by the a current. """ @overload - def __truediv__(self, other: Voltage) -> Current: + def __truediv__(self, voltage: Voltage, /) -> Current: """Return a current from dividing this power by the given voltage. Args: - other: The voltage to divide by. + voltage: The voltage to divide by. Returns: A current from dividing this power by a voltage. """ - def __truediv__(self, other: Current | Voltage) -> Voltage | Current: + def __truediv__( + self, other: float | Self | Current | Voltage, / + ) -> Self | float | Voltage | Current: """Return a current or voltage from dividing this power by the given value. Args: - other: The current or voltage to divide by. + other: The scalar, power, current or voltage to divide by. Returns: A current or voltage from dividing this power by the given value. - - Raises: - TypeError: If the given value is not a current or voltage. """ - if isinstance(other, Current): - return Voltage.from_volts(self._base_value / other._base_value) - if isinstance(other, Voltage): - return Current.from_amperes(self._base_value / other._base_value) - raise TypeError( - f"unsupported operand type(s) for /: '{type(self)}' and '{type(other)}'" - ) + match other: + case float(): + return super().__truediv__(other) + case Power(): + return self._base_value / other._base_value + case Current(): + return Voltage._new(self._base_value / other._base_value) + case Voltage(): + return Current._new(self._base_value / other._base_value) + case _: + return NotImplemented class Current( @@ -1043,47 +1108,75 @@ def __mul__(self, other: float | Percentage) -> Self: case _: return NotImplemented + # See the comment for Power.__mul__ for why we need the ignore here. + @overload # type: ignore[override] + def __truediv__(self, other: float, /) -> Self: + """Divide this energy by a scalar. + + Args: + other: The scalar to divide this energy by. + + Returns: + The divided energy. + """ + @overload - def __truediv__(self, other: timedelta) -> Power: + def __truediv__(self, other: Self, /) -> float: + """Return the ratio of this energy to another. + + Args: + other: The other energy. + + Returns: + The ratio of this energy to another. + """ + + @overload + def __truediv__(self, duration: timedelta, /) -> Power: """Return a power from dividing this energy by the given duration. Args: - other: The duration to divide by. + duration: The duration to divide by. Returns: A power from dividing this energy by the given duration. """ @overload - def __truediv__(self, other: Power) -> timedelta: + def __truediv__(self, power: Power, /) -> timedelta: """Return a duration from dividing this energy by the given power. Args: - other: The power to divide by. + power: The power to divide by. Returns: A duration from dividing this energy by the given power. """ - def __truediv__(self, other: timedelta | Power) -> Power | timedelta: + def __truediv__( + self, other: float | Self | timedelta | Power, / + ) -> Self | float | Power | timedelta: """Return a power or duration from dividing this energy by the given value. Args: - other: The power or duration to divide by. + other: The scalar, energy, power or duration to divide by. Returns: A power or duration from dividing this energy by the given value. - - Raises: - TypeError: If the given value is not a power or duration. """ - if isinstance(other, timedelta): - return Power.from_watts(self._base_value / (other.total_seconds() / 3600.0)) - if isinstance(other, Power): - return timedelta(seconds=(self._base_value / other._base_value) * 3600.0) - raise TypeError( - f"unsupported operand type(s) for /: '{type(self)}' and '{type(other)}'" - ) + match other: + case float(): + return super().__truediv__(other) + case Energy(): + return self._base_value / other._base_value + case timedelta(): + return Power._new(self._base_value / (other.total_seconds() / 3600.0)) + case Power(): + return timedelta( + seconds=(self._base_value / other._base_value) * 3600.0 + ) + case _: + return NotImplemented class Frequency( diff --git a/tests/timeseries/test_quantities.py b/tests/timeseries/test_quantities.py index 040e433b1..2babbea56 100644 --- a/tests/timeseries/test_quantities.py +++ b/tests/timeseries/test_quantities.py @@ -641,6 +641,253 @@ def test_invalid_multiplications() -> None: energy *= quantity # type: ignore +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + scalar=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False), +) +def test_quantity_divided_by_float( + quantity_ctor: type[Quantity], quantity_value: float, scalar: float +) -> None: + """Test the division of all quantities by a float.""" + hypothesis.assume(scalar != 0.0) + quantity = quantity_ctor(quantity_value) + expected_value = quantity.base_value / scalar + print(f"{quantity=}, {expected_value=}") + + quotient = quantity / scalar + print(f"{quotient=}") + assert quotient.base_value == expected_value + + quantity /= scalar + print(f"*{quantity=}") + assert quantity.base_value == expected_value + + +@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity]) +# Use a small amount to avoid long running tests, we have too many combinations +@hypothesis.settings(max_examples=10) +@hypothesis.given( + quantity_value=st.floats( + allow_infinity=False, + allow_nan=False, + allow_subnormal=False, + # We need to set this because otherwise constructors with big exponents will + # cause the value to be too big for the float type, and the test will fail. + max_value=1e298, + min_value=-1e298, + ), + divisor_value=st.floats( + allow_infinity=False, allow_nan=False, allow_subnormal=False + ), +) +def test_quantity_divided_by_self( + quantity_ctor: type[Quantity], quantity_value: float, divisor_value: float +) -> None: + """Test the division of all quantities by a float.""" + hypothesis.assume(divisor_value != 0.0) + # We need to have float here because quantity /= divisor will return a float + quantity: Quantity | float = quantity_ctor(quantity_value) + divisor = quantity_ctor(divisor_value) + assert isinstance(quantity, Quantity) + expected_value = quantity.base_value / divisor.base_value + print(f"{quantity=}, {expected_value=}") + + quotient = quantity / divisor + print(f"{quotient=}") + assert isinstance(quotient, float) + assert quotient == expected_value + + quantity /= divisor + print(f"*{quantity=}") + assert isinstance(quantity, float) + assert quantity == expected_value + + +@pytest.mark.parametrize( + "divisor", + [ + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_current_divisions(divisor: Quantity) -> None: + """Test the divisions of current with invalid quantities.""" + current = Current.from_amperes(2) + + with pytest.raises(TypeError): + _ = current / divisor # type: ignore + with pytest.raises(TypeError): + current /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Frequency.from_hertz(50), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_energy_divisions(divisor: Quantity) -> None: + """Test the divisions of energy with invalid quantities.""" + energy = Energy.from_kilowatt_hours(500.0) + + with pytest.raises(TypeError): + _ = energy / divisor # type: ignore + with pytest.raises(TypeError): + energy /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_frequency_divisions(divisor: Quantity) -> None: + """Test the divisions of frequency with invalid quantities.""" + frequency = Frequency.from_hertz(50) + + with pytest.raises(TypeError): + _ = frequency / divisor # type: ignore + with pytest.raises(TypeError): + frequency /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_percentage_divisions(divisor: Quantity) -> None: + """Test the divisions of percentage with invalid quantities.""" + percentage = Percentage.from_percent(50.0) + + with pytest.raises(TypeError): + _ = percentage / divisor # type: ignore + with pytest.raises(TypeError): + percentage /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Quantity(30.0), + Temperature.from_celsius(30), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_power_divisions(divisor: Quantity) -> None: + """Test the divisions of power with invalid quantities.""" + power = Power.from_watts(1000.0) + + with pytest.raises(TypeError): + _ = power / divisor # type: ignore + with pytest.raises(TypeError): + power /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Temperature.from_celsius(30), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_quantity_divisions(divisor: Quantity) -> None: + """Test the divisions of quantity with invalid quantities.""" + quantity = Quantity(30.0) + + with pytest.raises(TypeError): + _ = quantity / divisor + with pytest.raises(TypeError): + quantity /= divisor # type: ignore[assignment] + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Voltage.from_volts(230.0), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_temperature_divisions(divisor: Quantity) -> None: + """Test the divisions of temperature with invalid quantities.""" + temperature = Temperature.from_celsius(30) + + with pytest.raises(TypeError): + _ = temperature / divisor # type: ignore + with pytest.raises(TypeError): + temperature /= divisor # type: ignore + + +@pytest.mark.parametrize( + "divisor", + [ + Current.from_amperes(2), + Energy.from_kilowatt_hours(500.0), + Frequency.from_hertz(50), + Power.from_watts(1000.0), + Quantity(30.0), + Temperature.from_celsius(30), + ], + ids=lambda q: q.__class__.__name__, +) +def test_invalid_voltage_divisions(divisor: Quantity) -> None: + """Test the divisions of voltage with invalid quantities.""" + voltage = Voltage.from_volts(230.0) + + with pytest.raises(TypeError): + _ = voltage / divisor # type: ignore + with pytest.raises(TypeError): + voltage /= divisor # type: ignore + + # 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.