diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 3fbb9e2cb..c4820944e 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -916,12 +916,16 @@ in the config file. #### PID_CALIBRATE `PID_CALIBRATE HEATER= TARGET= -[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. +[WRITE_FILE=1] [TOLERANCE=0.02]`: 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.csv will be created with a log of all temperature samples +taken during the test. TOLERANCE defaults to 0.02 if not passed in. The +tighter the tolerance the better the calibration result will be, but how +tight you can achieve depends on how clean your sensor readings are. low +noise readings might allow 0.01, to be used, while noisy reading might +require a value of 0.03 or higher. #### SET_HEATER_PID `SET_HEATER_PID HEATER= KP= KI= KD=`: Will diff --git a/docs/PID.md b/docs/PID.md new file mode 100644 index 000000000..02be267ef --- /dev/null +++ b/docs/PID.md @@ -0,0 +1,188 @@ + +# PID + +PID control is a widely used control method in the 3D printing world. +It’s ubiquitous when it comes to temperature control, be it with heaters to +generate heat or fans to remove heat. This document aims to provide a +high-level overview of what PID is and how to use it best in Klipper. + +## PID Calibration +### Preparing the Calibration +When a calibration test is performed external influences should be minimized as +much as possible: +* Turn off fans +* Turn off chamber heaters +* Turn off the extruder heater when calibrating the bed and vice versa +* Avoid external disturbances like drafts etc + +### Choosing the right PID Algorithm +Klipper offers two different PID algorithms: Positional and Velocity + +* Positional (`pid`) + * The standard algorithm + * Very robust against noisy temperature readings + * Can cause overshoots + * Insufficient target control in edge cases +* Velocity (`pid_v`) + * No overshoot + * Better target control in certain scenarios + * More susceptible to noisy sensors + * Might require larger smoothing time constants + +Refer to the [control statement](Config_Reference.md#extruder) in the +Configuration Reference. + +### Running the PID Calibration +The PID calibration is invoked via the [PID_CALIBRATE](G-Codes.md#pid_calibrate) command. +This command will heat up the respective heater and let it cool down around +the target temperature in multiple cycles to determine the needed +parameters. + +Such a calibration cycles looks like the following snippet: +``` +3:12 PM PID_CALIBRATE HEATER=extruder TARGET=220 TOLERANCE=0.01 WRITE_FILE=1 +3:15 PM sample:1 pwm:1.0000 asymmetry:3.7519 tolerance:n/a +3:15 PM sample:2 pwm:0.6229 asymmetry:0.3348 tolerance:n/a +3:16 PM sample:3 pwm:0.5937 asymmetry:0.0840 tolerance:n/a +3:17 PM sample:4 pwm:0.5866 asymmetry:0.0169 tolerance:0.4134 +3:18 PM sample:5 pwm:0.5852 asymmetry:0.0668 tolerance:0.0377 +3:18 PM sample:6 pwm:0.5794 asymmetry:0.0168 tolerance:0.0142 +3:19 PM sample:7 pwm:0.5780 asymmetry:-0.1169 tolerance:0.0086 +3:19 PM PID parameters: pid_Kp=16.538 pid_Ki=0.801 pid_Kd=85.375 + The SAVE_CONFIG command will update the printer config file + with these parameters and restart the printer. +``` +Note the `asymmetry` information. It provides an indication if the heater's +power is sufficient to ensure a symmetrical "heat up" versus "cool down / +heat loss" behavior. It should start positive and converge to zero. +A negative starting value indicates that the heat loss is faster than the heat +up, this means the system is asymmetrical. The calibration will still be +successful but reserves to counter disturbances might be low. + +## Advanced / Manual Calibration + +Many methods exist for calculating control parameters, such as Ziegler-Nichols, +Cohen-Coon, Kappa-Tau, Lambda, and many more. By default, classical +Ziegler-Nichols parameters are generated. If a user wants to experiment with +other flavors of Ziegler-Nichols, or Cohen-Coon parameters, they can extract the +constants from the log as seen below and enter them into this +[spreadsheet](resources/pid_params.xls). + +```text +Ziegler-Nichols constants: Ku=0.103092 Tu=41.800000 +Cohen-Coon constants: Km=-17.734845 Theta=6.600000 Tau=-10.182680 +``` + +Classic Ziegler-Nichols parameters work in all scenarios. Cohen-Coon parameters +work better with systems that have a large amount of dead time/delay. For +example, if a printer has a bed with a large thermal mass that’s slow to heat +up and stabilize, the Cohen-Coon parameters will generally do a better job at +controlling it. + +## Further Readings +### History + +The first rudimentary PID controller was developed by Elmer Sperry in 1911 to +automate the control of a ship's rudder. Engineer Nicolas Minorsky published the +first mathematical analysis of a PID controller in 1922. In 1942, John Ziegler & +Nathaniel Nichols published their seminal paper, "Optimum Settings for Automatic +Controllers," which described a trial-and-error method for tuning a PID +controller, now commonly referred to as the "Ziegler-Nichols method. + +In 1984, Karl Astrom and Tore Hagglund published their paper "Automatic Tuning +of Simple Regulators with Specifications on Phase and Amplitude Margins". In the +paper they introduced an automatic tuning method commonly referred to as the +"Astrom-Hagglund method" or the "relay method". + +In 2019 Brandon Taysom & Carl Sorensen published their paper "Adaptive Relay +Autotuning under Static and Non-static Disturbances with Application to +Friction Stir Welding", which laid out a method to generate more accurate +results from a relay test. This is the PID calibration method currently used by +Klipper. + +### Details of the Relay Test +As previously mentioned, Klipper uses a relay test for calibration purposes. A +standard relay test is conceptually simple. You turn the heater’s power on and +off to get it to oscillate about the target temperature, as seen in the +following graph. + +![simple relay test](img/pid_01.png) + +The above graph shows a common issue with a standard relay test. If the system +being calibrated has too much or too little power for the chosen target +temperature, it will produce biased and asymmetric results. As can be seen +above, the system spends more time in the off state than on and has a larger +amplitude above the target temperature than below. + +In an ideal system, both the on and off times and the amplitude above and below +the target temperature would be the same. 3D printers don’t actively cool the +hot end or bed, so they can never reach the ideal state. + +The following graph is a relay test based on the methodology laid out by +Taysom & Sorensen. After each iteration, the data is analyzed and a new maximum +power setting is calculated. As can be seen, the system starts the test +asymmetric but ends very symmetric. + +![advanced relay test](img/pid_02.png) + +Asymmetry can be monitored in real time during a calibration run. It can also +provide insight into how suitable the heater is for the current calibration +parameters. When asymmetry starts off positive and converges to zero, the +heater has more than enough power to achieve symmetry for the calibration +parameters. + +``` +3:12 PM PID_CALIBRATE HEATER=extruder TARGET=220 TOLERANCE=0.01 WRITE_FILE=1 +3:15 PM sample:1 pwm:1.0000 asymmetry:3.7519 tolerance:n/a +3:15 PM sample:2 pwm:0.6229 asymmetry:0.3348 tolerance:n/a +3:16 PM sample:3 pwm:0.5937 asymmetry:0.0840 tolerance:n/a +3:17 PM sample:4 pwm:0.5866 asymmetry:0.0169 tolerance:0.4134 +3:18 PM sample:5 pwm:0.5852 asymmetry:0.0668 tolerance:0.0377 +3:18 PM sample:6 pwm:0.5794 asymmetry:0.0168 tolerance:0.0142 +3:19 PM sample:7 pwm:0.5780 asymmetry:-0.1169 tolerance:0.0086 +3:19 PM PID parameters: pid_Kp=16.538 pid_Ki=0.801 pid_Kd=85.375 + The SAVE_CONFIG command will update the printer config file + with these parameters and restart the printer. +``` + +When asymmetry starts off negative, It will not converge to zero. If Klipper +does not error out, the calibration run will complete and provide good PID +parameters, However the heater is less likely to handle disturbances as well +as a heater with power in reserve. + +``` +3:36 PM PID_CALIBRATE HEATER=extruder TARGET=220 TOLERANCE=0.01 WRITE_FILE=1 +3:38 PM sample:1 pwm:1.0000 asymmetry:-2.1149 tolerance:n/a +3:39 PM sample:2 pwm:1.0000 asymmetry:-2.0140 tolerance:n/a +3:39 PM sample:3 pwm:1.0000 asymmetry:-1.8811 tolerance:n/a +3:40 PM sample:4 pwm:1.0000 asymmetry:-1.8978 tolerance:0.0000 +3:40 PM PID parameters: pid_Kp=21.231 pid_Ki=1.227 pid_Kd=91.826 + The SAVE_CONFIG command will update the printer config file + with these parameters and restart the printer. +``` + +### Pid Control Algorithms + +Klipper currently supports two control algorithms: Positional and Velocity. +The fundamental difference between the two algorithms is that the Positional +algorithm calculates what the PWM value should be for the current time +interval, and the Velocity algorithm calculates how much the previous PWM +setting should be changed to get the PWM value for the current time interval. + +Positional is the default algorithm, as it will work in every scenario. The +Velocity algorithm can provide superior results to the Positional algorithm but +requires lower noise sensor readings, or a larger smoothing time setting. + +The most noticeable difference between the two algorithms is that for the same +configuration parameters, velocity control will eliminate or drastically reduce +overshoot, as seen in the graphs below, as it isn’t susceptible to integral +wind-up. + +![algorithm comparison](img/pid_03.png) + +![zoomed algorithm comparison](img/pid_04.png) + +In some scenarios Velocity control will also be better at holding the heater at +its target temperature, and rejecting disturbances. The primary reason for this +is that velocity control is more like a standard second order differential +equation. It takes into account position, velocity, and acceleration. diff --git a/docs/img/pid_01.png b/docs/img/pid_01.png new file mode 100644 index 000000000..75d55f646 Binary files /dev/null and b/docs/img/pid_01.png differ diff --git a/docs/img/pid_02.png b/docs/img/pid_02.png new file mode 100644 index 000000000..a4651dbce Binary files /dev/null and b/docs/img/pid_02.png differ diff --git a/docs/img/pid_03.png b/docs/img/pid_03.png new file mode 100644 index 000000000..3fd35ef0e Binary files /dev/null and b/docs/img/pid_03.png differ diff --git a/docs/img/pid_04.png b/docs/img/pid_04.png new file mode 100644 index 000000000..8c1f97eff Binary files /dev/null and b/docs/img/pid_04.png differ diff --git a/docs/resources/pid_params.xls b/docs/resources/pid_params.xls new file mode 100644 index 000000000..cb347110f Binary files /dev/null and b/docs/resources/pid_params.xls differ diff --git a/klippy/extras/pid_calibrate.py b/klippy/extras/pid_calibrate.py index 994bb32da..e6d710b52 100644 --- a/klippy/extras/pid_calibrate.py +++ b/klippy/extras/pid_calibrate.py @@ -23,13 +23,14 @@ def cmd_PID_CALIBRATE(self, gcmd): heater_name = gcmd.get("HEATER") target = gcmd.get_float("TARGET") write_file = gcmd.get_int("WRITE_FILE", 0) + tolerance = gcmd.get_float('TOLERANCE', TUNE_PID_TOL, above=0.) pheaters = self.printer.lookup_object("heaters") try: heater = pheaters.lookup_heater(heater_name) except self.printer.config_error as e: raise gcmd.error(str(e)) self.printer.lookup_object("toolhead").get_last_move_time() - calibrate = ControlAutoTune(heater, target) + calibrate = ControlAutoTune(heater, target, tolerance) old_control = heater.set_control(calibrate) try: pheaters.set_temperature(heater, target, True) @@ -38,11 +39,11 @@ def cmd_PID_CALIBRATE(self, gcmd): raise heater.set_control(old_control) if write_file: - calibrate.write_file("/tmp/heattest.txt") + calibrate.write_file("/tmp/heattest.csv") if calibrate.check_busy(0.0, 0.0, 0.0): raise gcmd.error("pid_calibrate interrupted") # Log and report results - Kp, Ki, Kd = calibrate.calc_final_pid() + Kp, Ki, Kd = calibrate.calc_pid() logging.info("Autotune: final: Kp=%f Ki=%f Kd=%f", Kp, Ki, Kd) gcmd.respond_info( "PID parameters: pid_Kp=%.3f pid_Ki=%.3f pid_Kd=%.3f\n" @@ -57,118 +58,227 @@ def cmd_PID_CALIBRATE(self, gcmd): configfile.set(heater_name, "pid_Ki", "%.3f" % (Ki,)) configfile.set(heater_name, "pid_Kd", "%.3f" % (Kd,)) - TUNE_PID_DELTA = 5.0 - +TUNE_PID_TOL = 0.02 +TUNE_PID_SAMPLES = 3 +TUNE_PID_MAX_PEAKS = 60 class ControlAutoTune: - def __init__(self, heater, target): + def __init__(self, heater, target, tolerance): self.heater = heater self.heater_max_power = heater.get_max_power() - self.calibrate_temp = target - # Heating control + # store the reference so we can push messages if needed + self.gcode = heater.printer.lookup_object('gcode') + # holds the various max power settings used during the test. + self.powers = [self.heater_max_power] + # holds the times the power setting was changed. + self.times = [] + # the target temperature to tune for + self.target = target + # the tolerance that determines if the system has converged to an + # acceptable level + self.tolerance = tolerance + # the the temp that determines when to turn the heater off + self.temp_high = target + TUNE_PID_DELTA/2. + # the the temp that determines when to turn the heater on + self.temp_low = target - TUNE_PID_DELTA/2. + # is the heater on self.heating = False - self.peak = 0.0 - self.peak_time = 0.0 - # Peak recording + # the current potential peak value + self.peak = self.target + # the time values associated with the current potential peak + self.peak_times = [] + # known peaks and their associated time values self.peaks = [] - # Sample recording - self.last_pwm = 0.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 + self.heater.get_pwm_delay(), value) - ) - self.last_pwm = value - self.heater.set_pwm(read_time, value) + # has the target temp been crossed at-least once + self.target_crossed = False + # has the tuning processed finished + self.done = False + # has the tuning processed started + self.started = False + # did an error happen + self.errored = False + # data from the test that can be optionally written to a file + self.data = [] def temperature_update(self, read_time, temp, target_temp): - self.temp_samples.append((read_time, temp)) - # Check if the temperature has crossed the target and - # enable/disable the heater if so. - if self.heating and temp >= target_temp: + # tuning is done, so don't do any more calculations + if self.done: + return + # store test data + self.data.append((read_time, temp, self.heater.last_pwm_value, + self.target)) + # ensure the starting temp is low enough to run the test. + if not self.started and temp >= self.temp_low: + self.errored = True + self.finish(read_time) + self.gcode.respond_info("temperature to high to start calibration") + return + else: + self.started = True + # ensure the test doesn't run to long + if float(len(self.peaks)) > TUNE_PID_MAX_PEAKS: + self.errored = True + self.finish(read_time) + self.gcode.respond_info("calibration did not finish in time") + return + # indicate that the target temp has been crossed at-least once + if temp > self.target and self.target_crossed == False: + self.target_crossed = True + # only do work if the target temp has been crossed at-least once + if self.target_crossed: + # check for a new peak value + if temp > self.temp_high or temp < self.temp_low : + self.check_peak(read_time, temp) + # it's time to calculate and store a high peak + if self.peak > self.temp_high and temp < self.target: + self.store_peak() + # it's time to calculate and store a low peak + if self.peak < self.temp_low and temp > self.target: + self.store_peak() + # check if the conditions are right to evaluate a new sample + peaks = float(len(self.peaks)) - 1. + powers = float(len(self.powers)) + if (peaks % 2.) == 0. and (powers * 2.) == peaks: + self.log_info() + # check for convergence + if self.converged(): + self.finish(read_time) + return + self.set_power() + # turn the heater off + if self.heating and temp >= self.temp_high: self.heating = False - self.check_peaks() - self.heater.alter_target(self.calibrate_temp - TUNE_PID_DELTA) - elif not self.heating and temp <= target_temp: + self.times.append(read_time) + self.heater.alter_target(self.temp_low) + # turn the heater on + if not self.heating and temp <= self.temp_low: self.heating = True - self.check_peaks() - self.heater.alter_target(self.calibrate_temp) - # Check if this temperature is a peak and record it if so + self.times.append(read_time) + self.heater.alter_target(self.temp_high) + # set the pwm output based on the heater state if self.heating: - self.set_pwm(read_time, self.heater_max_power) - if temp < self.peak: - self.peak = temp - self.peak_time = read_time + self.heater.set_pwm(read_time, self.powers[-1]) else: - self.set_pwm(read_time, 0.0) - if temp > self.peak: - self.peak = temp - self.peak_time = read_time - - def check_busy(self, eventtime, smoothed_temp, target_temp): - if self.heating or len(self.peaks) < 12: + self.heater.set_pwm(read_time, 0) + def check_peak(self, time, temp): + # deal with duplicate temps + if temp == self.peak: + self.peak_times.append(time) + # deal with storing high peak values + if temp > self.target and temp > self.peak: + self.peak = temp + self.peak_times = [time] + # deal with storing low peak values + if temp < self.target and temp < self.peak: + self.peak = temp + self.peak_times = [time] + def store_peak(self): + time = sum(self.peak_times)/float(len(self.peak_times)) + self.peaks.append((time, self.peak)) + self.peak = self.target + self.peak_times = [] + def log_info(self): + # provide some useful info to the user + sample = len(self.powers) + pwm = self.powers[-1] + asymmetry = (self.peaks[-2][1] + self.peaks[-1][1])/2. - self.target + tolerance = self.get_sample_tolerance() + if tolerance is False: + fmt = "sample:%d pwm:%.4f asymmetry:%.4f tolerance:n/a\n" + data = (sample, pwm, asymmetry) + self.gcode.respond_info(fmt % data) + else: + fmt = "sample:%d pwm:%.4f asymmetry:%.4f tolerance:%.4f\n" + data = (sample, pwm, asymmetry, tolerance) + self.gcode.respond_info(fmt % data) + def get_sample_tolerance(self): + powers = len(self.powers) + if powers < TUNE_PID_SAMPLES + 1: + return False + powers = self.powers[-1*(TUNE_PID_SAMPLES+1):] + return max(powers)-min(powers) + def converged(self): + tolerance = self.get_sample_tolerance() + if tolerance is False: + return False + if tolerance <= self.tolerance: return True return False - - # Analysis - def check_peaks(self): - self.peaks.append((self.peak, self.peak_time)) - if self.heating: - self.peak = 9999999.0 - else: - self.peak = -9999999.0 - if len(self.peaks) < 4: + def set_power(self): + peak_low = self.peaks[-2][1] + peak_high = self.peaks[-1][1] + power = self.powers[-1] + mid = power * ((self.target - peak_low)/(peak_high - peak_low)) + if mid * 2. > self.heater_max_power: + # the new power is to high so just return max power + self.powers.append(self.heater_max_power) 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] - # Use Astrom-Hagglund method to estimate Ku and Tu + self.powers.append(mid * 2.) + def finish(self, time): + self.heater.set_pwm(time, 0) + self.heater.alter_target(0) + self.done = True + self.heating = False + def check_busy(self, eventtime, smoothed_temp, target_temp): + if eventtime == 0. and smoothed_temp == 0. and target_temp == 0.: + if self.errored: + return True + return False + if self.done: + return False + return True + def write_file(self, filename): + f = open(filename, "w") + f.write('time, temp, pwm, target\n') + data = ["%.5f, %.5f, %.5f, %.5f" % (time, temp, pwm, target) + for time, temp, pwm, target in self.data] + f.write('\n'.join(data)) + peaks = self.peaks[:] + powers = self.powers[:] + # pop off the + peaks.pop(0) + samples = [] + for i in range(len(powers)): + samples.append((i, peaks[i*2][0], peaks[i*2][1], peaks[i*2+1][0], + peaks[i*2+1][1], powers[i])) + f.write('\nsample, low time, low, high time, high, max power\n') + data = ["%.5f, %.5f, %.5f, %.5f, %.5f, %.5f" % (sample, low_time, + low, high_time, high, max_power) for sample, low_time, low, + high_time, high, max_power in samples] + f.write('\n'.join(data)) + f.close() + def calc_pid(self): + temp_diff = 0. + time_diff = 0. + theta = 0. + for i in range(1, TUNE_PID_SAMPLES * 2, 2): + temp_diff = temp_diff + self.peaks[-i][1] - self.peaks[-i-1][1] + time_diff = time_diff + self.peaks[-i][0] - self.peaks[-i-2][0] + theta = theta + self.peaks[-i][0] - self.times[-i] + temp_diff = temp_diff/float(TUNE_PID_SAMPLES) + time_diff = time_diff/float(TUNE_PID_SAMPLES) + theta = theta/float(TUNE_PID_SAMPLES) amplitude = 0.5 * abs(temp_diff) - Ku = 4.0 * self.heater_max_power / (math.pi * amplitude) + power = self.powers[-1*(TUNE_PID_SAMPLES):] + power = sum(power)/float(len(power)) + # calculate the various parameters + Ku = 4. * power / (math.pi * amplitude) Tu = time_diff + Wu = (2. * math.pi)/Tu + tau = math.tan(math.pi - theta *Wu)/Wu + Km = -math.sqrt(tau**2 * Wu**2 + 1.)/Ku + # log the extra details + logging.info("Ziegler-Nichols constants: Ku=%f Tu=%f", Ku, Tu) + logging.info("Cohen-Coon constants: Km=%f Theta=%f Tau=%f", Km, + theta, tau) # Use Ziegler-Nichols method to generate PID parameters Ti = 0.5 * Tu Td = 0.125 * Tu Kp = 0.6 * Ku * heaters.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, - self.heater_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, "w") - f.write("\n".join(pwm + out)) - f.close() - - def load_config(config): return PIDCalibrate(config)