diff --git a/config/example-extras.cfg b/config/example-extras.cfg index 08fd6c4f..354eb333 100644 --- a/config/example-extras.cfg +++ b/config/example-extras.cfg @@ -233,14 +233,35 @@ # will go into a shutdown state. Set this range just wide enough so # that reasonable temperatures do not result in an error. These # parameters must be provided. -#temp: 40.0 -# A temperature (in Celsius) that the sensor must exceed before the -# fan is enabled. The default is 40 Celsius. -#fan_speed: 1.0 +#target_temp: 40.0 +# A temperature (in Celsius) that will be the target temperature +#max_speed: 1.0 # The fan speed (expressed as a value from 0.0 to 1.0) that the fan # will be set to when the sensor temperature exceeds the set value. # The default is 1.0. - +#min_speed: 1.0 +# The minumin fan speed (expressed as a value from 0.0 to 1.0) that the fan +# will be set to when the sensor temperature is the set value. +# The default is 0.3. +#control: watermark +# Control algorithm (either watermark or pid). This parameter must +# be provided. +#pid_Kp: 40 +# Kp is the "proportional" constant for the pid. This parameter must +# be provided for PID heaters. +pid_Ki: 0.2 +# Ki is the "integral" constant for the pid. This parameter must be +# provided for PID heaters. +pid_Kd: 0.1 +# Kd is the "derivative" constant for the pid. This parameter must +# be provided for PID heaters. +#pid_deriv_time: 2.0 +# A time value (in seconds) over which the derivative in the pid +# will be smoothed to reduce the impact of measurement noise. The +# default is 2 seconds. +#pid_integral_max: +# The maximum "windup" the integral term may accumulate. The default +# is to use the same value as max_power. # Additional micro-controllers (one may define any number of sections # with an "mcu" prefix). Additional micro-controllers introduce diff --git a/klippy/extras/temperature_fan.py b/klippy/extras/temperature_fan.py index 2c3e0177..8bdb7b84 100644 --- a/klippy/extras/temperature_fan.py +++ b/klippy/extras/temperature_fan.py @@ -5,8 +5,10 @@ # This file may be distributed under the terms of the GNU GPLv3 license. import fan -PIN_MIN_TIME = 0.100 KELVIN_TO_CELCIUS = -273.15 +MAX_FAN_TIME = 5.0 +AMBIENT_TEMP = 25. +PID_PARAM_BASE = 255. class TemperatureFan: def __init__(self, config): @@ -14,31 +16,105 @@ class TemperatureFan: self.printer = config.get_printer() self.fan = fan.PrinterFan(config) self.mcu = self.fan.mcu_fan.get_mcu() - self.fan_speed = config.getfloat('fan_speed', 1., minval=0., maxval=1.) - min_temp = config.getfloat('min_temp', minval=KELVIN_TO_CELCIUS) - max_temp = config.getfloat('max_temp', above=min_temp) - self.temp = config.getfloat('temp', 40. if max_temp > 40. else max_temp, - minval=min_temp, maxval=max_temp) - self.fan.set_shutdown_speed(1.) - self.last_temp = KELVIN_TO_CELCIUS + self.min_temp = config.getfloat('min_temp', minval=KELVIN_TO_CELCIUS) + self.max_temp = config.getfloat('max_temp', above=self.min_temp) self.sensor = self.printer.lookup_object('heater').setup_sensor(config) - self.sensor.setup_minmax(min_temp, max_temp) + self.sensor.setup_minmax(self.min_temp, self.max_temp) self.sensor.setup_callback(self.temperature_callback) - def printer_state(self, state): - if state == 'ready': - reactor = self.printer.get_reactor() - reactor.register_timer(self.callback, reactor.NOW) - def callback(self, eventtime): - power = 0. - if self.last_temp > self.temp: - power = self.fan_speed - print_time = self.mcu.estimated_print_time(eventtime) + PIN_MIN_TIME - self.fan.set_speed(print_time, power) - return eventtime + 1. + self.speed_delay = self.sensor.get_report_time_delta() + self.fan.set_shutdown_speed(1.) + self.max_speed = config.getfloat('max_speed', 1., above=0., maxval=1.) + self.min_speed = config.getfloat('min_speed', 0.3, above=0., maxval=1.) + self.last_temp = 0. + self.last_temp_time = 0. + self.target_temp = config.getfloat('target_temp', 40. if self.max_temp > 40. else self.max_temp, + minval=self.min_temp, maxval=self.max_temp) + algos = {'watermark': ControlBangBang, 'pid': ControlPID} + algo = config.getchoice('control', algos) + self.control = algo(self, config) + self.next_speed_time = 0. + self.last_speed_value = 0. + def set_speed(self, read_time, value): + if value < self.min_speed: + value = 0. + if self.target_temp <= 0.: + value = 0. + if ((read_time < self.next_speed_time or not self.last_speed_value) + and abs(value - self.last_speed_value) < 0.05): + # No significant change in value - can suppress update + return + speed_time = read_time + self.speed_delay + self.next_speed_time = speed_time + 0.75 * MAX_FAN_TIME + self.last_speed_value = value + self.fan.set_speed(speed_time, value) def temperature_callback(self, read_time, temp): self.last_temp = temp + self.control.temperature_callback(read_time, temp) def stats(self, eventtime): - return True, '%s: temp=%.1f' % (self.name, self.last_temp) + return True, '%s: temp=%.1f fan_speed=%.3f' % (self.name, self.last_temp, self.last_speed_value) + +###################################################################### +# Bang-bang control algo +###################################################################### + +class ControlBangBang: + def __init__(self, temperature_fan, config): + self.temperature_fan = temperature_fan + self.max_delta = config.getfloat('max_delta', 2.0, above=0.) + self.heating = False + def temperature_callback(self, read_time, temp): + if self.heating and temp >= self.temperature_fan.target_temp+self.max_delta: + self.heating = False + elif not self.heating and temp <= self.temperature_fan.target_temp-self.max_delta: + self.heating = True + if self.heating: + self.temperature_fan.set_speed(read_time, 0.) + else: + self.temperature_fan.set_speed(read_time, self.temperature_fan.max_power) + +###################################################################### +# Proportional Integral Derivative (PID) control algo +###################################################################### + +PID_SETTLE_DELTA = 1. +PID_SETTLE_SLOPE = .1 + +class ControlPID: + def __init__(self, temperature_fan, config): + self.temperature_fan = temperature_fan + self.Kp = config.getfloat('pid_Kp') / PID_PARAM_BASE + self.Ki = config.getfloat('pid_Ki') / PID_PARAM_BASE + self.Kd = config.getfloat('pid_Kd') / PID_PARAM_BASE + self.min_deriv_time = config.getfloat('pid_deriv_time', 2., above=0.) + imax = config.getfloat('pid_integral_max', temperature_fan.max_speed, minval=0.) + self.temp_integ_max = imax / self.Ki + self.prev_temp = AMBIENT_TEMP + self.prev_temp_time = 0. + self.prev_temp_deriv = 0. + self.prev_temp_integ = 0. + def temperature_callback(self, read_time, temp): + time_diff = read_time - self.prev_temp_time + # Calculate change of temperature + temp_diff = temp - self.prev_temp + if time_diff >= self.min_deriv_time: + temp_deriv = temp_diff / time_diff + else: + temp_deriv = (self.prev_temp_deriv * (self.min_deriv_time-time_diff) + + temp_diff) / self.min_deriv_time + # Calculate accumulated temperature "error" + temp_err = self.temperature_fan.target_temp - temp + temp_integ = self.prev_temp_integ + temp_err * time_diff + temp_integ = max(0., min(self.temp_integ_max, temp_integ)) + # Calculate output + co = self.Kp*temp_err + self.Ki*temp_integ - self.Kd*temp_deriv + bounded_co = max(0., min(self.temperature_fan.max_speed, co)) + self.temperature_fan.set_speed(read_time, self.temperature_fan.max_speed - bounded_co) + # Store state for next measurement + self.prev_temp = temp + self.prev_temp_time = read_time + self.prev_temp_deriv = temp_deriv + if co == bounded_co: + self.prev_temp_integ = temp_integ def load_config_prefix(config): return TemperatureFan(config)