pid_calibrate: Move PID calibration logic from heater.py to new file
Drop support for M303 and PID_TUNE, and replace it with a new PID_CALIBRATE command. Move the logic for this command from heater.py to a new pid_calibrate.py file in the extras/ directory. Signed-off-by: Kevin O'Connor <kevin@koconnor.net>
This commit is contained in:
parent
310cdf88cc
commit
973ef97143
|
@ -45,11 +45,11 @@
|
||||||
# PID values from stock Wanhao firmware (Repetier) do not
|
# PID values from stock Wanhao firmware (Repetier) do not
|
||||||
# translate directly to klipper. You will need to run klipper's
|
# translate directly to klipper. You will need to run klipper's
|
||||||
# PID autotune function for the extruder and bed. After getting the
|
# PID autotune function for the extruder and bed. After getting the
|
||||||
# klipper firmware up and running, run the M303 autotune procedures
|
# klipper firmware up and running, run the PID_CALIBRATE procedures
|
||||||
# by sending these commands via octoprint terminal (one per autotune):
|
# by sending these commands via octoprint terminal (one per autotune):
|
||||||
#
|
#
|
||||||
# extruder: M303 E0 S<temp>
|
# extruder: PID_CALIBRATE HEATER=extruder TARGET=<temp>
|
||||||
# heated bed: M303 E-1 S<temp>
|
# heated bed: PID_CALIBRATE HEATER=heater_bed TARGET=<temp>
|
||||||
#
|
#
|
||||||
# After the autotune process completes, PID parameter results
|
# After the autotune process completes, PID parameter results
|
||||||
# can be found in the Octoprint terminal tab (if you're quick)
|
# can be found in the Octoprint terminal tab (if you're quick)
|
||||||
|
|
|
@ -26,7 +26,6 @@ Klipper supports the following standard G-Code commands:
|
||||||
- Get current position: `M114`
|
- Get current position: `M114`
|
||||||
- Get firmware version: `M115`
|
- Get firmware version: `M115`
|
||||||
- Set home offset: `M206 [X<pos>] [Y<pos>] [Z<pos>]`
|
- Set home offset: `M206 [X<pos>] [Y<pos>] [Z<pos>]`
|
||||||
- Run PID tuning: `M303 [E<index>] S<temperature>`
|
|
||||||
|
|
||||||
For further details on the above commands see the
|
For further details on the above commands see the
|
||||||
[RepRap G-Code documentation](http://reprap.org/wiki/G-code).
|
[RepRap G-Code documentation](http://reprap.org/wiki/G-code).
|
||||||
|
@ -65,6 +64,13 @@ The following standard commands are supported:
|
||||||
verify that an endstop is working correctly.
|
verify that an endstop is working correctly.
|
||||||
- `GET_POSITION`: Return information on the current location of the
|
- `GET_POSITION`: Return information on the current location of the
|
||||||
toolhead.
|
toolhead.
|
||||||
|
- `PID_CALIBRATE HEATER=<config_name> TARGET=<temperature>
|
||||||
|
[WRITE_FILE=1]`: Perform a PID calibration test. The specified
|
||||||
|
heater will be enabled until the specified target temperature is
|
||||||
|
reached, and then the heater will be turned off and on for several
|
||||||
|
cycles. If the WRITE_FILE parameter is enabled, then the file
|
||||||
|
/tmp/heattest.txt will be created with a log of all temperature
|
||||||
|
samples taken during the test.
|
||||||
- `RESTART`: This will cause the host software to reload its config
|
- `RESTART`: This will cause the host software to reload its config
|
||||||
and perform an internal reset. This command will not clear error
|
and perform an internal reset. This command will not clear error
|
||||||
state from the micro-controller (see FIRMWARE_RESTART) nor will it
|
state from the micro-controller (see FIRMWARE_RESTART) nor will it
|
||||||
|
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Calibration of heater PID settings
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016-2018 Kevin O'Connor <kevin@koconnor.net>
|
||||||
|
#
|
||||||
|
# This file may be distributed under the terms of the GNU GPLv3 license.
|
||||||
|
import math, logging
|
||||||
|
import extruder, heater
|
||||||
|
|
||||||
|
class PIDCalibrate:
|
||||||
|
def __init__(self, config):
|
||||||
|
self.printer = config.get_printer()
|
||||||
|
self.gcode = self.printer.lookup_object('gcode')
|
||||||
|
self.gcode.register_command(
|
||||||
|
'PID_CALIBRATE', self.cmd_PID_CALIBRATE,
|
||||||
|
desc=self.cmd_PID_CALIBRATE_help)
|
||||||
|
cmd_PID_CALIBRATE_help = "Run PID calibration test"
|
||||||
|
def cmd_PID_CALIBRATE(self, params):
|
||||||
|
heater_name = self.gcode.get_str('HEATER', params)
|
||||||
|
target = self.gcode.get_float('TARGET', params)
|
||||||
|
write_file = self.gcode.get_int('WRITE_FILE', params, 0)
|
||||||
|
try:
|
||||||
|
heater = extruder.get_printer_heater(self.printer, heater_name)
|
||||||
|
except self.printer.config_error as e:
|
||||||
|
raise self.gcode.error(str(e))
|
||||||
|
print_time = self.printer.lookup_object('toolhead').get_last_move_time()
|
||||||
|
calibrate = ControlAutoTune(heater)
|
||||||
|
old_control = heater.set_control(calibrate)
|
||||||
|
try:
|
||||||
|
heater.set_temp(print_time, target)
|
||||||
|
except heater.error as e:
|
||||||
|
raise self.gcode.error(str(e))
|
||||||
|
self.gcode.bg_temp(heater)
|
||||||
|
heater.set_control(old_control)
|
||||||
|
if write_file:
|
||||||
|
calibrate.write_file('/tmp/heattest.txt')
|
||||||
|
Kp, Ki, Kd = calibrate.calc_final_pid()
|
||||||
|
logging.info("Autotune: final: Kp=%f Ki=%f Kd=%f", Kp, Ki, Kd)
|
||||||
|
self.gcode.respond_info(
|
||||||
|
"PID parameters: pid_Kp=%.3f pid_Ki=%.3f pid_Kd=%.3f\n"
|
||||||
|
"To use these parameters, update the printer config file with\n"
|
||||||
|
"the above and then issue a RESTART command" % (Kp, Ki, Kd))
|
||||||
|
|
||||||
|
TUNE_PID_DELTA = 5.0
|
||||||
|
|
||||||
|
class ControlAutoTune:
|
||||||
|
def __init__(self, heater):
|
||||||
|
self.heater = heater
|
||||||
|
# Heating control
|
||||||
|
self.heating = False
|
||||||
|
self.peak = 0.
|
||||||
|
self.peak_time = 0.
|
||||||
|
# Peak recording
|
||||||
|
self.peaks = []
|
||||||
|
# Sample recording
|
||||||
|
self.last_pwm = 0.
|
||||||
|
self.pwm_samples = []
|
||||||
|
self.temp_samples = []
|
||||||
|
# Heater control
|
||||||
|
def set_pwm(self, read_time, value):
|
||||||
|
if value != self.last_pwm:
|
||||||
|
self.pwm_samples.append((read_time + heater.PWM_DELAY, value))
|
||||||
|
self.last_pwm = value
|
||||||
|
self.heater.set_pwm(read_time, value)
|
||||||
|
def adc_callback(self, read_time, temp):
|
||||||
|
self.temp_samples.append((read_time, temp))
|
||||||
|
if self.heating and temp >= self.heater.target_temp:
|
||||||
|
self.heating = False
|
||||||
|
self.check_peaks()
|
||||||
|
elif (not self.heating
|
||||||
|
and temp <= self.heater.target_temp - TUNE_PID_DELTA):
|
||||||
|
self.heating = True
|
||||||
|
self.check_peaks()
|
||||||
|
if self.heating:
|
||||||
|
self.set_pwm(read_time, self.heater.max_power)
|
||||||
|
if temp < self.peak:
|
||||||
|
self.peak = temp
|
||||||
|
self.peak_time = read_time
|
||||||
|
else:
|
||||||
|
self.set_pwm(read_time, 0.)
|
||||||
|
if temp > self.peak:
|
||||||
|
self.peak = temp
|
||||||
|
self.peak_time = read_time
|
||||||
|
def check_busy(self, eventtime):
|
||||||
|
if self.heating or len(self.peaks) < 12:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
# Analysis
|
||||||
|
def check_peaks(self):
|
||||||
|
self.peaks.append((self.peak, self.peak_time))
|
||||||
|
if self.heating:
|
||||||
|
self.peak = 9999999.
|
||||||
|
else:
|
||||||
|
self.peak = -9999999.
|
||||||
|
if len(self.peaks) < 4:
|
||||||
|
return
|
||||||
|
self.calc_pid(len(self.peaks)-1)
|
||||||
|
def calc_pid(self, pos):
|
||||||
|
temp_diff = self.peaks[pos][0] - self.peaks[pos-1][0]
|
||||||
|
time_diff = self.peaks[pos][1] - self.peaks[pos-2][1]
|
||||||
|
max_power = self.heater.max_power
|
||||||
|
Ku = 4. * (2. * max_power) / (abs(temp_diff) * math.pi)
|
||||||
|
Tu = time_diff
|
||||||
|
|
||||||
|
Ti = 0.5 * Tu
|
||||||
|
Td = 0.125 * Tu
|
||||||
|
Kp = 0.6 * Ku * heater.PID_PARAM_BASE
|
||||||
|
Ki = Kp / Ti
|
||||||
|
Kd = Kp * Td
|
||||||
|
logging.info("Autotune: raw=%f/%f Ku=%f Tu=%f Kp=%f Ki=%f Kd=%f",
|
||||||
|
temp_diff, max_power, Ku, Tu, Kp, Ki, Kd)
|
||||||
|
return Kp, Ki, Kd
|
||||||
|
def calc_final_pid(self):
|
||||||
|
cycle_times = [(self.peaks[pos][1] - self.peaks[pos-2][1], pos)
|
||||||
|
for pos in range(4, len(self.peaks))]
|
||||||
|
midpoint_pos = sorted(cycle_times)[len(cycle_times)/2][1]
|
||||||
|
return self.calc_pid(midpoint_pos)
|
||||||
|
# Offline analysis helper
|
||||||
|
def write_file(self, filename):
|
||||||
|
pwm = ["pwm: %.3f %.3f" % (time, value)
|
||||||
|
for time, value in self.pwm_samples]
|
||||||
|
out = ["%.3f %.3f" % (time, temp) for time, temp in self.temp_samples]
|
||||||
|
f = open(filename, "wb")
|
||||||
|
f.write('\n'.join(pwm + out))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
def load_config(config):
|
||||||
|
return PIDCalibrate(config)
|
|
@ -369,7 +369,7 @@ class GCodeParser:
|
||||||
'G1', 'G4', 'G28', 'M18', 'M400',
|
'G1', 'G4', 'G28', 'M18', 'M400',
|
||||||
'G20', 'M82', 'M83', 'G90', 'G91', 'G92', 'M114', 'M206', 'M220', 'M221',
|
'G20', 'M82', 'M83', 'G90', 'G91', 'G92', 'M114', 'M206', 'M220', 'M221',
|
||||||
'M105', 'M104', 'M109', 'M140', 'M190', 'M106', 'M107',
|
'M105', 'M104', 'M109', 'M140', 'M190', 'M106', 'M107',
|
||||||
'M112', 'M115', 'IGNORE', 'QUERY_ENDSTOPS', 'GET_POSITION', 'PID_TUNE',
|
'M112', 'M115', 'IGNORE', 'QUERY_ENDSTOPS', 'GET_POSITION',
|
||||||
'RESTART', 'FIRMWARE_RESTART', 'ECHO', 'STATUS', 'HELP']
|
'RESTART', 'FIRMWARE_RESTART', 'ECHO', 'STATUS', 'HELP']
|
||||||
# G-Code movement commands
|
# G-Code movement commands
|
||||||
cmd_G1_aliases = ['G0']
|
cmd_G1_aliases = ['G0']
|
||||||
|
@ -569,18 +569,6 @@ class GCodeParser:
|
||||||
"gcode homing: %s" % (
|
"gcode homing: %s" % (
|
||||||
mcu_pos, stepper_pos, kinematic_pos, toolhead_pos,
|
mcu_pos, stepper_pos, kinematic_pos, toolhead_pos,
|
||||||
gcode_pos, origin_pos, homing_pos))
|
gcode_pos, origin_pos, homing_pos))
|
||||||
cmd_PID_TUNE_help = "Run PID Tuning"
|
|
||||||
cmd_PID_TUNE_aliases = ["M303"]
|
|
||||||
def cmd_PID_TUNE(self, params):
|
|
||||||
# Run PID tuning
|
|
||||||
heater_index = self.get_int('E', params, 0)
|
|
||||||
if (heater_index < -1 or heater_index >= len(self.heaters) - 1
|
|
||||||
or self.heaters[heater_index] is None):
|
|
||||||
self.respond_error("Heater not configured")
|
|
||||||
heater = self.heaters[heater_index]
|
|
||||||
temp = self.get_float('S', params)
|
|
||||||
heater.start_auto_tune(temp)
|
|
||||||
self.bg_temp(heater)
|
|
||||||
def request_restart(self, result):
|
def request_restart(self, result):
|
||||||
if self.is_printer_ready:
|
if self.is_printer_ready:
|
||||||
self.respond_info("Preparing to restart...")
|
self.respond_info("Preparing to restart...")
|
||||||
|
|
139
klippy/heater.py
139
klippy/heater.py
|
@ -98,6 +98,7 @@ REPORT_TIME = 0.300
|
||||||
MAX_HEAT_TIME = 5.0
|
MAX_HEAT_TIME = 5.0
|
||||||
AMBIENT_TEMP = 25.
|
AMBIENT_TEMP = 25.
|
||||||
PID_PARAM_BASE = 255.
|
PID_PARAM_BASE = 255.
|
||||||
|
PWM_DELAY = REPORT_TIME + SAMPLE_TIME*SAMPLE_COUNT
|
||||||
|
|
||||||
class error(Exception):
|
class error(Exception):
|
||||||
pass
|
pass
|
||||||
|
@ -141,8 +142,9 @@ class PrinterHeater:
|
||||||
# pwm caching
|
# pwm caching
|
||||||
self.next_pwm_time = 0.
|
self.next_pwm_time = 0.
|
||||||
self.last_pwm_value = 0.
|
self.last_pwm_value = 0.
|
||||||
# Load verify_heater module
|
# Load additional modules
|
||||||
printer.try_load_module(config, "verify_heater %s" % (self.name,))
|
printer.try_load_module(config, "verify_heater %s" % (self.name,))
|
||||||
|
printer.try_load_module(config, "pid_calibrate")
|
||||||
def set_pwm(self, read_time, value):
|
def set_pwm(self, read_time, value):
|
||||||
if self.target_temp <= 0.:
|
if self.target_temp <= 0.:
|
||||||
value = 0.
|
value = 0.
|
||||||
|
@ -150,7 +152,7 @@ class PrinterHeater:
|
||||||
and abs(value - self.last_pwm_value) < 0.05):
|
and abs(value - self.last_pwm_value) < 0.05):
|
||||||
# No significant change in value - can suppress update
|
# No significant change in value - can suppress update
|
||||||
return
|
return
|
||||||
pwm_time = read_time + REPORT_TIME + SAMPLE_TIME*SAMPLE_COUNT
|
pwm_time = read_time + PWM_DELAY
|
||||||
self.next_pwm_time = pwm_time + 0.75 * MAX_HEAT_TIME
|
self.next_pwm_time = pwm_time + 0.75 * MAX_HEAT_TIME
|
||||||
self.last_pwm_value = value
|
self.last_pwm_value = value
|
||||||
logging.debug("%s: pwm=%.3f@%.3f (from %.3f@%.3f [%.3f])",
|
logging.debug("%s: pwm=%.3f@%.3f (from %.3f@%.3f [%.3f])",
|
||||||
|
@ -181,16 +183,12 @@ class PrinterHeater:
|
||||||
def check_busy(self, eventtime):
|
def check_busy(self, eventtime):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return self.control.check_busy(eventtime)
|
return self.control.check_busy(eventtime)
|
||||||
def start_auto_tune(self, degrees):
|
def set_control(self, control):
|
||||||
if degrees and (degrees < self.min_temp or degrees > self.max_temp):
|
|
||||||
raise error("Requested temperature (%.1f) out of range (%.1f:%.1f)"
|
|
||||||
% (degrees, self.min_temp, self.max_temp))
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.control = ControlAutoTune(self, self.control)
|
old_control = self.control
|
||||||
self.target_temp = degrees
|
self.control = control
|
||||||
def finish_auto_tune(self, old_control):
|
self.target_temp = 0.
|
||||||
self.control = old_control
|
return old_control
|
||||||
self.target_temp = 0
|
|
||||||
def stats(self, eventtime):
|
def stats(self, eventtime):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
target_temp = self.target_temp
|
target_temp = self.target_temp
|
||||||
|
@ -278,125 +276,6 @@ class ControlPID:
|
||||||
return (abs(temp_diff) > PID_SETTLE_DELTA
|
return (abs(temp_diff) > PID_SETTLE_DELTA
|
||||||
or abs(self.prev_temp_deriv) > PID_SETTLE_SLOPE)
|
or abs(self.prev_temp_deriv) > PID_SETTLE_SLOPE)
|
||||||
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# Ziegler-Nichols PID autotuning
|
|
||||||
######################################################################
|
|
||||||
|
|
||||||
TUNE_PID_DELTA = 5.0
|
|
||||||
|
|
||||||
class ControlAutoTune:
|
|
||||||
def __init__(self, heater, old_control):
|
|
||||||
self.heater = heater
|
|
||||||
self.old_control = old_control
|
|
||||||
self.heating = False
|
|
||||||
self.peaks = []
|
|
||||||
self.peak = 0.
|
|
||||||
self.peak_time = 0.
|
|
||||||
def adc_callback(self, read_time, temp):
|
|
||||||
if self.heating and temp >= self.heater.target_temp:
|
|
||||||
self.heating = False
|
|
||||||
self.check_peaks()
|
|
||||||
elif (not self.heating
|
|
||||||
and temp <= self.heater.target_temp - TUNE_PID_DELTA):
|
|
||||||
self.heating = True
|
|
||||||
self.check_peaks()
|
|
||||||
if self.heating:
|
|
||||||
self.heater.set_pwm(read_time, self.heater.max_power)
|
|
||||||
if temp < self.peak:
|
|
||||||
self.peak = temp
|
|
||||||
self.peak_time = read_time
|
|
||||||
else:
|
|
||||||
self.heater.set_pwm(read_time, 0.)
|
|
||||||
if temp > self.peak:
|
|
||||||
self.peak = temp
|
|
||||||
self.peak_time = read_time
|
|
||||||
def check_peaks(self):
|
|
||||||
self.peaks.append((self.peak, self.peak_time))
|
|
||||||
if self.heating:
|
|
||||||
self.peak = 9999999.
|
|
||||||
else:
|
|
||||||
self.peak = -9999999.
|
|
||||||
if len(self.peaks) < 4:
|
|
||||||
return
|
|
||||||
self.calc_pid(len(self.peaks)-1)
|
|
||||||
def calc_pid(self, pos):
|
|
||||||
temp_diff = self.peaks[pos][0] - self.peaks[pos-1][0]
|
|
||||||
time_diff = self.peaks[pos][1] - self.peaks[pos-2][1]
|
|
||||||
max_power = self.heater.max_power
|
|
||||||
Ku = 4. * (2. * max_power) / (abs(temp_diff) * math.pi)
|
|
||||||
Tu = time_diff
|
|
||||||
|
|
||||||
Ti = 0.5 * Tu
|
|
||||||
Td = 0.125 * Tu
|
|
||||||
Kp = 0.6 * Ku * PID_PARAM_BASE
|
|
||||||
Ki = Kp / Ti
|
|
||||||
Kd = Kp * Td
|
|
||||||
logging.info("Autotune: raw=%f/%f Ku=%f Tu=%f Kp=%f Ki=%f Kd=%f",
|
|
||||||
temp_diff, max_power, Ku, Tu, Kp, Ki, Kd)
|
|
||||||
return Kp, Ki, Kd
|
|
||||||
def final_calc(self):
|
|
||||||
cycle_times = [(self.peaks[pos][1] - self.peaks[pos-2][1], pos)
|
|
||||||
for pos in range(4, len(self.peaks))]
|
|
||||||
midpoint_pos = sorted(cycle_times)[len(cycle_times)/2][1]
|
|
||||||
Kp, Ki, Kd = self.calc_pid(midpoint_pos)
|
|
||||||
logging.info("Autotune: final: Kp=%f Ki=%f Kd=%f", Kp, Ki, Kd)
|
|
||||||
gcode = self.heater.printer.lookup_object('gcode')
|
|
||||||
gcode.respond_info(
|
|
||||||
"PID parameters: pid_Kp=%.3f pid_Ki=%.3f pid_Kd=%.3f\n"
|
|
||||||
"To use these parameters, update the printer config file with\n"
|
|
||||||
"the above and then issue a RESTART command" % (Kp, Ki, Kd))
|
|
||||||
def check_busy(self, eventtime):
|
|
||||||
if self.heating or len(self.peaks) < 12:
|
|
||||||
return True
|
|
||||||
self.final_calc()
|
|
||||||
self.heater.finish_auto_tune(self.old_control)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# Tuning information test
|
|
||||||
######################################################################
|
|
||||||
|
|
||||||
class ControlBumpTest:
|
|
||||||
def __init__(self, heater, old_control):
|
|
||||||
self.heater = heater
|
|
||||||
self.old_control = old_control
|
|
||||||
self.temp_samples = {}
|
|
||||||
self.pwm_samples = {}
|
|
||||||
self.state = 0
|
|
||||||
def set_pwm(self, read_time, value):
|
|
||||||
self.pwm_samples[read_time + 2*REPORT_TIME] = value
|
|
||||||
self.heater.set_pwm(read_time, value)
|
|
||||||
def adc_callback(self, read_time, temp):
|
|
||||||
self.temp_samples[read_time] = temp
|
|
||||||
if not self.state:
|
|
||||||
self.set_pwm(read_time, 0.)
|
|
||||||
if len(self.temp_samples) >= 20:
|
|
||||||
self.state += 1
|
|
||||||
elif self.state == 1:
|
|
||||||
if temp < self.heater.target_temp:
|
|
||||||
self.set_pwm(read_time, self.heater.max_power)
|
|
||||||
return
|
|
||||||
self.set_pwm(read_time, 0.)
|
|
||||||
self.state += 1
|
|
||||||
elif self.state == 2:
|
|
||||||
self.set_pwm(read_time, 0.)
|
|
||||||
if temp <= (self.heater.target_temp + AMBIENT_TEMP) / 2.:
|
|
||||||
self.dump_stats()
|
|
||||||
self.state += 1
|
|
||||||
def dump_stats(self):
|
|
||||||
out = ["%.3f %.1f %d" % (time, temp, self.pwm_samples.get(time, -1.))
|
|
||||||
for time, temp in sorted(self.temp_samples.items())]
|
|
||||||
f = open("/tmp/heattest.txt", "wb")
|
|
||||||
f.write('\n'.join(out))
|
|
||||||
f.close()
|
|
||||||
def check_busy(self, eventtime):
|
|
||||||
if self.state < 3:
|
|
||||||
return True
|
|
||||||
self.heater.finish_auto_tune(self.old_control)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def add_printer_objects(printer, config):
|
def add_printer_objects(printer, config):
|
||||||
if config.has_section('heater_bed'):
|
if config.has_section('heater_bed'):
|
||||||
printer.add_object('heater_bed', PrinterHeater(
|
printer.add_object('heater_bed', PrinterHeater(
|
||||||
|
|
Loading…
Reference in New Issue