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:
Kevin O'Connor 2018-03-18 11:23:20 -04:00
parent 310cdf88cc
commit 973ef97143
5 changed files with 147 additions and 147 deletions

View File

@ -45,11 +45,11 @@
# PID values from stock Wanhao firmware (Repetier) do not
# translate directly to klipper. You will need to run klipper's
# 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):
#
# extruder: M303 E0 S<temp>
# heated bed: M303 E-1 S<temp>
# extruder: PID_CALIBRATE HEATER=extruder TARGET=<temp>
# heated bed: PID_CALIBRATE HEATER=heater_bed TARGET=<temp>
#
# After the autotune process completes, PID parameter results
# can be found in the Octoprint terminal tab (if you're quick)

View File

@ -26,7 +26,6 @@ Klipper supports the following standard G-Code commands:
- Get current position: `M114`
- Get firmware version: `M115`
- 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
[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.
- `GET_POSITION`: Return information on the current location of the
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
and perform an internal reset. This command will not clear error
state from the micro-controller (see FIRMWARE_RESTART) nor will it

View File

@ -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)

View File

@ -369,7 +369,7 @@ class GCodeParser:
'G1', 'G4', 'G28', 'M18', 'M400',
'G20', 'M82', 'M83', 'G90', 'G91', 'G92', 'M114', 'M206', 'M220', 'M221',
'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']
# G-Code movement commands
cmd_G1_aliases = ['G0']
@ -569,18 +569,6 @@ class GCodeParser:
"gcode homing: %s" % (
mcu_pos, stepper_pos, kinematic_pos, toolhead_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):
if self.is_printer_ready:
self.respond_info("Preparing to restart...")

View File

@ -98,6 +98,7 @@ REPORT_TIME = 0.300
MAX_HEAT_TIME = 5.0
AMBIENT_TEMP = 25.
PID_PARAM_BASE = 255.
PWM_DELAY = REPORT_TIME + SAMPLE_TIME*SAMPLE_COUNT
class error(Exception):
pass
@ -141,8 +142,9 @@ class PrinterHeater:
# pwm caching
self.next_pwm_time = 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, "pid_calibrate")
def set_pwm(self, read_time, value):
if self.target_temp <= 0.:
value = 0.
@ -150,7 +152,7 @@ class PrinterHeater:
and abs(value - self.last_pwm_value) < 0.05):
# No significant change in value - can suppress update
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.last_pwm_value = value
logging.debug("%s: pwm=%.3f@%.3f (from %.3f@%.3f [%.3f])",
@ -181,16 +183,12 @@ class PrinterHeater:
def check_busy(self, eventtime):
with self.lock:
return self.control.check_busy(eventtime)
def start_auto_tune(self, degrees):
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))
def set_control(self, control):
with self.lock:
self.control = ControlAutoTune(self, self.control)
self.target_temp = degrees
def finish_auto_tune(self, old_control):
self.control = old_control
self.target_temp = 0
old_control = self.control
self.control = control
self.target_temp = 0.
return old_control
def stats(self, eventtime):
with self.lock:
target_temp = self.target_temp
@ -278,125 +276,6 @@ class ControlPID:
return (abs(temp_diff) > PID_SETTLE_DELTA
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):
if config.has_section('heater_bed'):
printer.add_object('heater_bed', PrinterHeater(