Skip to content

Commit ac0f39d

Browse files
authored
Optimize buck/boost converter calculations, experimental infrastructure for serialized functions (#410)
Optimizes component calculation for buck and boost converters by allowing the inductor picker to trade-off inductance and max current rating. This is enabled by new experimental infrastructure for serialized functions, that allows the inductor to take in an additional functional filter that encodes this trade-off calculated per-part, instead of static (and worst-case) inductance / current pairs, which would lead to over-sized (and higher-DCR) inductors. Changes the buck / boost converter calculation to be more principled. It now uses two bounds, the optional ripple ratio at the actual output current (defaults to unbounded / unused, allowing wide range for optimization), and the fallback ripple ratio (0.1-0.5) at the switch current limits. The fallback ratio provides a minimum ripple current and maximum inductance at light-load operations where the converter will likely go into DCM. DCM effects are not modeled, since that would require additional parameters like t_on, min. Instead, the fallback / limit ripple ratio is meant to provide a sane component sizing for DCM operation. THIS IS AN API-BREAKING CHANGE. In most cases, Buck/BoostConverterPowerPath users will only need to delete the ripple_current parameter, which is now calculated in the power path. SwitchingVoltageRegulator also no longer provides a ripple_current_factor variable, and the passthrough to the power path can be eliminated. Updates netlists too, the new inductor sizes are closer to values given in the datasheet. This mostly affects IC-based converters that are operating at a fraction of their rated load, and where the ripple ratio can be wider. For custom converters which have a specified ripple ratio, there is less room to optimize. Detailed changes: - Ripple current factor removed from SwitchingVoltageRegulator - it's an internal variable that the system optimizes now. This also better accommodates all-in-one switching regulators where this may not be a specified parameter. - This is still required for custom buck / boost converters, since there isn't a reasonable fallback and is required to constrain inductance. - For custom converters, the parameter is renamed to ripple_ratio for consistency. - Buck/BoostConverterPowerPath _calculate_parameters now returns scales for inductance and capacitance (buck only, boost capacitance not dependent on ripple current) - Clarifies current_limits as sw_current_limits (peak current limit) - Adds optional ripple_ratio parameter - Inductor currents account for efficiency factor - Refactor all buck / boost converters to remove the inductor_current_ripple - Validate and correct that switch currents are used in the *PowerPath constructors - Remove fixed inductors in examples to allow new inductors to be tested Infrastructure changes: - Add CastableTypes to all ConstraintExpr - Add tighter bounds to then_else type and allow casting in one of then or else exprs. Resolves #405
1 parent c01d3e6 commit ac0f39d

61 files changed

Lines changed: 855 additions & 647 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

edg/abstract_parts/AbstractInductor.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Dict, Optional, cast
22

33
from ..electronics_model import *
4-
from .PartsTable import PartsTableColumn, PartsTableRow
4+
from .PartsTable import PartsTableColumn, PartsTableRow, ExperimentalUserFnPartsTable
55
from .PartsTablePart import PartsTableSelector
66
from .Categories import *
77
from .StandardFootprint import StandardFootprint, HasStandardFootprint
@@ -94,7 +94,9 @@ def symbol_pinning(self, symbol_name: str) -> Dict[str, BasePort]:
9494
def __init__(self, inductance: RangeLike,
9595
current: RangeLike = RangeExpr.ZERO,
9696
frequency: RangeLike = RangeExpr.ZERO,
97-
resistance_dc: RangeLike = (0, 1)*Ohm # generic sane choice?
97+
resistance_dc: RangeLike = (0, 1)*Ohm, # generic sane choice?
98+
*,
99+
experimental_filter_fn: StringLike = ""
98100
) -> None:
99101
super().__init__()
100102

@@ -105,6 +107,7 @@ def __init__(self, inductance: RangeLike,
105107
self.current = self.ArgParameter(current) # defined as operating current range, non-directioned
106108
self.frequency = self.ArgParameter(frequency) # defined as operating frequency range
107109
self.resistance_dc = self.ArgParameter(resistance_dc)
110+
self.experimental_filter_fn = self.ArgParameter(experimental_filter_fn)
108111

109112
self.actual_inductance = self.Parameter(RangeExpr())
110113
self.actual_current_rating = self.Parameter(RangeExpr())
@@ -136,15 +139,23 @@ class TableInductor(PartsTableSelector, Inductor):
136139
@init_in_parent
137140
def __init__(self, *args, **kwargs) -> None:
138141
super().__init__(*args, **kwargs)
139-
self.generator_param(self.inductance, self.current, self.frequency, self.resistance_dc)
142+
self.generator_param(self.inductance, self.current, self.frequency, self.resistance_dc,
143+
self.experimental_filter_fn)
140144

141145
def _row_filter(self, row: PartsTableRow) -> bool:
142146
# TODO eliminate arbitrary DCR limit in favor of exposing max DCR to upper levels
147+
filter_fn_str = self.get(self.experimental_filter_fn)
148+
if filter_fn_str:
149+
filter_fn = ExperimentalUserFnPartsTable.deserialize_fn(filter_fn_str)
150+
else:
151+
filter_fn = None
152+
143153
return super()._row_filter(row) and \
144154
row[self.INDUCTANCE].fuzzy_in(self.get(self.inductance)) and \
145155
self.get(self.current).fuzzy_in(row[self.CURRENT_RATING]) and \
146156
row[self.DC_RESISTANCE].fuzzy_in(self.get(self.resistance_dc)) and \
147-
self.get(self.frequency).fuzzy_in(row[self.FREQUENCY_RATING])
157+
self.get(self.frequency).fuzzy_in(row[self.FREQUENCY_RATING]) and\
158+
(filter_fn is None or filter_fn(row))
148159

149160
def _row_generate(self, row: PartsTableRow) -> None:
150161
super()._row_generate(row)

edg/abstract_parts/AbstractPowerConverters.py

Lines changed: 240 additions & 141 deletions
Large diffs are not rendered by default.

edg/abstract_parts/PartsTable.py

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,12 @@
22

33
import csv
44
import itertools
5-
import sys
65
from typing import TypeVar, Generic, Type, overload, Union, Callable, List, Dict, Any, KeysView, Optional, OrderedDict, \
7-
cast
6+
cast, Tuple, Sequence, Protocol
87

9-
if sys.version_info[1] < 8:
10-
from typing_extensions import Protocol
11-
else:
12-
from typing import Protocol
8+
from typing_extensions import ParamSpec
9+
10+
from ..core import Range
1311

1412

1513
# from https://stackoverflow.com/questions/47965083/comparable-types-with-mypy
@@ -152,3 +150,90 @@ def first(self, err="no elements in list") -> PartsTableRow:
152150
if not self.rows:
153151
raise IndexError(err)
154152
return self.rows[0]
153+
154+
155+
UserFnMetaParams = ParamSpec('UserFnMetaParams')
156+
UserFnType = TypeVar('UserFnType', bound=Callable, covariant=True)
157+
class UserFnSerialiable(Protocol[UserFnMetaParams, UserFnType]):
158+
"""A protocol that marks functions as usable in deserialize, that they have been registered."""
159+
_is_serializable: None # guard attribute
160+
161+
def __call__(self, *args: UserFnMetaParams.args, **kwargs: UserFnMetaParams.kwargs) -> UserFnType: ...
162+
__name__: str
163+
164+
165+
class ExperimentalUserFnPartsTable(PartsTable):
166+
"""A PartsTable that can take in a user-defined function for filtering and (possibly) other operations.
167+
These functions are serialized to a string by an internal name (cannot execute arbitrary code,
168+
bounded to defined functions in the codebase), and some arguments can be serialized with the name
169+
(think partial(...)).
170+
Functions must be pre-registered using the @ExperimentalUserFnPartsTable.user_fn(...) decorator,
171+
non-pre-registered functions will not be available.
172+
173+
This is intended to support searches on parts tables that are cross-coupled across multiple parameters,
174+
but still restricted to within on table (e.g., no cross-optimizing RC filters).
175+
176+
EXPERIMENTAL - subject to change without notice."""
177+
178+
_FN_SERIALIZATION_SEPARATOR = ";"
179+
180+
_user_fns: Dict[str, Tuple[Callable, Sequence[Type]]] = {} # name -> fn, [arg types]
181+
_fn_name_dict: Dict[Callable, str] = {}
182+
183+
@staticmethod
184+
def user_fn(param_types: Sequence[Type] = []) -> Callable[[Callable[UserFnMetaParams, UserFnType]],
185+
UserFnSerialiable[UserFnMetaParams, UserFnType]]:
186+
def decorator(fn: Callable[UserFnMetaParams, UserFnType]) -> UserFnSerialiable[UserFnMetaParams, UserFnType]:
187+
"""Decorator to register a user function that can be used in ExperimentalUserFnPartsTable."""
188+
if fn.__name__ in ExperimentalUserFnPartsTable._user_fns or fn in ExperimentalUserFnPartsTable._fn_name_dict:
189+
raise ValueError(f"Function {fn.__name__} already registered.")
190+
ExperimentalUserFnPartsTable._user_fns[fn.__name__] = (fn, param_types)
191+
ExperimentalUserFnPartsTable._fn_name_dict[fn] = fn.__name__
192+
return fn # type: ignore
193+
return decorator
194+
195+
@classmethod
196+
def serialize_fn(cls, fn: UserFnSerialiable[UserFnMetaParams, UserFnType],
197+
*args: UserFnMetaParams.args, **kwargs: UserFnMetaParams.kwargs) -> str:
198+
"""Serializes a user function to a string."""
199+
assert not kwargs, "kwargs not supported in serialization"
200+
if fn not in cls._fn_name_dict:
201+
raise ValueError(f"Function {fn} not registered.")
202+
fn_ctor, fn_argtypes = cls._user_fns[fn.__name__]
203+
def serialize_arg(tpe: Type, val: Any) -> str:
204+
assert isinstance(val, tpe), f"in serialize {val}, expected {tpe}, got {type(val)}"
205+
if tpe is bool:
206+
return str(val)
207+
elif tpe is int:
208+
return str(val)
209+
elif tpe is float:
210+
return str(val)
211+
elif tpe is Range:
212+
return f"({val.lower},{val.upper})"
213+
else:
214+
raise TypeError(f"cannot serialize type {tpe} in user function serialization")
215+
serialized_args = [serialize_arg(tpe, arg) for tpe, arg in zip(fn_argtypes, args)]
216+
return cls._FN_SERIALIZATION_SEPARATOR.join([fn.__name__] + serialized_args)
217+
218+
@classmethod
219+
def deserialize_fn(cls, serialized: str) -> Callable:
220+
"""Deserializes a user function from a string."""
221+
split = serialized.split(cls._FN_SERIALIZATION_SEPARATOR)
222+
if split[0] not in cls._user_fns:
223+
raise ValueError(f"Function {serialized} not registered.")
224+
fn_ctor, fn_argtypes = cls._user_fns[split[0]]
225+
assert len(split) == len(fn_argtypes) + 1
226+
def deserialize_arg(tpe: Type, val: str) -> Any:
227+
if tpe is bool:
228+
return val == 'True'
229+
elif tpe is int:
230+
return int(val)
231+
elif tpe is float:
232+
return float(val)
233+
elif tpe is Range:
234+
parts = val[1:-1].split(",")
235+
return Range(float(parts[0]), float(parts[1])) # type: ignore
236+
else:
237+
raise TypeError(f"cannot deserialize type {tpe} in user function serialization")
238+
deserialized_args = [deserialize_arg(tpe, arg) for tpe, arg in zip(fn_argtypes, split[1:])]
239+
return fn_ctor(*deserialized_args)

edg/abstract_parts/test_parts_table.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,49 @@ def test_map(self) -> None:
7575

7676
def test_first(self) -> None:
7777
self.assertEqual(self.table.first().value, {'header1': '1', 'header2': 'foo', 'header3': '9'})
78+
79+
80+
class UserFnPartsTableTest(unittest.TestCase):
81+
@staticmethod
82+
@ExperimentalUserFnPartsTable.user_fn()
83+
def user_fn_false() -> Callable[[], bool]:
84+
def inner() -> bool:
85+
return False
86+
return inner
87+
88+
@staticmethod
89+
@ExperimentalUserFnPartsTable.user_fn([bool])
90+
def user_fn_bool_pass(meta_arg: bool) -> Callable[[], bool]:
91+
def inner() -> bool:
92+
return meta_arg
93+
return inner
94+
95+
@staticmethod
96+
@ExperimentalUserFnPartsTable.user_fn([float])
97+
def user_fn_float_pass(meta_arg: float) -> Callable[[], float]:
98+
def inner() -> float:
99+
return meta_arg
100+
return inner
101+
102+
@staticmethod
103+
def user_fn_unserialized() -> Callable[[], None]:
104+
def inner() -> None:
105+
return None
106+
return inner
107+
108+
def test_serialize_deserialize(self) -> None:
109+
self.assertEqual(ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_false), 'user_fn_false')
110+
self.assertEqual(ExperimentalUserFnPartsTable.deserialize_fn(
111+
ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_false))(), False)
112+
113+
self.assertEqual(ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_bool_pass, False),
114+
'user_fn_bool_pass;False')
115+
self.assertEqual(ExperimentalUserFnPartsTable.deserialize_fn(
116+
ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_bool_pass, False))(), False)
117+
self.assertEqual(ExperimentalUserFnPartsTable.deserialize_fn(
118+
ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_bool_pass, True))(), True)
119+
120+
self.assertEqual(ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_float_pass, 0.42),
121+
'user_fn_float_pass;0.42')
122+
self.assertEqual(ExperimentalUserFnPartsTable.deserialize_fn(
123+
ExperimentalUserFnPartsTable.serialize_fn(self.user_fn_float_pass, 0.42))(), 0.42)

