diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index 07cf944..b070053 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -21,7 +21,7 @@ There needs to be no abnormal source of temperature change to the kiln: eg if yo To record the profile, run: ``` -python kiln-tuner.py zn.csv +python kiln-tuner.py recordprofile zn.csv ``` The above will drive your kiln to 400 and record the temperature profile to the file `zn.csv`. The file will look something like this: @@ -40,7 +40,7 @@ time,temperature Once you have your zn.csv profile, run the following: ``` -python zieglernicols.py zn.csv +python kiln-tuner.py zn zn.csv ``` The values will be output to stdout, for example: @@ -55,7 +55,7 @@ Kp: 3.853985144980333 1/Ki: 87.78173053095107 Kd: 325.9599328488931 If you run ``` -python zieglernicols.py zn.csv --showplot +python kiln-tuner.py zn zn.csv --showplot ``` It will display a plot of the parameters. It should look simular to this ![kiln-tuner-example.png](kiln-tuner-example.png). @@ -71,7 +71,7 @@ The red diagonal line: this **must** follow the smooth part of your chart closel You might need to adjust the line parameters to make it fit your data properly. You can do this as follows: ``` -python zieglernicols.py zn.csv --tangentdivisor 8 +python kiln-tuner.py zn zn.csv --tangentdivisor 8 ``` `tangentdivisor` modifies which parts of the profile is used to calculate the line. @@ -83,7 +83,7 @@ It is a floating point number >= 2; If necessary, try varying it till you get a By default it is 400. You can change this as follows: ``` -python kiln-tuner.py zn.csv --targettemp 500 +python kiln-tuner.py recordprofile zn.csv --targettemp 500 ``` -(where the target temperature has been changed to 500) +(where the target temperature has been changed to 500 in the example above) diff --git a/kiln-tuner.py b/kiln-tuner.py index 3171202..85259d0 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -7,24 +7,24 @@ import time import argparse -try: - sys.dont_write_bytecode = True - import config - sys.dont_write_bytecode = False +def recordprofile(csvfile, targettemp): -except: - print("Could not import config file.") - print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.") - exit(1) + try: + sys.dont_write_bytecode = True + import config + sys.dont_write_bytecode = False -script_dir = os.path.dirname(os.path.realpath(__file__)) -sys.path.insert(0, script_dir + '/lib/') -profile_path = os.path.join(script_dir, "storage", "profiles") + except: + print("Could not import config file.") + print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.") + exit(1) -from oven import RealOven, SimulatedOven + script_dir = os.path.dirname(os.path.realpath(__file__)) + sys.path.insert(0, script_dir + '/lib/') + profile_path = os.path.join(script_dir, "storage", "profiles") + from oven import RealOven, SimulatedOven -def tune(csvfile, targettemp): # open the file to log data to f = open(csvfile, 'w') csvout = csv.writer(f) @@ -78,10 +78,125 @@ def tune(csvfile, targettemp): oven.output.heat(0) +def line(a, b, x): + return a * x + b + + +def invline(a, b, y): + return (y - b) / a + + +def plot(xdata, ydata, + tangent_min, tangent_max, tangent_slope, tangent_offset, + lower_crossing_x, upper_crossing_x): + from matplotlib import pyplot + + minx = min(xdata) + maxx = max(xdata) + miny = min(ydata) + maxy = max(ydata) + + pyplot.scatter(xdata, ydata) + + pyplot.plot([minx, maxx], [miny, miny], '--', color='purple') + pyplot.plot([minx, maxx], [maxy, maxy], '--', color='purple') + + pyplot.plot(tangent_min[0], tangent_min[1], 'v', color='red') + pyplot.plot(tangent_max[0], tangent_max[1], 'v', color='red') + pyplot.plot([minx, maxx], [line(tangent_slope, tangent_offset, minx), line(tangent_slope, tangent_offset, maxx)], '--', color='red') + + pyplot.plot([lower_crossing_x, lower_crossing_x], [miny, maxy], '--', color='black') + pyplot.plot([upper_crossing_x, upper_crossing_x], [miny, maxy], '--', color='black') + + pyplot.show() + + +def calculate(filename, tangentdivisor, showplot): + # parse the csv file + xdata = [] + ydata = [] + filemintime = None + with open(filename) as f: + for row in csv.DictReader(f): + try: + time = float(row['time']) + temp = float(row['temperature']) + if filemintime is None: + filemintime = time + + xdata.append(time - filemintime) + ydata.append(temp) + except ValueError: + continue # just ignore bad values! + + # gather points for tangent line + miny = min(ydata) + maxy = max(ydata) + midy = (maxy + miny) / 2 + yoffset = int((maxy - miny) / tangentdivisor) + tangent_min = tangent_max = None + for i in range(0, len(xdata)): + rowx = xdata[i] + rowy = ydata[i] + + if rowy >= (midy - yoffset) and tangent_min is None: + tangent_min = (rowx, rowy) + elif rowy >= (midy + yoffset) and tangent_max is None: + tangent_max = (rowx, rowy) + + # calculate tangent line to the main temperature curve + tangent_slope = (tangent_max[1] - tangent_min[1]) / (tangent_max[0] - tangent_min[0]) + tangent_offset = tangent_min[1] - line(tangent_slope, 0, tangent_min[0]) + + # determine the point at which the tangent line crosses the min/max temperaturess + lower_crossing_x = invline(tangent_slope, tangent_offset, miny) + upper_crossing_x = invline(tangent_slope, tangent_offset, maxy) + + # compute parameters + L = lower_crossing_x - min(xdata) + T = upper_crossing_x - lower_crossing_x + + # Magic Ziegler-Nicols constants ahead! + Kp = 1.2 * (T / L) + Ti = 2 * L + Td = 0.5 * L + Ki = Kp / Ti + Kd = Kp * Td + + # outut to the user + print(f"Kp: {Kp} 1/Ki: {1/ Ki}, Kd: {Kd}") + + if showplot: + plot(xdata, ydata, + tangent_min, tangent_max, tangent_slope, tangent_offset, + lower_crossing_x, upper_crossing_x) + + if __name__ == "__main__": parser = argparse.ArgumentParser(description='Record data for kiln tuning') - parser.add_argument('csvfile', type=str, help="The CSV file to write to.") - parser.add_argument('--targettemp', type=int, default=400, help="The target temperature to drive the kiln to (default 400).") + subparsers = parser.add_subparsers() + + parser_profile = subparsers.add_parser('recordprofile', help='Record kiln temperature profile') + parser_profile.add_argument('csvfile', type=str, help="The CSV file to write to.") + parser_profile.add_argument('--targettemp', type=int, default=400, help="The target temperature to drive the kiln to (default 400).") + parser_profile.set_defaults(mode='recordprofile') + + parser_zn = subparsers.add_parser('zn', help='Calculate Ziegler-Nicols parameters') + parser_zn.add_argument('csvfile', type=str, help="The CSV file to read from. Must contain two columns called pid_time (time in seconds) and pid_ispoint (observed temperature)") + parser_zn.add_argument('--showplot', action='store_true', help="If set, also plot results (requires pyplot to be pip installed)") + parser_zn.add_argument('--tangentdivisor', type=float, default=4, help="Adjust the tangent calculation to fit better. Must be >= 2.") + parser_zn.set_defaults(mode='zn') + args = parser.parse_args() - tune(args.csvfile, args.targettemp) + if args.mode == 'recordprofile': + recordprofile(args.csvfile, args.targettemp) + + elif args.mode == 'zn': + if args.tangentdivisor < 2: + raise ValueError("tangentdivisor must be >= 2") + + calculate(args.csvfile, args.tangentdivisor, args.showplot) + + else: + raise NotImplementedError(f"Unknown mode {args.mode}") diff --git a/zieglernicols.py b/zieglernicols.py deleted file mode 100755 index 7dd093f..0000000 --- a/zieglernicols.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python - -import csv -import argparse - - -# Using the method described in "Ziegler–Nichols Tuning Method∗" by Vishakha Vijay Patel -# (https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397) - - -def line(a, b, x): - return a * x + b - - -def invline(a, b, y): - return (y - b) / a - - -def plot(xdata, ydata, - tangent_min, tangent_max, tangent_slope, tangent_offset, - lower_crossing_x, upper_crossing_x): - from matplotlib import pyplot - - minx = min(xdata) - maxx = max(xdata) - miny = min(ydata) - maxy = max(ydata) - - pyplot.scatter(xdata, ydata) - - pyplot.plot([minx, maxx], [miny, miny], '--', color='purple') - pyplot.plot([minx, maxx], [maxy, maxy], '--', color='purple') - - pyplot.plot(tangent_min[0], tangent_min[1], 'v', color='red') - pyplot.plot(tangent_max[0], tangent_max[1], 'v', color='red') - pyplot.plot([minx, maxx], [line(tangent_slope, tangent_offset, minx), line(tangent_slope, tangent_offset, maxx)], '--', color='red') - - pyplot.plot([lower_crossing_x, lower_crossing_x], [miny, maxy], '--', color='black') - pyplot.plot([upper_crossing_x, upper_crossing_x], [miny, maxy], '--', color='black') - - pyplot.show() - - -def calculate(filename, tangentdivisor, showplot): - # parse the csv file - xdata = [] - ydata = [] - filemintime = None - with open(filename) as f: - for row in csv.DictReader(f): - try: - time = float(row['time']) - temp = float(row['temperature']) - if filemintime is None: - filemintime = time - - xdata.append(time - filemintime) - ydata.append(temp) - except ValueError: - continue # just ignore bad values! - - # gather points for tangent line - miny = min(ydata) - maxy = max(ydata) - midy = (maxy + miny) / 2 - yoffset = int((maxy - miny) / tangentdivisor) - tangent_min = tangent_max = None - for i in range(0, len(xdata)): - rowx = xdata[i] - rowy = ydata[i] - - if rowy >= (midy - yoffset) and tangent_min is None: - tangent_min = (rowx, rowy) - elif rowy >= (midy + yoffset) and tangent_max is None: - tangent_max = (rowx, rowy) - - # calculate tangent line to the main temperature curve - tangent_slope = (tangent_max[1] - tangent_min[1]) / (tangent_max[0] - tangent_min[0]) - tangent_offset = tangent_min[1] - line(tangent_slope, 0, tangent_min[0]) - - # determine the point at which the tangent line crosses the min/max temperaturess - lower_crossing_x = invline(tangent_slope, tangent_offset, miny) - upper_crossing_x = invline(tangent_slope, tangent_offset, maxy) - - # compute parameters - L = lower_crossing_x - min(xdata) - T = upper_crossing_x - lower_crossing_x - - # Magic Ziegler-Nicols constants ahead! - Kp = 1.2 * (T / L) - Ti = 2 * L - Td = 0.5 * L - Ki = Kp / Ti - Kd = Kp * Td - - # outut to the user - print(f"Kp: {Kp} 1/Ki: {1/ Ki}, Kd: {Kd}") - - if showplot: - plot(xdata, ydata, - tangent_min, tangent_max, tangent_slope, tangent_offset, - lower_crossing_x, upper_crossing_x) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Perform Ziegler-Nichols PID tuning') - parser.add_argument('csvfile', type=str, help="The CSV file to read from. Must contain two columns called pid_time (time in seconds) and pid_ispoint (observed temperature)") - parser.add_argument('--showplot', action='store_true', help="If set, also plot results (requires pyplot to be pip installed)") - parser.add_argument('--tangentdivisor', type=float, default=4, help="Adjust the tangent calculation to fit better. Must be >= 2.") - args = parser.parse_args() - - if args.tangentdivisor < 2: - raise ValueError("tangentdivisor must be >= 2") - - calculate(args.csvfile, args.tangentdivisor, args.showplot)