From 7b2bc3e1ba9ea4d8c2b593380a574a0b7420dd60 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 20:58:03 +0100 Subject: [PATCH 01/24] Add ziegler-nichols tool --- kiln-tuner.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 kiln-tuner.py diff --git a/kiln-tuner.py b/kiln-tuner.py new file mode 100644 index 0000000..5e69f26 --- /dev/null +++ b/kiln-tuner.py @@ -0,0 +1,110 @@ +import csv +import argparse + + +# Using the method from https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397 or https://www.youtube.com/watch?v=nvAQHSe-Ax4 + + +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['pid_time']) + temp = float(row['pid_ispoint']) + 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 + Kp = 1.2 * (T / L) + Ti = 2 * L + Td = 0.5 * L + Ki = Kp / Ti + Kd = Kp * Td + + # outut to the user + print(Kp, 1 / Ki, 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) From b78c28068f09fdefacad4705ee29a4bf63327870 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 21:44:23 +0100 Subject: [PATCH 02/24] Rename it --- kiln-tuner.py => zieglernicols.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename kiln-tuner.py => zieglernicols.py (100%) diff --git a/kiln-tuner.py b/zieglernicols.py similarity index 100% rename from kiln-tuner.py rename to zieglernicols.py From a7fafeed65520e471915efd789a1a19cb651a755 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 22:23:11 +0100 Subject: [PATCH 03/24] add recorder code --- kiln-tuner.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/oven.py | 30 +++++++++-------- zieglernicols.py | 3 +- 3 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 kiln-tuner.py diff --git a/kiln-tuner.py b/kiln-tuner.py new file mode 100644 index 0000000..26b6001 --- /dev/null +++ b/kiln-tuner.py @@ -0,0 +1,83 @@ +import os +import sys +import csv +import time +import argparse + + +try: + sys.dont_write_bytecode = True + import config + sys.dont_write_bytecode = False + +except: + print("Could not import config file.") + print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.") + exit(1) + +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) + csvout.write(['time', 'temperature']) + + # construct the oven + if config.simulate: + oven = SimulatedOven() + else: + oven = RealOven() + + # Main loop: + # + # * heat the oven to the target temperature at maximum burn. + # * when we reach it turn the heating off completely. + # * wait for it to decay back to the target again. + # * quit + # + # We record the temperature every config.sensor_time_wait + try: + stage = 'heating' + if not config.simulate: + oven.output.heat(1, tuning=True) + + while True: + temp = oven.board.temp_sensor.temperature + \ + config.thermocouple_offset + + csvout.writerow([time.time(), temp]) + csvout.flush() + + if stage == 'heating': + if temp > targettemp: + if not config.simulate: + oven.output.heat(0) + stage = 'decaying' + + elif stage == 'decaying': + if temp < targettemp: + break + + time.sleep(config.sensor_time_wait) + + f.close() + + finally: + # ensure we always shut the oven down! + if not config.simulate: + oven.output.heat(0) + + +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.") + args = parser.parse_args() + + tune(args.csvfile, args.targettemp) diff --git a/lib/oven.py b/lib/oven.py index 4a1f5b0..869cd3f 100644 --- a/lib/oven.py +++ b/lib/oven.py @@ -26,8 +26,10 @@ class Output(object): log.warning(msg) self.active = False - def heat(self,sleepfor): + def heat(self,sleepfor, tuning=False): self.GPIO.output(config.gpio_heat, self.GPIO.HIGH) + if tuning: + return time.sleep(sleepfor) self.GPIO.output(config.gpio_heat, self.GPIO.LOW) @@ -54,7 +56,7 @@ class Board(object): self.active = True log.info("import %s " % (self.name)) except ImportError: - msg = "max31855 config set, but import failed" + msg = "max31855 config set, but import failed" log.warning(msg) if config.max31856: @@ -64,7 +66,7 @@ class Board(object): self.active = True log.info("import %s " % (self.name)) except ImportError: - msg = "max31856 config set, but import failed" + msg = "max31856 config set, but import failed" log.warning(msg) def create_temp_sensor(self): @@ -76,7 +78,7 @@ class Board(object): class BoardSimulated(object): def __init__(self): self.temp_sensor = TempSensorSimulated() - + class TempSensor(threading.Thread): def __init__(self): threading.Thread.__init__(self) @@ -118,7 +120,7 @@ class TempSensorReal(TempSensor): '''take 5 measurements over each time period and return the average''' while True: - maxtries = 5 + maxtries = 5 sleeptime = self.time_step / float(maxtries) temps = [] for x in range(0,maxtries): @@ -245,10 +247,10 @@ class SimulatedOven(Oven): # set temps to the temp of the surrounding environment self.t = self.t_env # deg C temp of oven self.t_h = self.t_env #deg C temp of heating element - + # call parent init Oven.__init__(self) - + # start thread self.start() log.info("SimulatedOven started") @@ -306,9 +308,9 @@ class SimulatedOven(Oven): self.runtime, self.totaltime, time_left)) - + # we don't actually spend time heating & cooling during - # a simulation, so sleep. + # a simulation, so sleep. time.sleep(self.time_step) @@ -396,7 +398,7 @@ class PID(): self.lastErr = 0 # FIX - this was using a really small window where the PID control - # takes effect from -1 to 1. I changed this to various numbers and + # takes effect from -1 to 1. I changed this to various numbers and # settled on -50 to 50 and then divide by 50 at the end. This results # in a larger PID control window and much more accurate control... # instead of what used to be binary on/off control. @@ -410,7 +412,7 @@ class PID(): if self.ki > 0: self.iterm += (error * timeDelta * (1/self.ki)) - + dErr = (error - self.lastErr) / timeDelta output = self.kp * error + self.iterm + self.kd * dErr out4logs = output @@ -427,10 +429,10 @@ class PID(): output = float(output / window_size) - if out4logs > 0: + if out4logs > 0: log.info("pid percents pid=%0.2f p=%0.2f i=%0.2f d=%0.2f" % (out4logs, - ((self.kp * error)/out4logs)*100, + ((self.kp * error)/out4logs)*100, (self.iterm/out4logs)*100, - ((self.kd * dErr)/out4logs)*100)) + ((self.kd * dErr)/out4logs)*100)) return output diff --git a/zieglernicols.py b/zieglernicols.py index 5e69f26..913fbea 100644 --- a/zieglernicols.py +++ b/zieglernicols.py @@ -2,7 +2,8 @@ import csv import argparse -# Using the method from https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397 or https://www.youtube.com/watch?v=nvAQHSe-Ax4 +# 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): From 25794fa012c9f3a7d3105655fbcc8284bde54c05 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 22:53:43 +0100 Subject: [PATCH 04/24] add docs on ziegler-nicols tuning --- docs/kiln-tuner-example.png | Bin 0 -> 32621 bytes docs/ziegler_tuning.md | 83 ++++++++++++++++++++++++++++++++++++ zieglernicols.py | 2 +- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 docs/kiln-tuner-example.png create mode 100644 docs/ziegler_tuning.md diff --git a/docs/kiln-tuner-example.png b/docs/kiln-tuner-example.png new file mode 100644 index 0000000000000000000000000000000000000000..9f4c28d6db33152d40ff82561a3cc74d3d2bd45f GIT binary patch literal 32621 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