edg/abstract_parts/test_switching_converters.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,21 @@
44
from ..core import Range
55

66

7-
class BuckConverterCalculationTest(unittest.TestCase):
7+
class SwitchingConverterCalculationTest(unittest.TestCase):
88
def test_buck_converter(self):
9-
values_ref = BuckConverterPowerPath.calculate_parameters(
9+
values_ref = BuckConverterPowerPath._calculate_parameters(
1010
Range.exact(5), Range.exact(2.5), Range.exact(100e3), Range.exact(1),
11-
Range.exact(0.1), 0.01, 0.001,
11+
Range.exact(1), Range.exact(0.1), 0.01, 0.001,
1212
efficiency=Range.exact(1)
1313
)
1414
self.assertEqual(values_ref.dutycycle, Range.exact(0.5))
1515
# validated against https://www.omnicalculator.com/physics/buck-converter
1616
self.assertEqual(values_ref.inductance, Range.exact(125e-6))
1717

1818
# test that component values are calculated for worst-case conversion
19-
values = BuckConverterPowerPath.calculate_parameters(
19+
values = BuckConverterPowerPath._calculate_parameters(
2020
Range(4, 5), Range(2.5, 4), Range.exact(100e3), Range.exact(1),
21-
Range.exact(0.1), 0.01, 0.001,
21+
Range.exact(1), Range.exact(0.1), 0.01, 0.001,
2222
efficiency=Range.exact(1)
2323
)
2424
self.assertEqual(values_ref.inductance, values.inductance)
@@ -27,37 +27,38 @@ def test_buck_converter(self):
2727

2828
def test_buck_converter_example(self):
2929
# using the example from https://passive-components.eu/buck-converter-design-and-calculation/
30-
values = BuckConverterPowerPath.calculate_parameters(
30+
values = BuckConverterPowerPath._calculate_parameters(
3131
Range.exact(12 + 0.4), Range.exact(3.3 + 0.4), Range.exact(500e3), Range.exact(1),
32-
Range.exact(0.35), 1, 0.0165,
32+
Range.exact(2), Range.exact(0.35), 1, 0.0165,
3333
efficiency=Range.exact(1)
3434
)
3535
self.assertAlmostEqual(values.dutycycle.upper, 0.298, places=3)
3636
self.assertAlmostEqual(values.inductance.upper, 14.8e-6, places=7)
3737

3838
# the example uses a ripple current of 0.346 for the rest of the calculations
39-
values = BuckConverterPowerPath.calculate_parameters(
39+
values = BuckConverterPowerPath._calculate_parameters(
4040
Range.exact(12 + 0.4), Range.exact(3.3 + 0.4), Range.exact(500e3), Range.exact(1),
41-
Range.exact(0.346), 1, 0.0165,
41+
Range.exact(2), Range.exact(0.346), 1, 0.0165,
4242
efficiency=Range.exact(1)
4343
)
4444
self.assertAlmostEqual(values.inductor_peak_currents.upper, 1.173, places=3)
4545
self.assertAlmostEqual(values.output_capacitance.lower, 5.24e-6, places=7)
4646

4747
def test_boost_converter(self):
48-
values_ref = BoostConverterPowerPath.calculate_parameters(
49-
Range.exact(5), Range.exact(10), Range.exact(100e3), Range.exact(1),
50-
Range.exact(0.1), 0.01, 0.001,
48+
values_ref = BoostConverterPowerPath._calculate_parameters(
49+
Range.exact(5), Range.exact(10), Range.exact(100e3), Range.exact(0.5),
50+
Range.exact(2), Range.exact(0.4), 0.01, 0.001,
5151
efficiency=Range.exact(1)
5252
)
5353
self.assertEqual(values_ref.dutycycle, Range.exact(0.5))
5454
# validated against https://www.omnicalculator.com/physics/boost-converter
55-
self.assertEqual(values_ref.inductance, Range.exact(250e-6))
55+
self.assertEqual(values_ref.inductance, Range.exact(62.5e-6))
56+
self.assertEqual(values_ref.inductor_avg_current, Range.exact(1))
5657

5758
# test that component values are calculated for worst-case conversion
58-
values = BoostConverterPowerPath.calculate_parameters(
59-
Range(5, 8), Range(7, 10), Range.exact(100e3), Range.exact(1),
60-
Range.exact(0.1), 0.01, 0.001,
59+
values = BoostConverterPowerPath._calculate_parameters(
60+
Range(5, 8), Range(7, 10), Range.exact(100e3), Range.exact(0.5),
61+
Range.exact(2), Range.exact(0.4), 0.01, 0.001,
6162
efficiency=Range.exact(1)
6263
)
6364
self.assertEqual(values_ref.inductance, values.inductance)
@@ -66,19 +67,19 @@ def test_boost_converter(self):
6667

6768
def test_boost_converter_example(self):
6869
# using the example from https://passive-components.eu/boost-converter-design-and-calculation/
69-
# 0.4342A ripple current from .35 factor in example converted in output current terms
70-
values = BoostConverterPowerPath.calculate_parameters(
70+
values = BoostConverterPowerPath._calculate_parameters(
7171
Range.exact(5), Range.exact(12 + 0.4), Range.exact(500e3), Range.exact(0.5),
72-
Range.exact(0.4342), 1, 1,
72+
Range.exact(2), Range.exact(0.35), 1, 1,
7373
efficiency=Range.exact(1)
7474
)
7575
self.assertAlmostEqual(values.dutycycle.upper, 0.597, places=3)
7676
self.assertAlmostEqual(values.inductance.upper, 13.75e-6, places=7)
77+
self.assertAlmostEqual(values.inductor_avg_current.upper, 1.24, places=2)
7778

7879
# the example continues with a normalized inductance of 15uH
79-
values = BoostConverterPowerPath.calculate_parameters(
80+
values = BoostConverterPowerPath._calculate_parameters(
8081
Range.exact(5), Range.exact(12 + 0.4), Range.exact(500e3), Range.exact(0.5),
81-
Range.exact(.4342*13.75/15), 0.01, 0.06,
82+
Range.exact(2), Range.exact(0.321), 0.01, 0.06,
8283
efficiency=Range.exact(1)
8384
)
8485
self.assertAlmostEqual(values.dutycycle.upper, 0.597, places=3)

edg/core/ConstraintExpr.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class ConstraintExpr(Refable, Generic[WrappedType, CastableType]):
2626
"""Base class for constraint expressions. Basically a container for operations.
2727
Actual meaning is held in the Binding.
2828
"""
29+
_CASTABLE_TYPES: Tuple[Type[CastableType], ...] # for use in ininstance(), excluding self-cls
30+
2931
def __repr__(self) -> str:
3032
if self.binding is not None and self.initializer is not None:
3133
return f"{super().__repr__()}({self.binding})={self.initializer}"
@@ -101,6 +103,9 @@ def __eq__(self: SelfType, other: ConstraintExprCastable) -> BoolExpr: #type: i
101103
BoolLike = Union[bool, 'BoolExpr']
102104
class BoolExpr(ConstraintExpr[bool, BoolLike]):
103105
"""Boolean expression, can be used as a constraint"""
106+
107+
_CASTABLE_TYPES = (bool, )
108+
104109
@classmethod
105110
def _to_expr_type(cls, input: BoolLike) -> BoolExpr:
106111
if isinstance(input, BoolExpr):
@@ -159,11 +164,23 @@ def implies(self, target: BoolLike) -> BoolExpr:
159164
def __invert__(self) -> BoolExpr:
160165
return self._new_bind(UnaryOpBinding(self, BoolOp.op_not))
161166

162-
167+
# does not seem possible to restrict type-params of type vars pre-Python3.12
168+
# so where a single cast is needed we're stuck with Any
163169
IteType = TypeVar('IteType', bound=ConstraintExpr)
164-
def then_else(self, then_val: IteType, else_val: IteType) -> IteType:
165-
assert isinstance(then_val, type(else_val)) and isinstance(else_val, type(then_val)), \
166-
f"if-then-else results must be of same type, got then={then_val}, else={else_val}"
170+
@overload
171+
def then_else(self, then_val: IteType, else_val: IteType) -> IteType: ... # optional strongest-typed version
172+
@overload
173+
def then_else(self, then_val: IteType, else_val: Any) -> IteType: ...
174+
@overload
175+
def then_else(self, then_val: Any, else_val: IteType) -> IteType: ...
176+
177+
def then_else(self, then_val: Any, else_val: Any) -> ConstraintExpr: # type: ignore
178+
if isinstance(then_val, ConstraintExpr):
179+
else_val = then_val._to_expr_type(else_val)
180+
elif isinstance(else_val, ConstraintExpr):
181+
then_val = else_val._to_expr_type(then_val)
182+
else:
183+
raise ValueError("either then_val or else_val must be ConstraintExpr, TODO support dual-casting")
167184
assert self._is_bound() and then_val._is_bound() and else_val._is_bound()
168185
return then_val._new_bind(IfThenElseBinding(self, then_val, else_val))
169186

@@ -173,8 +190,6 @@ def then_else(self, then_val: IteType, else_val: IteType) -> IteType:
173190
class NumLikeExpr(ConstraintExpr[WrappedType, NumLikeCastable], Generic[WrappedType, NumLikeCastable]):
174191
"""Trait for numeric-like expressions, providing common arithmetic operations"""
175192

176-
_CASTABLE_TYPES: Tuple[Type[NumLikeCastable], ...] # NumLikeCastable for use in ininstance(), excluding self-cls
177-
178193
@classmethod
179194
@abstractmethod
180195
def _to_expr_type(cls: Type[NumLikeSelfType],
@@ -479,6 +494,9 @@ def abs(self) -> RangeExpr:
479494
StringLike = Union['StringExpr', str]
480495
class StringExpr(ConstraintExpr[str, StringLike]):
481496
"""String expression, can be used as a constraint"""
497+
498+
_CASTABLE_TYPES = (str, )
499+
482500
@classmethod
483501
def _to_expr_type(cls, input: StringLike) -> StringExpr:
484502
if isinstance(input, StringExpr):
@@ -596,7 +614,8 @@ def __rmul__(self, other: Union[float, Range, Tuple[float, float]]) -> Union[Flo
596614
return FloatExpr._to_expr_type(other * self.scale)
597615
elif isinstance(other, Range):
598616
return RangeExpr._to_expr_type(other * self.scale)
599-
elif isinstance(other, tuple) and isinstance(other[0], (int, float)) and isinstance(other[1], (int, float)):
600-
return RangeExpr._to_expr_type(Range(other[0], other[1]) * self.scale)
617+
elif isinstance(other, tuple) and isinstance(other[0], (int, float, IntExpr, FloatExpr)) \
618+
and isinstance(other[1], (int, float, IntExpr, FloatExpr)):
619+
return RangeExpr._to_expr_type((other[0] * self.scale, other[1] * self.scale))
601620
else:
602621
raise TypeError(f"expected Float or Range Literal, got {other} of type {type(other)}")

0 commit comments

Comments
 (0)