diff --git a/edg/abstract_parts/AbstractPowerConverters.py b/edg/abstract_parts/AbstractPowerConverters.py index ad9108649..5d29276df 100644 --- a/edg/abstract_parts/AbstractPowerConverters.py +++ b/edg/abstract_parts/AbstractPowerConverters.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Optional +from typing import Optional, NamedTuple, Generic, TypeVar, Union from ..electronics_model import * from .Categories import * from .AbstractCapacitor import DecouplingCapacitor @@ -203,14 +203,49 @@ class BuckConverterPowerPath(InternalSubcircuit, GeneratorBlock): http://www.onmyphd.com/?p=voltage.regulators.buck.step.down.converter Very detailed analysis including component sizing, operating modes, calculating losses """ + + class Values(NamedTuple): + dutycycle: Range + inductance: Range + input_capacitance: Range + output_capacitance: Range + + inductor_peak_currents: Range # based on the worst case input spec + effective_dutycycle: Range # duty cycle adjusted for tracking behavior + + @classmethod + def calculate_parameters(cls, input_voltage: Range, output_voltage: Range, frequency: Range, output_current: Range, + inductor_current_ripple: Range, input_voltage_ripple: float, output_voltage_ripple: float, + efficiency: Range = Range(0.9, 1.0), dutycycle_limit: Range = Range(0.1, 0.9)) -> 'BuckConverterPowerPath.Values': + dutycycle = output_voltage / input_voltage / efficiency + effective_dutycycle = dutycycle.bound_to(dutycycle_limit) # account for tracking behavior + + # TODO different equation for DCM operation + inductor_peak_currents = Range(max(0, output_current.lower - inductor_current_ripple.upper / 2), + output_current.upper + inductor_current_ripple.upper / 2) + + # calculate minimum inductance based on worst case values (operating range corners producing maximum inductance) + # worst-case input/output voltages and frequency is used to avoid double-counting tolerances as ranges + inductance = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / + (inductor_current_ripple * frequency.lower * input_voltage.upper)) + + input_capacitance = Range.from_lower(output_current.upper * cls.max_d_inverse_d(effective_dutycycle) / + (frequency.lower * input_voltage_ripple)) + output_capacitance = Range.from_lower(inductor_current_ripple.upper / + (8 * frequency.lower * output_voltage_ripple)) + + return cls.Values(dutycycle=dutycycle, inductance=inductance, + input_capacitance=input_capacitance, output_capacitance=output_capacitance, + inductor_peak_currents=inductor_peak_currents, + effective_dutycycle= effective_dutycycle) + @init_in_parent def __init__(self, input_voltage: RangeLike, output_voltage: RangeLike, frequency: RangeLike, output_current: RangeLike, current_limits: RangeLike, inductor_current_ripple: RangeLike, *, input_voltage_ripple: FloatLike, output_voltage_ripple: FloatLike, efficiency: RangeLike = (0.9, 1.0), # from TI reference - dutycycle_limit: RangeLike = (0.1, 0.9), - inductor_scale: FloatLike = 1.0): # arbitrary + dutycycle_limit: RangeLike = (0.1, 0.9)): super().__init__() self.pwr_in = self.Port(VoltageSink.empty(), [Power]) # models the input cap only @@ -232,7 +267,6 @@ def __init__(self, input_voltage: RangeLike, output_voltage: RangeLike, frequenc self.input_voltage_ripple, self.output_voltage_ripple, self.dutycycle_limit) self.current_limits = self.ArgParameter(current_limits) - self.inductor_scale = self.ArgParameter(inductor_scale) self.actual_dutycycle = self.Parameter(RangeExpr()) self.actual_inductor_current_ripple = self.Parameter(RangeExpr()) @@ -261,45 +295,31 @@ def max_d_inverse_d(d_range: Range) -> float: def generate(self) -> None: super().generate() - input_voltage = self.get(self.input_voltage) - output_voltage = self.get(self.output_voltage) - frequency = self.get(self.frequency) - output_current = self.get(self.output_current) - inductor_current_ripple = self.get(self.inductor_current_ripple) - input_voltage_ripple = self.get(self.input_voltage_ripple) - output_voltage_ripple = self.get(self.output_voltage_ripple) - - dutycycle = output_voltage / input_voltage / self.get(self.efficiency) - self.assign(self.actual_dutycycle, dutycycle) - # if these are violated, these generally mean that the converter will start tracking the input - # these can (maybe?) be waived if tracking (plus losses) is acceptable - self.require(self.actual_dutycycle.within(self.dutycycle_limit), "dutycycle outside limit") - # these are actual numbers to be used in calculations, accounting for tracking behavior - effective_dutycycle = dutycycle.bound_to(self.get(self.dutycycle_limit)) - - # calculate minimum inductance based on worst case values (operating range corners producing maximum inductance) - # this range must be constructed manually to not double-count the tolerance stackup of the voltages - inductance_min = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (inductor_current_ripple.upper * frequency.lower * input_voltage.upper)) - if inductor_current_ripple.lower == 0: # basically infinite inductance - inductance_max = float('inf') - else: - inductance_max = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (inductor_current_ripple.lower * frequency.lower * input_voltage.upper)) - inductor_spec_peak_current = output_current.upper + inductor_current_ripple.upper / 2 + values = self.calculate_parameters(self.get(self.input_voltage), self.get(self.output_voltage), + self.get(self.frequency), self.get(self.output_current), + self.get(self.inductor_current_ripple), + self.get(self.input_voltage_ripple), self.get(self.output_voltage_ripple), + efficiency=self.get(self.efficiency), + dutycycle_limit=self.get(self.dutycycle_limit)) + self.assign(self.actual_dutycycle, values.dutycycle) + self.require(values.dutycycle == values.effective_dutycycle, "dutycycle outside limit") + + # TODO maximum current depends on the inductance, but this just uses a worst-case value for simplicity + # TODO ideally the inductor selector would take a function that can account for this coupled equation self.inductor = self.Block(Inductor( - inductance=(inductance_min, inductance_max)*Henry / self.inductor_scale, - current=(0, inductor_spec_peak_current), - frequency=frequency*Hertz + inductance=values.inductance*Henry, + current=values.inductor_peak_currents, + frequency=self.frequency )) - actual_ripple = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (self.inductor.actual_inductance * frequency.lower * input_voltage.upper)) - self.assign(self.actual_inductor_current_ripple, actual_ripple / self.inductor_scale) + # expand out the equation to avoid double-counting tolerance + actual_peak_ripple = (self.output_voltage.lower() * (self.input_voltage.upper() - self.output_voltage.lower()) / + (self.inductor.actual_inductance * self.frequency.lower() * self.input_voltage.upper())) + self.assign(self.actual_inductor_current_ripple, actual_peak_ripple) self.connect(self.switch, self.inductor.a.adapt_to(VoltageSink( voltage_limits=RangeExpr.ALL, - current_draw=self.pwr_out.link().current_drawn * dutycycle + current_draw=self.pwr_out.link().current_drawn * values.dutycycle ))) self.connect(self.pwr_out, self.inductor.b.adapt_to(VoltageSource( voltage_out=self.output_voltage, @@ -307,17 +327,12 @@ def generate(self) -> None: (self.actual_inductor_current_ripple.upper() / 2)) ))) - input_capacitance = Range.from_lower(output_current.upper * self.max_d_inverse_d(effective_dutycycle) / - (frequency.lower * input_voltage_ripple)) self.in_cap = self.Block(DecouplingCapacitor( - capacitance=input_capacitance*Farad, + capacitance=values.input_capacitance * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_in) - # TODO size based on transient response, add to voltage tolerance stackups - output_capacitance = Range.from_lower(inductor_current_ripple.upper / - (8 * frequency.lower * output_voltage_ripple)) self.out_cap = self.Block(DecouplingCapacitor( - capacitance=output_capacitance*Farad, + capacitance=values.output_capacitance * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_out) @@ -364,6 +379,50 @@ class BoostConverterPowerPath(InternalSubcircuit, GeneratorBlock): http://www.simonbramble.co.uk/dc_dc_converter_design/boost_converter/boost_converter_design.htm Detailed analysis of converter with discrete FET and diode """ + + class Values(NamedTuple): + dutycycle: Range + inductance: Range + input_capacitance: Range + output_capacitance: Range + + inductor_peak_currents: Range # based on the worst case input spec + effective_dutycycle: Range + + @classmethod + def calculate_parameters(cls, input_voltage: Range, output_voltage: Range, frequency: Range, output_current: Range, + inductor_current_ripple: Range, input_voltage_ripple: float, output_voltage_ripple: float, + efficiency: Range = Range(0.8, 1.0), dutycycle_limit: Range = Range(0.1, 0.9)) -> 'BoostConverterPowerPath.Values': + dutycycle = 1 - input_voltage / output_voltage * efficiency + effective_dutycycle = dutycycle.bound_to(dutycycle_limit) # account for tracking behavior + + # TODO different equation for DCM operation + inductor_peak_currents = Range(max(0, output_current.upper / (1 - effective_dutycycle.upper) + - inductor_current_ripple.upper / 2), + output_current.upper / (1 - effective_dutycycle.upper) + + inductor_current_ripple.upper / 2) + + # calculate minimum inductance based on worst case values (operating range corners producing maximum inductance) + # worst-case input/output voltages and frequency is used to avoid double-counting tolerances as ranges + inductance = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / + (inductor_current_ripple * frequency.lower * output_voltage.upper)) + + # Capacitor equation Q = CV => i = C dv/dt => for constant current, i * t = C dV => dV = i * t / C + # C = i * t / dV => C = i / (f * dV) + # Boost converter draws current from input throughout the entire cycle, and by conversation of power + # the average input current is Iin = Vout/Vin * Iout = 1/(1-D) * Iout + # Boost converter current should be much less spikey than buck converter current and probably + # less filtering than this is acceptable + input_capacitance = Range.from_lower((output_current.upper / (1 - effective_dutycycle.upper)) / + (frequency.lower * input_voltage_ripple)) + output_capacitance = Range.from_lower(output_current.upper * effective_dutycycle.upper / + (frequency.lower * output_voltage_ripple)) + + return cls.Values(dutycycle=dutycycle, inductance=inductance, + input_capacitance=input_capacitance, output_capacitance=output_capacitance, + inductor_peak_currents=inductor_peak_currents, + effective_dutycycle=effective_dutycycle) + @init_in_parent def __init__(self, input_voltage: RangeLike, output_voltage: RangeLike, frequency: RangeLike, output_current: RangeLike, current_limits: RangeLike, inductor_current_ripple: RangeLike, *, @@ -408,45 +467,28 @@ def contents(self): def generate(self) -> None: super().generate() - input_voltage = self.get(self.input_voltage) - output_voltage = self.get(self.output_voltage) - frequency = self.get(self.frequency) - output_current = self.get(self.output_current) - inductor_current_ripple = self.get(self.inductor_current_ripple) - input_voltage_ripple = self.get(self.input_voltage_ripple) - output_voltage_ripple = self.get(self.output_voltage_ripple) - - dutycycle = 1 - input_voltage / output_voltage * self.get(self.efficiency) - self.assign(self.actual_dutycycle, dutycycle) - # if these are violated, these generally mean that the converter will start tracking the input - # these can (maybe?) be waived if tracking (plus losses) is acceptable - self.require(self.actual_dutycycle.within(self.dutycycle_limit), "dutycycle outside limit") - # these are actual numbers to be used in calculations - effective_dutycycle = dutycycle.bound_to(self.get(self.dutycycle_limit)) - - # Calculate minimum inductance based on worst case values (operating range corners producing maximum inductance) - # This range must be constructed manually to not double-count the tolerance stackup of the voltages - inductance_min = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (inductor_current_ripple.upper * frequency.lower * output_voltage.lower)) - if inductor_current_ripple.lower == 0: # basically infinite inductance - inductance_max = float('inf') - else: - inductance_max = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (inductor_current_ripple.lower * frequency.lower * output_voltage.lower)) - inductor_spec_peak_current = inductor_current_ripple.upper / 2 + output_current.upper / (1 - effective_dutycycle.upper) + values = self.calculate_parameters(self.get(self.input_voltage), self.get(self.output_voltage), + self.get(self.frequency), self.get(self.output_current), + self.get(self.inductor_current_ripple), + self.get(self.input_voltage_ripple), self.get(self.output_voltage_ripple), + efficiency=self.get(self.efficiency), + dutycycle_limit=self.get(self.dutycycle_limit)) + self.assign(self.actual_dutycycle, values.dutycycle) + self.require(values.dutycycle == values.effective_dutycycle, "dutycycle outside limit") + self.inductor = self.Block(Inductor( - inductance=(inductance_min, inductance_max)*Henry, - current=(0, inductor_spec_peak_current), - frequency=frequency*Hertz + inductance=values.inductance * Henry, + current=values.inductor_peak_currents, + frequency=self.frequency )) - actual_ripple = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (self.inductor.actual_inductance * frequency.lower * output_voltage.lower)) + actual_ripple = (self.input_voltage.lower() * (self.output_voltage.upper() - self.input_voltage.lower()) / + (self.inductor.actual_inductance * self.frequency.lower() * self.output_voltage.lower())) self.assign(self.actual_inductor_current_ripple, actual_ripple) self.connect(self.pwr_in, self.inductor.a.adapt_to(VoltageSink( voltage_limits=RangeExpr.ALL, - current_draw=self.pwr_out.link().current_drawn / (1 - effective_dutycycle) + current_draw=self.pwr_out.link().current_drawn / (1 - values.dutycycle) ))) self.connect(self.switch, self.inductor.b.adapt_to(VoltageSource( voltage_out=self.output_voltage, @@ -454,22 +496,13 @@ def generate(self) -> None: (self.actual_inductor_current_ripple.upper() / 2)) ))) - # Capacitor equation Q = CV => i = C dv/dt => for constant current, i * t = C dV => dV = i * t / C - # C = i * t / dV => C = i / (f * dV) - # Boost converter draws current from input throughout the entire cycle, and by conversation of power - # the average input current is Iin = Vout/Vin * Iout = 1/(1-D) * Iout - # Boost converter current should be much less spikey than buck converter current and probably - # less filtering than this is acceptable - input_capacitance = Range.from_lower((output_current.upper / (1 - effective_dutycycle.upper)) / - (frequency.lower * input_voltage_ripple)) self.in_cap = self.Block(DecouplingCapacitor( - capacitance=input_capacitance*Farad, + capacitance=values.input_capacitance * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_in) - output_capacitance = Range.from_lower(output_current.upper * effective_dutycycle.upper / - (frequency.lower * output_voltage_ripple)) + self.out_cap = self.Block(DecouplingCapacitor( - capacitance=output_capacitance*Farad, + capacitance=values.output_capacitance * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_out) @@ -539,9 +572,6 @@ def __init__(self, input_voltage: RangeLike, output_voltage: RangeLike, frequenc self.inductor_current_ripple, self.current_limits, self.efficiency, self.input_voltage_ripple, self.output_voltage_ripple) - # TODO, this is a hack and should be replaced by the actual peak current - self.inductor_spec_peak_current = self.Parameter(FloatExpr()) - self.actual_buck_dutycycle = self.Parameter(RangeExpr()) # possible actual duty cycle in buck mode self.actual_boost_dutycycle = self.Parameter(RangeExpr()) # possible actual duty cycle in boost mode self.actual_inductor_current_ripple = self.Parameter(RangeExpr()) @@ -560,76 +590,46 @@ def contents(self): def generate(self) -> None: super().generate() - input_voltage = self.get(self.input_voltage) - output_voltage = self.get(self.output_voltage) - frequency = self.get(self.frequency) - output_current = self.get(self.output_current) - inductor_current_ripple = self.get(self.inductor_current_ripple) - input_voltage_ripple = self.get(self.input_voltage_ripple) - output_voltage_ripple = self.get(self.output_voltage_ripple) - - # clip each mode's duty cycle to that mode's operating range - buck_dutycycle = (output_voltage / input_voltage / self.get(self.efficiency)).bound_to(Range(-float('inf'), 1)) - self.assign(self.actual_buck_dutycycle, buck_dutycycle) - boost_dutycycle = (1 - input_voltage / output_voltage * self.get(self.efficiency)).bound_to(Range(0, float('inf'))) - self.assign(self.actual_boost_dutycycle, boost_dutycycle) - - # Calculate minimum inductance based on worst case values (operating range corners producing maximum inductance) - # This range must be constructed manually to not double-count the tolerance stackup of the voltages - buck_inductance_min = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (inductor_current_ripple.upper * frequency.lower * input_voltage.upper)) - if inductor_current_ripple.lower == 0: # basically infinite inductance - buck_inductance_max = float('inf') - else: - buck_inductance_max = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (inductor_current_ripple.lower * frequency.lower * input_voltage.upper)) - min_current = max(0, output_current.lower - inductor_current_ripple.upper / 2) # applies to both modes - buck_peak_current = output_current.upper + inductor_current_ripple.upper / 2 - boost_inductance_min = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (inductor_current_ripple.upper * frequency.lower * output_voltage.lower)) - if inductor_current_ripple.lower == 0: # basically infinite inductance - boost_inductance_max = float('inf') - else: - boost_inductance_max = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (inductor_current_ripple.lower * frequency.lower * output_voltage.lower)) - boost_peak_current = output_current.upper / (1 - boost_dutycycle.upper) + inductor_current_ripple.upper / 2 - inductor_spec_peak_current = max(buck_peak_current, boost_peak_current) - self.assign(self.inductor_spec_peak_current, inductor_spec_peak_current) - - # take intersection of buck and boost inductances, and hopefully they overlap - inductance_min = max(buck_inductance_min, boost_inductance_min) - inductance_max = min(buck_inductance_max, boost_inductance_max) + buck_values = BuckConverterPowerPath.calculate_parameters( + self.get(self.input_voltage), self.get(self.output_voltage), + self.get(self.frequency), self.get(self.output_current), + self.get(self.inductor_current_ripple), self.get(self.input_voltage_ripple), self.get(self.output_voltage_ripple), + efficiency=self.get(self.efficiency), dutycycle_limit=Range(0, 1)) + boost_values = BoostConverterPowerPath.calculate_parameters( + self.get(self.input_voltage), self.get(self.output_voltage), + self.get(self.frequency), self.get(self.output_current), + self.get(self.inductor_current_ripple), self.get(self.input_voltage_ripple), self.get(self.output_voltage_ripple), + efficiency=self.get(self.efficiency), dutycycle_limit=Range(0, 1)) + self.assign(self.actual_buck_dutycycle, buck_values.effective_dutycycle) + self.assign(self.actual_boost_dutycycle, boost_values.effective_dutycycle) + self.inductor = self.Block(Inductor( - inductance=(inductance_min, inductance_max)*Henry, - current=(0, inductor_spec_peak_current), - frequency=frequency*Hertz + inductance=buck_values.inductance.intersect(boost_values.inductance) * Henry, + current=buck_values.inductor_peak_currents.hull(boost_values.inductor_peak_currents), + frequency=self.frequency )) - buck_actual_ripple = (output_voltage.lower * (input_voltage.upper - output_voltage.lower) / - (self.inductor.actual_inductance * frequency.lower * input_voltage.upper)) - boost_actual_ripple = (input_voltage.lower * (output_voltage.upper - input_voltage.lower) / - (self.inductor.actual_inductance * frequency.lower * output_voltage.lower)) + # TODO deduplciate w/ ripple code in buck and boost converters + buck_actual_ripple = (self.output_voltage.lower() * (self.input_voltage.upper() - self.output_voltage.lower()) / + (self.inductor.actual_inductance * self.frequency.lower() * self.input_voltage.upper())) + boost_actual_ripple = (self.input_voltage.lower() * (self.output_voltage.upper() - self.input_voltage.lower()) / + (self.inductor.actual_inductance * self.frequency.lower() * self.output_voltage.lower())) self.assign(self.actual_inductor_current_ripple, buck_actual_ripple.hull(boost_actual_ripple)) self.connect(self.switch_in, self.inductor.a) - self.assign(self.actual_inductor_current, (min_current, inductor_spec_peak_current)) # peak currents + + # full range across all modes + dc_current_range = self.output_current / Range((1 - boost_values.effective_dutycycle.upper), 1) + self.assign(self.actual_inductor_current, dc_current_range + (self.actual_inductor_current_ripple.upper() / 2)) self.connect(self.switch_out, self.inductor.b) self.assign(self.actual_avg_current_rating, (0, self.current_limits.intersect(self.inductor.actual_current_rating).upper() - (self.actual_inductor_current_ripple.upper() / 2))) - input_buck_min_cap = (output_current.upper * BuckConverterPowerPath.max_d_inverse_d(buck_dutycycle) / - (frequency.lower * input_voltage_ripple)) - input_boost_min_cap = ((output_current.upper / (1 - boost_dutycycle.upper)) / - (frequency.lower * input_voltage_ripple)) self.in_cap = self.Block(DecouplingCapacitor( - capacitance=Range.from_lower(max(input_buck_min_cap, input_boost_min_cap))*Farad, + capacitance=buck_values.input_capacitance.intersect(boost_values.input_capacitance) * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_in) - - # calculated with steady-state ripple - output_buck_min_cap = inductor_current_ripple.upper / (8 * frequency.lower * output_voltage_ripple) - output_boost_min_cap = output_current.upper * boost_dutycycle.upper / (frequency.lower * output_voltage_ripple) self.out_cap = self.Block(DecouplingCapacitor( - capacitance=Range.from_lower(max(output_buck_min_cap, output_boost_min_cap))*Farad, + capacitance=buck_values.output_capacitance.intersect(boost_values.output_capacitance) * Farad, exact_capacitance=True )).connected(self.gnd, self.pwr_out) diff --git a/edg/abstract_parts/test_switching_converters.py b/edg/abstract_parts/test_switching_converters.py new file mode 100644 index 000000000..b490594e2 --- /dev/null +++ b/edg/abstract_parts/test_switching_converters.py @@ -0,0 +1,88 @@ +import unittest + +from .AbstractPowerConverters import BuckConverterPowerPath, BoostConverterPowerPath +from ..core import Range + + +class BuckConverterCalculationTest(unittest.TestCase): + def test_buck_converter(self): + values_ref = BuckConverterPowerPath.calculate_parameters( + Range.exact(5), Range.exact(2.5), Range.exact(100e3), Range.exact(1), + Range.exact(0.1), 0.01, 0.001, + efficiency=Range.exact(1) + ) + self.assertEqual(values_ref.dutycycle, Range.exact(0.5)) + # validated against https://www.omnicalculator.com/physics/buck-converter + self.assertEqual(values_ref.inductance, Range.exact(125e-6)) + + # test that component values are calculated for worst-case conversion + values = BuckConverterPowerPath.calculate_parameters( + Range(4, 5), Range(2.5, 4), Range.exact(100e3), Range.exact(1), + Range.exact(0.1), 0.01, 0.001, + efficiency=Range.exact(1) + ) + self.assertEqual(values_ref.inductance, values.inductance) + self.assertEqual(values_ref.input_capacitance, values.input_capacitance) + self.assertEqual(values_ref.output_capacitance, values.output_capacitance) + + def test_buck_converter_example(self): + # using the example from https://passive-components.eu/buck-converter-design-and-calculation/ + values = BuckConverterPowerPath.calculate_parameters( + Range.exact(12 + 0.4), Range.exact(3.3 + 0.4), Range.exact(500e3), Range.exact(1), + Range.exact(0.35), 1, 0.0165, + efficiency=Range.exact(1) + ) + self.assertAlmostEqual(values.dutycycle.upper, 0.298, places=3) + self.assertAlmostEqual(values.inductance.upper, 14.8e-6, places=7) + + # the example uses a ripple current of 0.346 for the rest of the calculations + values = BuckConverterPowerPath.calculate_parameters( + Range.exact(12 + 0.4), Range.exact(3.3 + 0.4), Range.exact(500e3), Range.exact(1), + Range.exact(0.346), 1, 0.0165, + efficiency=Range.exact(1) + ) + self.assertAlmostEqual(values.inductor_peak_currents.upper, 1.173, places=3) + self.assertAlmostEqual(values.output_capacitance.lower, 5.24e-6, places=7) + + def test_boost_converter(self): + values_ref = BoostConverterPowerPath.calculate_parameters( + Range.exact(5), Range.exact(10), Range.exact(100e3), Range.exact(1), + Range.exact(0.1), 0.01, 0.001, + efficiency=Range.exact(1) + ) + self.assertEqual(values_ref.dutycycle, Range.exact(0.5)) + # validated against https://www.omnicalculator.com/physics/boost-converter + self.assertEqual(values_ref.inductance, Range.exact(250e-6)) + + # test that component values are calculated for worst-case conversion + values = BoostConverterPowerPath.calculate_parameters( + Range(5, 8), Range(7, 10), Range.exact(100e3), Range.exact(1), + Range.exact(0.1), 0.01, 0.001, + efficiency=Range.exact(1) + ) + self.assertEqual(values_ref.inductance, values.inductance) + self.assertEqual(values_ref.input_capacitance, values.input_capacitance) + self.assertEqual(values_ref.output_capacitance, values.output_capacitance) + + def test_boost_converter_example(self): + # using the example from https://passive-components.eu/boost-converter-design-and-calculation/ + # 0.4342A ripple current from .35 factor in example converted in output current terms + values = BoostConverterPowerPath.calculate_parameters( + Range.exact(5), Range.exact(12 + 0.4), Range.exact(500e3), Range.exact(0.5), + Range.exact(0.4342), 1, 1, + efficiency=Range.exact(1) + ) + self.assertAlmostEqual(values.dutycycle.upper, 0.597, places=3) + self.assertAlmostEqual(values.inductance.upper, 13.75e-6, places=7) + + # the example continues with a normalized inductance of 15uH + values = BoostConverterPowerPath.calculate_parameters( + Range.exact(5), Range.exact(12 + 0.4), Range.exact(500e3), Range.exact(0.5), + Range.exact(.4342*13.75/15), 0.01, 0.06, + efficiency=Range.exact(1) + ) + self.assertAlmostEqual(values.dutycycle.upper, 0.597, places=3) + self.assertAlmostEqual(values.inductance.upper, 15.0e-6, places=7) + self.assertAlmostEqual(values.inductor_peak_currents.upper, 1.44, places=2) + # the example calculation output is wrong, this is the correct result of the formula + self.assertAlmostEqual(values.output_capacitance.lower, 9.95e-6, places=7) diff --git a/edg/core/ConstraintExpr.py b/edg/core/ConstraintExpr.py index 2655fba63..c89a475e2 100644 --- a/edg/core/ConstraintExpr.py +++ b/edg/core/ConstraintExpr.py @@ -173,6 +173,8 @@ def then_else(self, then_val: IteType, else_val: IteType) -> IteType: class NumLikeExpr(ConstraintExpr[WrappedType, NumLikeCastable], Generic[WrappedType, NumLikeCastable]): """Trait for numeric-like expressions, providing common arithmetic operations""" + _CASTABLE_TYPES: Tuple[Type[NumLikeCastable], ...] # NumLikeCastable for use in ininstance(), excluding self-cls + @classmethod @abstractmethod def _to_expr_type(cls: Type[NumLikeSelfType], @@ -209,28 +211,44 @@ def __mul_inv__(self: NumLikeSelfType) -> NumLikeSelfType: return self._create_unary_op(self, NumericOp.invert) def __add__(self: NumLikeSelfType, rhs: NumLikeCastable) -> NumLikeSelfType: - return self._create_binary_op(self, self._to_expr_type(rhs), NumericOp.add) + if isinstance(rhs, self._CASTABLE_TYPES) or isinstance(rhs, self.__class__): + return self._create_binary_op(self, self._to_expr_type(rhs), NumericOp.add) + return NotImplemented def __radd__(self: NumLikeSelfType, lhs: NumLikeCastable) -> NumLikeSelfType: - return self._create_binary_op(self._to_expr_type(lhs), self, NumericOp.add) + if isinstance(lhs, self._CASTABLE_TYPES) or isinstance(lhs, self.__class__): + return self._create_binary_op(self._to_expr_type(lhs), self, NumericOp.add) + return NotImplemented def __sub__(self: NumLikeSelfType, rhs: NumLikeCastable) -> NumLikeSelfType: - return self.__add__(self._to_expr_type(rhs).__neg__()) + if isinstance(rhs, self._CASTABLE_TYPES) or isinstance(rhs, self.__class__): + return self.__add__(self._to_expr_type(rhs).__neg__()) + return NotImplemented def __rsub__(self: NumLikeSelfType, lhs: NumLikeCastable) -> NumLikeSelfType: - return self.__neg__().__radd__(self._to_expr_type(lhs)) + if isinstance(lhs, self._CASTABLE_TYPES) or isinstance(lhs, self.__class__): + return self.__neg__().__radd__(self._to_expr_type(lhs)) + return NotImplemented def __mul__(self: NumLikeSelfType, rhs: NumLikeCastable) -> NumLikeSelfType: - return self._create_binary_op(self, self._to_expr_type(rhs), NumericOp.mul) + if isinstance(rhs, self._CASTABLE_TYPES) or isinstance(rhs, self.__class__): + return self._create_binary_op(self, self._to_expr_type(rhs), NumericOp.mul) + return NotImplemented def __rmul__(self: NumLikeSelfType, lhs: NumLikeCastable) -> NumLikeSelfType: - return self._create_binary_op(self._to_expr_type(lhs), self, NumericOp.mul) + if isinstance(lhs, self._CASTABLE_TYPES) or isinstance(lhs, self.__class__): + return self._create_binary_op(self._to_expr_type(lhs), self, NumericOp.mul) + return NotImplemented def __truediv__(self: NumLikeSelfType, rhs: NumLikeCastable) -> NumLikeSelfType: - return self.__mul__(self._to_expr_type(rhs).__mul_inv__()) + if isinstance(rhs, self._CASTABLE_TYPES) or isinstance(rhs, self.__class__): + return self.__mul__(self._to_expr_type(rhs).__mul_inv__()) + return NotImplemented def __rtruediv__(self: NumLikeSelfType, lhs: NumLikeCastable) -> NumLikeSelfType: - return self.__mul_inv__().__mul__(self._to_expr_type(lhs)) + if isinstance(lhs, self._CASTABLE_TYPES) or isinstance(lhs, self.__class__): + return self.__mul_inv__().__mul__(self._to_expr_type(lhs)) + return NotImplemented @classmethod def _create_bool_op(cls, @@ -245,23 +263,35 @@ def _create_bool_op(cls, return BoolExpr()._bind(BinaryOpBinding(lhs, rhs, op)) def __ne__(self: NumLikeSelfType, other: NumLikeCastable) -> BoolExpr: #type: ignore - return self._create_bool_op(self, self._to_expr_type(other), EqOp.ne) + if isinstance(other, self._CASTABLE_TYPES) or isinstance(other, self.__class__): + return self._create_bool_op(self, self._to_expr_type(other), EqOp.ne) + return NotImplemented def __gt__(self: NumLikeSelfType, other: NumLikeCastable) -> BoolExpr: #type: ignore - return self._create_bool_op(self, self._to_expr_type(other), OrdOp.gt) + if isinstance(other, self._CASTABLE_TYPES) or isinstance(other, self.__class__): + return self._create_bool_op(self, self._to_expr_type(other), OrdOp.gt) + return NotImplemented def __ge__(self: NumLikeSelfType, other: NumLikeCastable) -> BoolExpr: #type: ignore - return self._create_bool_op(self, self._to_expr_type(other), OrdOp.ge) + if isinstance(other, self._CASTABLE_TYPES) or isinstance(other, self.__class__): + return self._create_bool_op(self, self._to_expr_type(other), OrdOp.ge) + return NotImplemented def __lt__(self: NumLikeSelfType, other: NumLikeCastable) -> BoolExpr: #type: ignore - return self._create_bool_op(self, self._to_expr_type(other), OrdOp.lt) + if isinstance(other, self._CASTABLE_TYPES) or isinstance(other, self.__class__): + return self._create_bool_op(self, self._to_expr_type(other), OrdOp.lt) + return NotImplemented def __le__(self: NumLikeSelfType, other: NumLikeCastable) -> BoolExpr: #type: ignore - return self._create_bool_op(self, self._to_expr_type(other), OrdOp.le) + if isinstance(other, self._CASTABLE_TYPES) or isinstance(other, self.__class__): + return self._create_bool_op(self, self._to_expr_type(other), OrdOp.le) + return NotImplemented IntLike = Union['IntExpr', int] class IntExpr(NumLikeExpr[int, IntLike]): + _CASTABLE_TYPES = (int, ) + @classmethod def _to_expr_type(cls, input: IntLike) -> IntExpr: if isinstance(input, IntExpr): @@ -287,6 +317,8 @@ def _from_lit(cls, pb: edgir.ValueLit) -> int: FloatLit = Union[float, int] FloatLike = Union['FloatExpr', float, int] class FloatExpr(NumLikeExpr[float, Union[FloatLike, IntExpr]]): + _CASTABLE_TYPES = (float, int) + @classmethod def _to_expr_type(cls, input: Union[FloatLike, IntExpr]) -> FloatExpr: if isinstance(input, FloatExpr): @@ -320,6 +352,9 @@ def max(self, other: FloatLike) -> FloatExpr: RangeLike = Union['RangeExpr', Range, Tuple[FloatLike, FloatLike]] class RangeExpr(NumLikeExpr[Range, Union[RangeLike, FloatLike, IntExpr]]): + # mypy doesn't like the unbounded tuple + _CASTABLE_TYPES = (float, int, FloatExpr, IntExpr, Range, tuple) # type: ignore + # Some range literals for defaults POSITIVE: Range = Range.from_lower(0.0) NEGATIVE: Range = Range.from_upper(0.0) @@ -424,26 +459,6 @@ def _create_range_float_binary_op(cls, assert lhs._is_bound() and rhs._is_bound() return lhs._new_bind(BinaryOpBinding(lhs, rhs, op)) - # special option to allow range * float - def __mul__(self, rhs: Union[RangeLike, FloatLike, IntLike]) -> RangeExpr: - if isinstance(rhs, (int, float)): # TODO clean up w/ literal to expr pass, then type based on that - rhs_cast: Union[FloatExpr, IntExpr, RangeExpr] = FloatExpr._to_expr_type(rhs) - elif isinstance(rhs, (FloatExpr, IntExpr)): - rhs_cast = rhs - else: - rhs_cast = self._to_expr_type(rhs) # type: ignore - return self._create_range_float_binary_op(self, rhs_cast, NumericOp.mul) - - # special option to allow range / float - def __truediv__(self, rhs: Union[RangeLike, FloatLike, IntLike]) -> RangeExpr: - if isinstance(rhs, (int, float)): # TODO clean up w/ literal to expr pass, then type based on that - rhs_cast: Union[FloatExpr, IntExpr, RangeExpr] = FloatExpr._to_expr_type(rhs) - elif isinstance(rhs, (FloatExpr, IntExpr)): - rhs_cast = rhs - else: - rhs_cast = self._to_expr_type(rhs) # type: ignore - return self * rhs_cast.__mul_inv__() - def shrink_multiply(self, contributing: RangeLike) -> RangeExpr: """RangeExpr version of Range.shrink_multiply. See docs for Range.shrink_multiply.""" diff --git a/edg/core/Range.py b/edg/core/Range.py index d67b11ae2..a8fe98eed 100644 --- a/edg/core/Range.py +++ b/edg/core/Range.py @@ -144,9 +144,18 @@ def __contains__(self, item: Union['Range', float]) -> bool: else: return NotImplemented + def hull(self, other: 'Range') -> 'Range': + return Range(min(self.lower, other.lower), max(self.upper, other.upper)) + def intersects(self, other: 'Range') -> bool: return (self.upper >= other.lower) and (self.lower <= other.upper) + def intersect(self, other: 'Range') -> 'Range': + # TODO make behavior more consistent w/ compiler and returning empty that props as a unit + if not self.intersects(other): + raise ValueError("cannot intersect ranges that do not intersect") + return Range(max(self.lower, other.lower), min(self.upper, other.upper)) + def __add__(self, other: Union['Range', float]) -> 'Range': if isinstance(other, Range): return Range(self.lower + other.lower, self.upper + other.upper) diff --git a/edg/core/test_range.py b/edg/core/test_range.py index 0f5ff0c0d..b4cc31af6 100644 --- a/edg/core/test_range.py +++ b/edg/core/test_range.py @@ -47,7 +47,7 @@ def test_ops(self) -> None: self.assertEqual(Range(1, 5).center(), 3) - def test_intersect(self) -> None: + def test_intersects(self) -> None: self.assertTrue(Range(-1, 2).intersects(Range(2, 3))) self.assertTrue(Range(-1, 2).intersects(Range(0, 3))) self.assertTrue(Range(-1, 2).intersects(Range(-2, -1))) @@ -56,6 +56,22 @@ def test_intersect(self) -> None: self.assertFalse(Range(-1, 2).intersects(Range(3, 4))) self.assertFalse(Range(-1, 2).intersects(Range(-3, -2))) + def test_intersect(self): + self.assertEqual(Range(-1, 2).intersect(Range(2, 3)), Range(2, 2)) + self.assertEqual(Range(-1, 2).intersect(Range(0, 3)), Range(0, 2)) + self.assertEqual(Range(-1, 2).intersect(Range(-2, -1)), Range(-1, -1)) + self.assertEqual(Range(-1, 2).intersect(Range(-2, 0)), Range(-1, 0)) + self.assertEqual(Range(-1, 2).intersect(Range(0, 1)), Range(0, 1)) + with self.assertRaises(ValueError): + Range(-1, 2).intersect(Range(3, 4)) + + def test_hull(self): + self.assertEqual(Range(-1, 2).hull(Range(2, 3)), Range(-1, 3)) + self.assertEqual(Range(-1, 2).hull(Range(0, 3)), Range(-1, 3)) + self.assertEqual(Range(-1, 2).hull(Range(-2, -1)), Range(-2, 2)) + self.assertEqual(Range(-1, 2).hull(Range(-2, 0)), Range(-2, 2)) + self.assertEqual(Range(-1, 2).hull(Range(0, 1)), Range(-1, 2)) + def test_shrink_property(self) -> None: range1 = Range(10, 20) self.assertEqual(range1.shrink_multiply(1/range1), Range(1, 1)) diff --git a/examples/Fcml/Fcml.net b/examples/Fcml/Fcml.net index 77cba9926..46788cced 100644 --- a/examples/Fcml/Fcml.net +++ b/examples/Fcml/Fcml.net @@ -279,7 +279,7 @@ (value "4.1A 4.7uH ±30% 23.4mΩ SMD,8x8x4.2mm Power Inductors ROHS") (footprint "Inductor_SMD:L_Taiyo-Yuden_NR-80xx") (property (name "Sheetname") (value "power_path")) - (property (name "Sheetfile") (value "edg.abstract_parts.AbstractPowerConverters.BuckConverterPowerPath")) + (property (name "Sheetfile") (value "examples.test_fcml.FcmlPowerPath")) (property (name "edg_path") (value "conv.power_path.inductor")) (property (name "edg_short_path") (value "conv.power_path.inductor")) (property (name "edg_refdes") (value "L2")) diff --git a/examples/Fcml/Fcml.ref.net b/examples/Fcml/Fcml.ref.net index 8b4642112..e6e459eba 100644 --- a/examples/Fcml/Fcml.ref.net +++ b/examples/Fcml/Fcml.ref.net @@ -279,7 +279,7 @@ (value "4.1A 4.7uH ±30% 23.4mΩ SMD,8x8x4.2mm Power Inductors ROHS") (footprint "Inductor_SMD:L_Taiyo-Yuden_NR-80xx") (property (name "Sheetname") (value "power_path")) - (property (name "Sheetfile") (value "edg.abstract_parts.AbstractPowerConverters.BuckConverterPowerPath")) + (property (name "Sheetfile") (value "examples.test_fcml.FcmlPowerPath")) (property (name "edg_path") (value "conv.power_path.inductor")) (property (name "edg_short_path") (value "conv.power_path.inductor")) (property (name "edg_refdes") (value "L2")) diff --git a/examples/Simon/Simon.net b/examples/Simon/Simon.net index 19e786748..c88a1ab8d 100644 --- a/examples/Simon/Simon.net +++ b/examples/Simon/Simon.net @@ -276,14 +276,14 @@ (sheetpath (names "/pwr/fb/") (tstamps "/02b3015a/013000c9/")) (tstamps "175b043f")) (comp (ref "pwr.power_path.inductor") - (value "390mA 47uH ±10% 1Ω 1210 Power Inductors ROHS") + (value "250mA 33uH ±10% 800mΩ 1210 Inductors (SMD) ROHS") (footprint "Inductor_SMD:L_1210_3225Metric") (property (name "Sheetname") (value "power_path")) (property (name "Sheetfile") (value "edg.abstract_parts.AbstractPowerConverters.BoostConverterPowerPath")) (property (name "edg_path") (value "pwr.power_path.inductor")) (property (name "edg_short_path") (value "pwr.power_path.inductor")) (property (name "edg_refdes") (value "L1")) - (property (name "edg_part") (value "BRL3225T470K (Taiyo Yuden)")) + (property (name "edg_part") (value "CMI322513J330KT (FH(Guangdong Fenghua Advanced Tech))")) (sheetpath (names "/pwr/power_path/") (tstamps "/02b3015a/1786043a/")) (tstamps "0f2b0369")) (comp (ref "pwr.power_path.in_cap") diff --git a/examples/Simon/Simon.ref.net b/examples/Simon/Simon.ref.net index 4f87d48c5..e0f630163 100644 --- a/examples/Simon/Simon.ref.net +++ b/examples/Simon/Simon.ref.net @@ -276,14 +276,14 @@ (sheetpath (names "/pwr/fb/") (tstamps "/02b3015a/013000c9/")) (tstamps "175b043f")) (comp (ref "L1") - (value "390mA 47uH ±10% 1Ω 1210 Power Inductors ROHS") + (value "250mA 33uH ±10% 800mΩ 1210 Inductors (SMD) ROHS") (footprint "Inductor_SMD:L_1210_3225Metric") (property (name "Sheetname") (value "power_path")) (property (name "Sheetfile") (value "edg.abstract_parts.AbstractPowerConverters.BoostConverterPowerPath")) (property (name "edg_path") (value "pwr.power_path.inductor")) (property (name "edg_short_path") (value "pwr.power_path.inductor")) (property (name "edg_refdes") (value "L1")) - (property (name "edg_part") (value "BRL3225T470K (Taiyo Yuden)")) + (property (name "edg_part") (value "CMI322513J330KT (FH(Guangdong Fenghua Advanced Tech))")) (sheetpath (names "/pwr/power_path/") (tstamps "/02b3015a/1786043a/")) (tstamps "0f2b0369")) (comp (ref "C4") diff --git a/examples/test_fcml.py b/examples/test_fcml.py index 7b11b0eb1..8c595da4d 100644 --- a/examples/test_fcml.py +++ b/examples/test_fcml.py @@ -172,6 +172,88 @@ def generate(self): }) +class FcmlPowerPath(InternalSubcircuit, GeneratorBlock): + """FCML power path that accounts for inductor scaling behavior + TODO: Is there a way to unify this with BuckConverterPowerPath? + This basically completely duplicates it, but adds a scaling factor that doesn't exist there + """ + @init_in_parent + def __init__(self, input_voltage: RangeLike, output_voltage: RangeLike, frequency: RangeLike, + output_current: RangeLike, current_limits: RangeLike, inductor_current_ripple: RangeLike, *, + input_voltage_ripple: FloatLike, + output_voltage_ripple: FloatLike, + efficiency: RangeLike = (0.9, 1.0), # from TI reference + dutycycle_limit: RangeLike = (0.1, 0.9), + inductor_scale: FloatLike = 1.0): # arbitrary + super().__init__() + + self.pwr_in = self.Port(VoltageSink.empty(), [Power]) # models the input cap only + self.pwr_out = self.Port(VoltageSource.empty()) # models the output cap and inductor power source + self.switch = self.Port(VoltageSink.empty()) # current draw defined as average + self.gnd = self.Port(Ground.empty(), [Common]) + + self.input_voltage = self.ArgParameter(input_voltage) + self.output_voltage = self.ArgParameter(output_voltage) + self.frequency = self.ArgParameter(frequency) + self.output_current = self.ArgParameter(output_current) + self.inductor_current_ripple = self.ArgParameter(inductor_current_ripple) + self.efficiency = self.ArgParameter(efficiency) + self.input_voltage_ripple = self.ArgParameter(input_voltage_ripple) + self.output_voltage_ripple = self.ArgParameter(output_voltage_ripple) + self.dutycycle_limit = self.ArgParameter(dutycycle_limit) + self.generator_param(self.input_voltage, self.output_voltage, self.frequency, self.output_current, + self.inductor_current_ripple, self.efficiency, + self.input_voltage_ripple, self.output_voltage_ripple, self.dutycycle_limit) + + self.current_limits = self.ArgParameter(current_limits) + self.inductor_scale = self.ArgParameter(inductor_scale) + + self.actual_dutycycle = self.Parameter(RangeExpr()) + self.actual_inductor_current_ripple = self.Parameter(RangeExpr()) + + def generate(self) -> None: + super().generate() + values = BuckConverterPowerPath.calculate_parameters( + self.get(self.input_voltage), self.get(self.output_voltage), + self.get(self.frequency), self.get(self.output_current), self.get(self.inductor_current_ripple), + self.get(self.input_voltage_ripple), self.get(self.output_voltage_ripple), + efficiency=self.get(self.efficiency), dutycycle_limit=self.get(self.dutycycle_limit)) + self.assign(self.actual_dutycycle, values.dutycycle) + self.require(values.dutycycle == values.effective_dutycycle, "dutycycle outside limit") + + # TODO maximum current depends on the inductance, but this just uses a worst-case value for simplicity + # TODO ideally the inductor selector would take a function that can account for this coupled equation + self.inductor = self.Block(Inductor( + inductance=values.inductance*Henry / self.inductor_scale, + current=values.inductor_peak_currents, + frequency=self.frequency + )) + + # expand out the equation to avoid double-counting tolerance + actual_peak_ripple = (self.output_voltage.lower() * (self.input_voltage.upper() - self.output_voltage.lower()) / + (self.inductor.actual_inductance * self.frequency.lower() * self.input_voltage.upper())) + self.assign(self.actual_inductor_current_ripple, actual_peak_ripple / self.inductor_scale) + + self.connect(self.switch, self.inductor.a.adapt_to(VoltageSink( + voltage_limits=RangeExpr.ALL, + current_draw=self.pwr_out.link().current_drawn * values.dutycycle + ))) + self.connect(self.pwr_out, self.inductor.b.adapt_to(VoltageSource( + voltage_out=self.output_voltage, + current_limits=(0, self.current_limits.intersect(self.inductor.actual_current_rating).upper() - + (self.actual_inductor_current_ripple.upper() / 2)) + ))) + + self.in_cap = self.Block(DecouplingCapacitor( + capacitance=values.input_capacitance * Farad, + exact_capacitance=True + )).connected(self.gnd, self.pwr_in) + self.out_cap = self.Block(DecouplingCapacitor( + capacitance=values.output_capacitance * Farad, + exact_capacitance=True + )).connected(self.gnd, self.pwr_out) + + class DiscreteMutlilevelBuckConverter(PowerConditioner, GeneratorBlock): """Flying capacitor multilevel buck converter. Trades more switches for smaller inductor size: for number of levels N, inductor value is reduced by a factor of (N-1)^2. @@ -214,7 +296,7 @@ def generate(self): super().generate() levels = self.get(self.levels) assert levels >= 2, "levels must be 2 or more" - self.power_path = self.Block(BuckConverterPowerPath( + self.power_path = self.Block(FcmlPowerPath( self.pwr_in.link().voltage, self.pwr_in.link().voltage * self.get(self.ratios), self.frequency, self.pwr_out.link().current_drawn, Range.all(), # TODO add current limits from FETs inductor_current_ripple=self.inductor_current_ripple,