x0d%xlQP`f)CdO+e1 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 literal 0 HcmV?d00001 diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md new file mode 100644 index 0000000..6215efb --- /dev/null +++ b/docs/ziegler_tuning.md @@ -0,0 +1,83 @@ +# PID Tuning Using Ziegler-Nicols + +This uses the Ziegler Nicols method to estimate values for the Kp/Ki/Kd PID control values. + +The method implemented here is taken from ["Ziegler–Nichols Tuning Method"](https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397) by Vishakha Vijay Patel + +One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further fiddling may be necessary. + +## Process Overview + +1. First of all, you will record a temperature profile for your kiln. +2. Next, we use those figures to estimate the parameters. + +## Step 1: Record Temperature Profie + +This must be done without any interference from the real PID control loop. To do so, run: + +``` +python kiln-tuner.py ziegler.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: + +``` +time,temperature +4.025461912,45.5407078 +6.035358906,45.5407078 +8.045399904,45.5407078 +10.05544925,45.59087846 +... +``` + +## Step 2: Compute the PID parameters + +Once you have your zn.csv profile, run the following: + +``` +python zieglernicols.py zn.csv +``` + +The values will be output to stdout, for example: +``` +Kp: 3.853985144980333 1/Ki: 87.78173053095107 Kd: 325.9599328488931 +``` +(Note that the Ki value is already inverted ready for use in config.py) + +------ + +## Sanity checking the results + +If you run +``` +python zieglernicols.py 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) +(Note: you will need python's `pyplot` installed for this to work.) + +The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later). + +Note the red diagonal line: this **must** follow the smooth part of your chart closely. + +## My diagonal line isn't right + +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 +``` + +`tangentdivisor` modifies which parts of the profile is used to calculate the line. + +It is a floating point number >= 2; If necessary, try varying it till you get a better fit. + +## Changing the target temperature + +By default it is 400. You can change this as follows: + +``` +python kiln-tuner.py zn.csv --targettemp 500 +``` + +(where the target temperature has been changed to 500) diff --git a/zieglernicols.py b/zieglernicols.py index 913fbea..82abece 100644 --- a/zieglernicols.py +++ b/zieglernicols.py @@ -90,7 +90,7 @@ def calculate(filename, tangentdivisor, showplot): Kd = Kp * Td # outut to the user - print(Kp, 1 / Ki, Kd) + print(f"Kp: {Kp} 1/Ki: {1/ Ki}, Kd: {Kd}") if showplot: plot(xdata, ydata, From 82c2b2bcc79c8329f10904d655004869c9b048b7 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 22:59:55 +0100 Subject: [PATCH 05/24] more doc tweaks --- docs/ziegler_tuning.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index 6215efb..dfa9c91 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -4,17 +4,18 @@ This uses the Ziegler Nicols method to estimate values for the Kp/Ki/Kd PID cont The method implemented here is taken from ["Ziegler–Nichols Tuning Method"](https://www.ias.ac.in/article/fulltext/reso/025/10/1385-1397) by Vishakha Vijay Patel -One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further fiddling may be necessary. +One issue with Ziegler Nicols is that is a **heuristic**: it generally works quite well, but it might not be the optimal values. Further manual adjustment may be necessary. ## Process Overview 1. First of all, you will record a temperature profile for your kiln. -2. Next, we use those figures to estimate the parameters. +2. Next, we use those figures to estimate Kp/Ki/Kd. ## Step 1: Record Temperature Profie -This must be done without any interference from the real PID control loop. To do so, run: +Ensure `kiln-controller` is **stopped** during profile recording: The profile must be recorded without any interference from the actual PID control loop (you also don't want two things changing the same GPIOs at the same time!) +To record the profile, run: ``` python kiln-tuner.py ziegler.csv ``` @@ -42,7 +43,7 @@ The values will be output to stdout, for example: ``` Kp: 3.853985144980333 1/Ki: 87.78173053095107 Kd: 325.9599328488931 ``` -(Note that the Ki value is already inverted ready for use in config.py) +(Note that the Ki value is already inverted ready for use in config) ------ @@ -53,12 +54,13 @@ If you run python zieglernicols.py 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) -(Note: you will need python's `pyplot` installed for this to work.) +It will display a plot of the parameters. It should look simular to this ![kiln-tuner-example.png](kiln-tuner-example.png). + +Note: you will need python's `pyplot` installed for this to work. The smooth linear part of the chart is very important. If it is too short, try increasing the target temperature (see later). -Note the red diagonal line: this **must** follow the smooth part of your chart closely. +The red diagonal line: this **must** follow the smooth part of your chart closely. ## My diagonal line isn't right From 04e402a04c6718008b9711462533c8b9a7d31669 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Fri, 30 Apr 2021 23:05:24 +0100 Subject: [PATCH 06/24] comments --- zieglernicols.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zieglernicols.py b/zieglernicols.py index 82abece..8194bad 100644 --- a/zieglernicols.py +++ b/zieglernicols.py @@ -83,6 +83,8 @@ def calculate(filename, tangentdivisor, showplot): # 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 From 2ea32dd05f7f1c53325cc76b6f54a8ac24c63c6c Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 09:40:17 +0100 Subject: [PATCH 07/24] some more tweaks --- docs/ziegler_tuning.md | 2 +- kiln-tuner.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index dfa9c91..6f7b7a0 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -17,7 +17,7 @@ Ensure `kiln-controller` is **stopped** during profile recording: The profile mu To record the profile, run: ``` -python kiln-tuner.py ziegler.csv +python kiln-tuner.py 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: diff --git a/kiln-tuner.py b/kiln-tuner.py index 26b6001..d064e86 100644 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -64,6 +64,8 @@ def tune(csvfile, targettemp): if temp < targettemp: break + sys.stdout.write(f"\n{stage} {temp}/{targettemp} ") + sys.stdout.flush() time.sleep(config.sensor_time_wait) f.close() From 92002c7a0b852f8bde8254bec26973f3bd90ce50 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 09:42:21 +0100 Subject: [PATCH 08/24] fix type of arg --- kiln-tuner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index d064e86..3fcf1e2 100644 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -79,7 +79,7 @@ def tune(csvfile, targettemp): 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.") + parser.add_argument('--targettemp', type=int, default=400, help="The target temperature to drive the kiln to.") args = parser.parse_args() tune(args.csvfile, args.targettemp) From 04b14b977cc7d2b88066c21f3f9cafd24dc12d1c Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 09:44:55 +0100 Subject: [PATCH 09/24] more fixes --- kiln-tuner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index 3fcf1e2..d07c13d 100644 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -52,7 +52,7 @@ def tune(csvfile, targettemp): config.thermocouple_offset csvout.writerow([time.time(), temp]) - csvout.flush() + f.flush() if stage == 'heating': if temp > targettemp: @@ -64,7 +64,7 @@ def tune(csvfile, targettemp): if temp < targettemp: break - sys.stdout.write(f"\n{stage} {temp}/{targettemp} ") + sys.stdout.write(f"\r{stage} {temp}/{targettemp} ") sys.stdout.flush() time.sleep(config.sensor_time_wait) @@ -79,7 +79,7 @@ def tune(csvfile, targettemp): 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.") + parser.add_argument('--targettemp', type=int, default=400, help="The target temperature to drive the kiln to (default 400).") args = parser.parse_args() tune(args.csvfile, args.targettemp) From 84eabe34927a167e71a3c55a0abca280763655fc Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 09:58:06 +0100 Subject: [PATCH 10/24] some more doc udates --- docs/ziegler_tuning.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index 6f7b7a0..297f418 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -15,6 +15,10 @@ One issue with Ziegler Nicols is that is a **heuristic**: it generally works qui Ensure `kiln-controller` is **stopped** during profile recording: The profile must be recorded without any interference from the actual PID control loop (you also don't want two things changing the same GPIOs at the same time!) +Make sure your kiln is completely cool - we need to record the data starting from room temperature to correctly measure the effect of kiln/heating. + +There needs to be no other source of temperature change to the kiln: eg if you normally run with a kiln plug in place - makae sure its in place for the test! + To record the profile, run: ``` python kiln-tuner.py zn.csv From f8b9f2203e8043949e5b5d1503a04e630cb595a8 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:06:25 +0100 Subject: [PATCH 11/24] wording --- docs/ziegler_tuning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index 297f418..8b2bcc4 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -17,7 +17,7 @@ Ensure `kiln-controller` is **stopped** during profile recording: The profile mu Make sure your kiln is completely cool - we need to record the data starting from room temperature to correctly measure the effect of kiln/heating. -There needs to be no other source of temperature change to the kiln: eg if you normally run with a kiln plug in place - makae sure its in place for the test! +There needs to be no abnormal source of temperature change to the kiln: eg if you normally run with a kiln plug in place - makae sure its in place for the test! To record the profile, run: ``` From 1e63b74b1be5a0be4661b08368cf2317b7364be5 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:27:58 +0100 Subject: [PATCH 12/24] another doc tweak --- docs/ziegler_tuning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index 8b2bcc4..07cf944 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -17,7 +17,7 @@ Ensure `kiln-controller` is **stopped** during profile recording: The profile mu Make sure your kiln is completely cool - we need to record the data starting from room temperature to correctly measure the effect of kiln/heating. -There needs to be no abnormal source of temperature change to the kiln: eg if you normally run with a kiln plug in place - makae sure its in place for the test! +There needs to be no abnormal source of temperature change to the kiln: eg if you normally run with a kiln plug in place - make sure its in place for the test! To record the profile, run: ``` From 1b14b9d14d2481c47abddb857503d2d59524a9af Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:29:10 +0100 Subject: [PATCH 13/24] make executable --- kiln-tuner.py | 2 ++ zieglernicols.py | 2 ++ 2 files changed, 4 insertions(+) mode change 100644 => 100755 kiln-tuner.py mode change 100644 => 100755 zieglernicols.py diff --git a/kiln-tuner.py b/kiln-tuner.py old mode 100644 new mode 100755 index d07c13d..bd89acf --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import os import sys import csv diff --git a/zieglernicols.py b/zieglernicols.py old mode 100644 new mode 100755 index 8194bad..e1f3223 --- a/zieglernicols.py +++ b/zieglernicols.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + import csv import argparse From 0fadd97d67fb0625b9c194fa8ea5053f275a9f50 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:35:14 +0100 Subject: [PATCH 14/24] fix state name --- kiln-tuner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index bd89acf..e1114d6 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -60,9 +60,9 @@ def tune(csvfile, targettemp): if temp > targettemp: if not config.simulate: oven.output.heat(0) - stage = 'decaying' + stage = 'cooling' - elif stage == 'decaying': + elif stage == 'cooling': if temp < targettemp: break From 91ca3324c470781da7c930d3b31aad5c18f8229f Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:38:16 +0100 Subject: [PATCH 15/24] more tweaks --- kiln-tuner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index e1114d6..dc8606c 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -28,7 +28,7 @@ def tune(csvfile, targettemp): # open the file to log data to f = open(csvfile, 'w') csvout = csv.writer(f) - csvout.write(['time', 'temperature']) + csvout.writerow(['time', 'temperature']) # construct the oven if config.simulate: @@ -66,7 +66,7 @@ def tune(csvfile, targettemp): if temp < targettemp: break - sys.stdout.write(f"\r{stage} {temp}/{targettemp} ") + sys.stdout.write(f"\r{stage} {temp:.2f}/{targettemp} ") sys.stdout.flush() time.sleep(config.sensor_time_wait) From 83e742c433d8cd0db7225a3093aa1301a7f99752 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 10:38:49 +0100 Subject: [PATCH 16/24] fix temp comparison --- kiln-tuner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index dc8606c..3171202 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -57,7 +57,7 @@ def tune(csvfile, targettemp): f.flush() if stage == 'heating': - if temp > targettemp: + if temp >= targettemp: if not config.simulate: oven.output.heat(0) stage = 'cooling' From 0c0f0cff1e4f5fc2e2e6f6bfe533e7c669ec9a1f Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 11:04:08 +0100 Subject: [PATCH 17/24] fix column headers --- zieglernicols.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zieglernicols.py b/zieglernicols.py index e1f3223..7dd093f 100755 --- a/zieglernicols.py +++ b/zieglernicols.py @@ -49,8 +49,8 @@ def calculate(filename, tangentdivisor, showplot): with open(filename) as f: for row in csv.DictReader(f): try: - time = float(row['pid_time']) - temp = float(row['pid_ispoint']) + time = float(row['time']) + temp = float(row['temperature']) if filemintime is None: filemintime = time From 9603332bfbf10b6ec51f143bfcb28be73744c653 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:18:27 +0100 Subject: [PATCH 18/24] combine tools into one --- docs/ziegler_tuning.md | 12 ++-- kiln-tuner.py | 147 ++++++++++++++++++++++++++++++++++++----- zieglernicols.py | 115 -------------------------------- 3 files changed, 137 insertions(+), 137 deletions(-) delete mode 100755 zieglernicols.py 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) From a629c54fb6d9d1988d60275cc9ab2e0c872b5050 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:20:49 +0100 Subject: [PATCH 19/24] fix column names --- kiln-tuner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index 85259d0..19d7b03 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -182,7 +182,7 @@ if __name__ == "__main__": 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('csvfile', type=str, help="The CSV file to read from. Must contain two columns called time (time in seconds) and temperature (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') From 4e7dc16506f6a054b6cb0e2a5c9e2870d21cbd84 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:21:13 +0100 Subject: [PATCH 20/24] fix command description --- kiln-tuner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index 19d7b03..f8c8b94 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -173,7 +173,7 @@ def calculate(filename, tangentdivisor, showplot): if __name__ == "__main__": - parser = argparse.ArgumentParser(description='Record data for kiln tuning') + parser = argparse.ArgumentParser(description='Kiln tuner') subparsers = parser.add_subparsers() parser_profile = subparsers.add_parser('recordprofile', help='Record kiln temperature profile') From cdca23060d263b495a3fed2f363628a80ed532fa Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:22:47 +0100 Subject: [PATCH 21/24] just record the temp every second --- kiln-tuner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index f8c8b94..666f663 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -43,7 +43,7 @@ def recordprofile(csvfile, targettemp): # * wait for it to decay back to the target again. # * quit # - # We record the temperature every config.sensor_time_wait + # We record the temperature every second try: stage = 'heating' if not config.simulate: @@ -68,7 +68,7 @@ def recordprofile(csvfile, targettemp): sys.stdout.write(f"\r{stage} {temp:.2f}/{targettemp} ") sys.stdout.flush() - time.sleep(config.sensor_time_wait) + time.sleep(1) f.close() From 8d06a2aa29c91431059476c6bd1e23651ee8230c Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:25:13 +0100 Subject: [PATCH 22/24] minor code cleanup --- kiln-tuner.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kiln-tuner.py b/kiln-tuner.py index 666f663..1253c2b 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -14,14 +14,13 @@ def recordprofile(csvfile, targettemp): import config sys.dont_write_bytecode = False - except: + except ImportError: print("Could not import config file.") print("Copy config.py.EXAMPLE to config.py and adapt it for your setup.") exit(1) 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 From f0e95215e09f9f575c2f73a43dc644a1bc980fa0 Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 14:34:57 +0100 Subject: [PATCH 23/24] tweak tangent divisor --- docs/ziegler_tuning.md | 2 +- kiln-tuner.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ziegler_tuning.md b/docs/ziegler_tuning.md index b070053..89719c8 100644 --- a/docs/ziegler_tuning.md +++ b/docs/ziegler_tuning.md @@ -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 kiln-tuner.py zn zn.csv --tangentdivisor 8 +python kiln-tuner.py zn zn.csv --tangentdivisor 4 ``` `tangentdivisor` modifies which parts of the profile is used to calculate the line. diff --git a/kiln-tuner.py b/kiln-tuner.py index 1253c2b..b7a3674 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -183,7 +183,7 @@ if __name__ == "__main__": 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 time (time in seconds) and temperature (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.add_argument('--tangentdivisor', type=float, default=8, help="Adjust the tangent calculation to fit better. Must be >= 2 (default 8).") parser_zn.set_defaults(mode='zn') args = parser.parse_args() From f98b2e614c6e954e16b86fd3e3f2a1f5a6f3a61e Mon Sep 17 00:00:00 2001 From: Andrew de Quincey Date: Sat, 1 May 2021 15:12:21 +0100 Subject: [PATCH 24/24] cope with no args --- kiln-tuner.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kiln-tuner.py b/kiln-tuner.py index b7a3674..1fdda65 100755 --- a/kiln-tuner.py +++ b/kiln-tuner.py @@ -174,6 +174,7 @@ def calculate(filename, tangentdivisor, showplot): if __name__ == "__main__": parser = argparse.ArgumentParser(description='Kiln tuner') subparsers = parser.add_subparsers() + parser.set_defaults(mode='') 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.") @@ -197,5 +198,9 @@ if __name__ == "__main__": calculate(args.csvfile, args.tangentdivisor, args.showplot) + elif args.mode == '': + parser.print_help() + exit(1) + else: raise NotImplementedError(f"Unknown mode {args.mode}")