From 973ef971438b14e25b1c9d1002f86fab94dad33e Mon Sep 17 00:00:00 2001
From: Kevin O'Connor <kevin@koconnor.net>
Date: Sun, 18 Mar 2018 11:23:20 -0400
Subject: [PATCH] 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>
---
 ...printer-wanhao-duplicator-i3-v2.1-2017.cfg |   6 +-
 docs/G-Codes.md                               |   8 +-
 klippy/extras/pid_calibrate.py                | 127 ++++++++++++++++
 klippy/gcode.py                               |  14 +-
 klippy/heater.py                              | 139 ++----------------
 5 files changed, 147 insertions(+), 147 deletions(-)
 create mode 100644 klippy/extras/pid_calibrate.py

diff --git a/config/printer-wanhao-duplicator-i3-v2.1-2017.cfg b/config/printer-wanhao-duplicator-i3-v2.1-2017.cfg
index d1495afb2..d7dd37383 100644
--- a/config/printer-wanhao-duplicator-i3-v2.1-2017.cfg
+++ b/config/printer-wanhao-duplicator-i3-v2.1-2017.cfg
@@ -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)
diff --git a/docs/G-Codes.md b/docs/G-Codes.md
index 73fd2e938..25ff75500 100644
--- a/docs/G-Codes.md
+++ b/docs/G-Codes.md
@@ -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
diff --git a/klippy/extras/pid_calibrate.py b/klippy/extras/pid_calibrate.py
new file mode 100644
index 000000000..477f3a93b
--- /dev/null
+++ b/klippy/extras/pid_calibrate.py
@@ -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)
diff --git a/klippy/gcode.py b/klippy/gcode.py
index dee03a16d..050377f00 100644
--- a/klippy/gcode.py
+++ b/klippy/gcode.py
@@ -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...")
diff --git a/klippy/heater.py b/klippy/heater.py
index 7d746d22e..d80213329 100644
--- a/klippy/heater.py
+++ b/klippy/heater.py
@@ -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(