Skip to content

Commit 8aa7977

Browse files
authored
Allow multiplying Quantity by float (#875)
This is only applying a factor so it should be safe for any Quantity. This PR also improve multiplication tests to use hypothesis and test with all constructors.
2 parents 03e6a3f + 9d45a46 commit 8aa7977

File tree

3 files changed

+191
-75
lines changed

3 files changed

+191
-75
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
- Allow multiplying any `Quantity` by a `float` too. This just scales the `Quantity` value.
1414

1515
## Bug Fixes
1616

src/frequenz/sdk/timeseries/_quantities.py

Lines changed: 128 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,19 @@ def __sub__(self, other: Self) -> Self:
334334
difference._base_value = self._base_value - other._base_value
335335
return difference
336336

337-
def __mul__(self, percent: Percentage) -> Self:
337+
@overload
338+
def __mul__(self, scalar: float, /) -> Self:
339+
"""Scale this quantity by a scalar.
340+
341+
Args:
342+
scalar: The scalar by which to scale this quantity.
343+
344+
Returns:
345+
The scaled quantity.
346+
"""
347+
348+
@overload
349+
def __mul__(self, percent: Percentage, /) -> Self:
338350
"""Scale this quantity by a percentage.
339351
340352
Args:
@@ -343,12 +355,23 @@ def __mul__(self, percent: Percentage) -> Self:
343355
Returns:
344356
The scaled quantity.
345357
"""
346-
if not isinstance(percent, Percentage):
347-
return NotImplemented
348358

349-
product = type(self).__new__(type(self))
350-
product._base_value = self._base_value * percent.as_fraction()
351-
return product
359+
def __mul__(self, value: float | Percentage, /) -> Self:
360+
"""Scale this quantity by a scalar or percentage.
361+
362+
Args:
363+
value: The scalar or percentage by which to scale this quantity.
364+
365+
Returns:
366+
The scaled quantity.
367+
"""
368+
match value:
369+
case float():
370+
return type(self)._new(self._base_value * value)
371+
case Percentage():
372+
return type(self)._new(self._base_value * value.as_fraction())
373+
case _:
374+
return NotImplemented
352375

353376
def __gt__(self, other: Self) -> bool:
354377
"""Return whether this quantity is greater than another.
@@ -583,19 +606,38 @@ def as_megawatts(self) -> float:
583606
"""
584607
return self._base_value / 1e6
585608

586-
@overload # type: ignore
587-
def __mul__(self, other: Percentage) -> Self:
609+
# We need the ignore here because otherwise mypy will give this error:
610+
# > Overloaded operator methods can't have wider argument types in overrides
611+
# The problem seems to be when the other type implements an **incompatible**
612+
# __rmul__ method, which is not the case here, so we should be safe.
613+
# Please see this example:
614+
# https://github.com/python/mypy/blob/c26f1297d4f19d2d1124a30efc97caebb8c28616/test-data/unit/check-overloading.test#L4738C1-L4769C55
615+
# And a discussion in a mypy issue here:
616+
# https://github.com/python/mypy/issues/4985#issuecomment-389692396
617+
@overload # type: ignore[override]
618+
def __mul__(self, scalar: float, /) -> Self:
619+
"""Scale this power by a scalar.
620+
621+
Args:
622+
scalar: The scalar by which to scale this power.
623+
624+
Returns:
625+
The scaled power.
626+
"""
627+
628+
@overload
629+
def __mul__(self, percent: Percentage, /) -> Self:
588630
"""Scale this power by a percentage.
589631
590632
Args:
591-
other: The percentage by which to scale this power.
633+
percent: The percentage by which to scale this power.
592634
593635
Returns:
594636
The scaled power.
595637
"""
596638

597639
@overload
598-
def __mul__(self, other: timedelta) -> Energy:
640+
def __mul__(self, other: timedelta, /) -> Energy:
599641
"""Return an energy from multiplying this power by the given duration.
600642
601643
Args:
@@ -605,23 +647,22 @@ def __mul__(self, other: timedelta) -> Energy:
605647
The calculated energy.
606648
"""
607649

608-
def __mul__(self, other: Percentage | timedelta) -> Self | Energy:
650+
def __mul__(self, other: float | Percentage | timedelta, /) -> Self | Energy:
609651
"""Return a power or energy from multiplying this power by the given value.
610652
611653
Args:
612-
other: The percentage or duration to multiply by.
654+
other: The scalar, percentage or duration to multiply by.
613655
614656
Returns:
615657
A power or energy.
616658
"""
617-
if isinstance(other, Percentage):
618-
return super().__mul__(other)
619-
if isinstance(other, timedelta):
620-
return Energy.from_watt_hours(
621-
self._base_value * other.total_seconds() / 3600.0
622-
)
623-
624-
return NotImplemented
659+
match other:
660+
case float() | Percentage():
661+
return super().__mul__(other)
662+
case timedelta():
663+
return Energy._new(self._base_value * other.total_seconds() / 3600.0)
664+
case _:
665+
return NotImplemented
625666

626667
@overload
627668
def __truediv__(self, other: Current) -> Voltage:
@@ -725,19 +766,31 @@ def as_milliamperes(self) -> float:
725766
"""
726767
return self._base_value * 1e3
727768

728-
@overload # type: ignore
729-
def __mul__(self, other: Percentage) -> Self:
769+
# See comment for Power.__mul__ for why we need the ignore here.
770+
@overload # type: ignore[override]
771+
def __mul__(self, scalar: float, /) -> Self:
772+
"""Scale this current by a scalar.
773+
774+
Args:
775+
scalar: The scalar by which to scale this current.
776+
777+
Returns:
778+
The scaled current.
779+
"""
780+
781+
@overload
782+
def __mul__(self, percent: Percentage, /) -> Self:
730783
"""Scale this current by a percentage.
731784
732785
Args:
733-
other: The percentage by which to scale this current.
786+
percent: The percentage by which to scale this current.
734787
735788
Returns:
736789
The scaled current.
737790
"""
738791

739792
@overload
740-
def __mul__(self, other: Voltage) -> Power:
793+
def __mul__(self, other: Voltage, /) -> Power:
741794
"""Multiply the current by a voltage to get a power.
742795
743796
Args:
@@ -747,21 +800,22 @@ def __mul__(self, other: Voltage) -> Power:
747800
The calculated power.
748801
"""
749802

750-
def __mul__(self, other: Percentage | Voltage) -> Self | Power:
803+
def __mul__(self, other: float | Percentage | Voltage, /) -> Self | Power:
751804
"""Return a current or power from multiplying this current by the given value.
752805
753806
Args:
754-
other: The percentage or voltage to multiply by.
807+
other: The scalar, percentage or voltage to multiply by.
755808
756809
Returns:
757810
A current or power.
758811
"""
759-
if isinstance(other, Percentage):
760-
return super().__mul__(other)
761-
if isinstance(other, Voltage):
762-
return Power.from_watts(self._base_value * other._base_value)
763-
764-
return NotImplemented
812+
match other:
813+
case float() | Percentage():
814+
return super().__mul__(other)
815+
case Voltage():
816+
return Power._new(self._base_value * other._base_value)
817+
case _:
818+
return NotImplemented
765819

766820

767821
class Voltage(
@@ -840,19 +894,31 @@ def as_kilovolts(self) -> float:
840894
"""
841895
return self._base_value / 1e3
842896

843-
@overload # type: ignore
844-
def __mul__(self, other: Percentage) -> Self:
897+
# See comment for Power.__mul__ for why we need the ignore here.
898+
@overload # type: ignore[override]
899+
def __mul__(self, scalar: float, /) -> Self:
900+
"""Scale this voltage by a scalar.
901+
902+
Args:
903+
scalar: The scalar by which to scale this voltage.
904+
905+
Returns:
906+
The scaled voltage.
907+
"""
908+
909+
@overload
910+
def __mul__(self, percent: Percentage, /) -> Self:
845911
"""Scale this voltage by a percentage.
846912
847913
Args:
848-
other: The percentage by which to scale this voltage.
914+
percent: The percentage by which to scale this voltage.
849915
850916
Returns:
851917
The scaled voltage.
852918
"""
853919

854920
@overload
855-
def __mul__(self, other: Current) -> Power:
921+
def __mul__(self, other: Current, /) -> Power:
856922
"""Multiply the voltage by the current to get the power.
857923
858924
Args:
@@ -862,21 +928,22 @@ def __mul__(self, other: Current) -> Power:
862928
The calculated power.
863929
"""
864930

865-
def __mul__(self, other: Percentage | Current) -> Self | Power:
931+
def __mul__(self, other: float | Percentage | Current, /) -> Self | Power:
866932
"""Return a voltage or power from multiplying this voltage by the given value.
867933
868934
Args:
869-
other: The percentage or current to multiply by.
935+
other: The scalar, percentage or current to multiply by.
870936
871937
Returns:
872938
The calculated voltage or power.
873939
"""
874-
if isinstance(other, Percentage):
875-
return super().__mul__(other)
876-
if isinstance(other, Current):
877-
return Power.from_watts(self._base_value * other._base_value)
878-
879-
return NotImplemented
940+
match other:
941+
case float() | Percentage():
942+
return super().__mul__(other)
943+
case Current():
944+
return Power._new(self._base_value * other._base_value)
945+
case _:
946+
return NotImplemented
880947

881948

882949
class Energy(
@@ -959,6 +1026,23 @@ def as_megawatt_hours(self) -> float:
9591026
"""
9601027
return self._base_value / 1e6
9611028

1029+
def __mul__(self, other: float | Percentage) -> Self:
1030+
"""Scale this energy by a percentage.
1031+
1032+
Args:
1033+
other: The percentage by which to scale this energy.
1034+
1035+
Returns:
1036+
The scaled energy.
1037+
"""
1038+
match other:
1039+
case float():
1040+
return self._new(self._base_value * other)
1041+
case Percentage():
1042+
return self._new(self._base_value * other.as_fraction())
1043+
case _:
1044+
return NotImplemented
1045+
9621046
@overload
9631047
def __truediv__(self, other: timedelta) -> Power:
9641048
"""Return a power from dividing this energy by the given duration.

tests/timeseries/test_quantities.py

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -544,31 +544,69 @@ def test_abs() -> None:
544544
assert abs(-pct) == Percentage.from_fraction(30)
545545

546546

547-
def test_quantity_multiplied_with_precentage() -> None:
547+
@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity])
548+
# Use a small amount to avoid long running tests, we have too many combinations
549+
@hypothesis.settings(max_examples=10)
550+
@hypothesis.given(
551+
quantity_value=st.floats(
552+
allow_infinity=False,
553+
allow_nan=False,
554+
allow_subnormal=False,
555+
# We need to set this because otherwise constructors with big exponents will
556+
# cause the value to be too big for the float type, and the test will fail.
557+
max_value=1e298,
558+
min_value=-1e298,
559+
),
560+
percent=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False),
561+
)
562+
def test_quantity_multiplied_with_precentage(
563+
quantity_ctor: type[Quantity], quantity_value: float, percent: float
564+
) -> None:
548565
"""Test the multiplication of all quantities with percentage."""
549-
percentage = Percentage.from_percent(50)
550-
power = Power.from_watts(1000.0)
551-
voltage = Voltage.from_volts(230.0)
552-
current = Current.from_amperes(2)
553-
energy = Energy.from_kilowatt_hours(12)
554-
percentage_ = Percentage.from_percent(50)
555-
556-
assert power * percentage == Power.from_watts(500.0)
557-
assert voltage * percentage == Voltage.from_volts(115.0)
558-
assert current * percentage == Current.from_amperes(1)
559-
assert energy * percentage == Energy.from_kilowatt_hours(6)
560-
assert percentage_ * percentage == Percentage.from_percent(25)
561-
562-
power *= percentage
563-
assert power == Power.from_watts(500.0)
564-
voltage *= percentage
565-
assert voltage == Voltage.from_volts(115.0)
566-
current *= percentage
567-
assert current == Current.from_amperes(1)
568-
energy *= percentage
569-
assert energy == Energy.from_kilowatt_hours(6)
570-
percentage_ *= percentage
571-
assert percentage_ == Percentage.from_percent(25)
566+
percentage = Percentage.from_percent(percent)
567+
quantity = quantity_ctor(quantity_value)
568+
expected_value = quantity.base_value * (percent / 100.0)
569+
print(f"{quantity=}, {percentage=}, {expected_value=}")
570+
571+
product = quantity * percentage
572+
print(f"{product=}")
573+
assert product.base_value == expected_value
574+
575+
quantity *= percentage
576+
print(f"*{quantity=}")
577+
assert quantity.base_value == expected_value
578+
579+
580+
@pytest.mark.parametrize("quantity_ctor", _QUANTITY_CTORS + [Quantity])
581+
# Use a small amount to avoid long running tests, we have too many combinations
582+
@hypothesis.settings(max_examples=10)
583+
@hypothesis.given(
584+
quantity_value=st.floats(
585+
allow_infinity=False,
586+
allow_nan=False,
587+
allow_subnormal=False,
588+
# We need to set this because otherwise constructors with big exponents will
589+
# cause the value to be too big for the float type, and the test will fail.
590+
max_value=1e298,
591+
min_value=-1e298,
592+
),
593+
scalar=st.floats(allow_infinity=False, allow_nan=False, allow_subnormal=False),
594+
)
595+
def test_quantity_multiplied_with_float(
596+
quantity_ctor: type[Quantity], quantity_value: float, scalar: float
597+
) -> None:
598+
"""Test the multiplication of all quantities with a float."""
599+
quantity = quantity_ctor(quantity_value)
600+
expected_value = quantity.base_value * scalar
601+
print(f"{quantity=}, {expected_value=}")
602+
603+
product = quantity * scalar
604+
print(f"{product=}")
605+
assert product.base_value == expected_value
606+
607+
quantity *= scalar
608+
print(f"*{quantity=}")
609+
assert quantity.base_value == expected_value
572610

573611

574612
def test_invalid_multiplications() -> None:
@@ -602,12 +640,6 @@ def test_invalid_multiplications() -> None:
602640
with pytest.raises(TypeError):
603641
energy *= quantity # type: ignore
604642

605-
for quantity in [power, voltage, current, energy, Percentage.from_percent(50)]:
606-
with pytest.raises(TypeError):
607-
_ = quantity * 200.0 # type: ignore
608-
with pytest.raises(TypeError):
609-
quantity *= 200.0 # type: ignore
610-
611643

612644
# We can't use _QUANTITY_TYPES here, because it will break the tests, as hypothesis
613645
# will generate more values, some of which are unsupported by the quantities. See the

0 commit comments

Comments
 (0)