ntpheatusb 8.14 KB
Newer Older
1 2 3 4 5 6 7 8
#!/usr/bin/env python
#
# generate some heat!
#
# Wrap your RasPi in a closed box.  Get a usbrelay1 to control
# an incadescent light bulb in the box.  Heat to 45C.  profit.
#
# This code depends on the program 'usbrelay' to manage your usbrelay
9 10 11
# connected device.
#   Get it here:  git@github.com:darrylb123/usbrelay.git
#   Update the usbrelay_on/off variables below with your device ID
12
#
13 14 15 16 17
# This code depends on the program 'temper-poll' to read the box temp
# from an attached TEMPer device.
#   Get it here: git@github.com:padelt/temper-python.git
#
# ntpheatusb will use a lot less CPU than ntpheat, and more directly
18 19
# heats the XTAL rather than the CPU.
#
20
# Avoid the desire to decrease the wait time.  The relay clocks twice
21 22
# per cycle, and those cycles add up.  Minimize wear on your relay.
#
23 24
# Try the simple P controller (the default) before trying the PID controller.
# The PID controller may take some fiddling with the constants to get
25
# it working better than the simple P controller.
26 27
#
# More info on the blog post: https://blog.ntpsec.org/2017/03/21/More_Heat.html
28

29 30
from __future__ import print_function, division

31
import argparse
32
import atexit
33 34 35 36 37 38 39
import subprocess
import sys
import time

try:
    import ntp.util
except ImportError as e:
40 41
    sys.stderr.write("ntpheatusb: can't find Python NTP modules. "
                     "-- check PYTHONPATH.\n%s\n" % e)
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
    sys.exit(1)


def run_binary(cmd):
    """\
Run a binary
Return its output if good, None if bad
"""

    try:
        # sadly subprocess.check_output() is not in Python 2.6
        # so use Popen()
        # this throws an exception if not found
        proc = subprocess.Popen(cmd,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.STDOUT,
                                universal_newlines=True)
        output = proc.communicate()[0].split("\n")

        if proc.returncode:
            # non-zero return code, fail
            print("Return %s" % proc.returncode)
            return None

    except ImportError as e:
        sys.stderr.write("Unable to run %s binary\n" % cmd[0])
        print(cmd)
        sys.stderr.write("%s\n" % e)
        return None

    return output


class PID:
    """\
Discrete PID control
"""

    def __init__(self, setpoint=0.0,
                 P=2.0, I=0.0, D=1.0,
                 Derivator=0, Integrator=0,
                 Integrator_max=100, Integrator_min=-100):

        self.Kp = P
        self.Ki = I
        self.Kd = D
        self.Derivator = Derivator
        self.Integrator = Integrator
        self.Integrator_max = Integrator_max
        self.Integrator_min = Integrator_min

        self.set_point = setpoint
        self.error = 0.0

    def __repr__(self):
97
        return "D_value=%s, I_value=%s" % (self.D_value, self.I_value)
98

Gary E. Miller's avatar
Gary E. Miller committed
99
    def setPoint(self, set_point):
Matt Selsky's avatar
Matt Selsky committed
100 101 102 103
        """
        Initialize the setpoint of PID
        """
        self.set_point = set_point
104

105 106 107 108 109 110 111 112 113 114 115 116 117 118
    def update(self, current_value):
        """
        Calculate PID output value for given reference input and feedback
        """

        self.error = self.set_point - current_value

        self.P_value = self.Kp * self.error
        self.D_value = self.Kd * (self.error - self.Derivator)
        self.Derivator = self.error

        self.Integrator = self.Integrator + self.error

        if self.Integrator > self.Integrator_max:
Matt Selsky's avatar
Matt Selsky committed
119
            self.Integrator = self.Integrator_max
120
        elif self.Integrator < self.Integrator_min:
Matt Selsky's avatar
Matt Selsky committed
121
            self.Integrator = self.Integrator_min
122 123 124 125 126 127 128

        self.I_value = self.Integrator * self.Ki

        PID = self.P_value + self.I_value + self.D_value

        return PID

Matt Selsky's avatar
Matt Selsky committed
129

130 131 132 133 134 135
# Work with argvars
parser = argparse.ArgumentParser(description="make heat with USB relay")
parser.add_argument('-p', '--pid',
                    action="store_true",
                    dest='pid',
                    help="Use PID controller instead of simple P controller.")
136 137 138
parser.add_argument('-s', '--step',
                    action="store_true",
                    dest='step',
Gary E. Miller's avatar
Gary E. Miller committed
139 140
                    help="Step up 1C every 2 hours for 20 hours, "
                         "then back down.")
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
parser.add_argument('-t', '--temp',
                    default=[45.0],
                    dest='target_temp',
                    help="Temperature to hold in C.  Default is 45.0C",
                    nargs=1,
                    type=float)
parser.add_argument('-w', '--wait',
                    default=[60],
                    dest='wait',
                    help="Set delay time in seconds, default is 60",
                    nargs=1,
                    type=float)
parser.add_argument('-v', '--verbose',
                    action="store_true",
                    dest='verbose',
                    help="be verbose")
parser.add_argument('-V', '--version',
                    action="version",
159
                    version="ntpheatusb %s" % ntp.util.stdversion())
160 161 162 163 164 165
args = parser.parse_args()

zone0 = '/sys/class/thermal/thermal_zone0/temp'
cnt = 0

temp_gate = args.target_temp[0]
166
start_temp_gate = temp_gate
167 168 169 170 171 172
period = float(args.wait[0])

# you will need to personalize these to your relay ID:
usbrelay_on = ['usbrelay', '959BI_1=1']
usbrelay_off = ['usbrelay', '959BI_1=0']

173 174 175
# turn off the usb relay on exit.  no need to cook..
atexit.register(run_binary, usbrelay_off)

176
# to adjust the PID variables
177 178 179 180 181 182 183 184 185
# set I and D to zero
#
# increase P until you get a small overshoot, and mostly damped response,
# to a large temp change
#
# then increase I until the persistent error goes away.
#
# if the temp oscillates then increase D
#
186 187 188 189 190
pid = PID(setpoint=temp_gate, P=35.0, I=10.0, D=10.0)

start_time = time.time()
step_time = start_time
step = 0
191

192
start_time = time.time()
193 194 195
step_time = start_time
step = 0

196 197
try:
    while True:
198
        if args.step:
199 200
            now = time.time()
            if 7200 < (now - step_time):
201 202 203 204 205 206 207 208 209 210
                # time to step
                step_time = now
                step += 1
                if 0 <= step:
                    # step up
                    temp_gate += 1.0
                else:
                    # step down
                    temp_gate -= 1.0
                if 9 < step:
211 212
                    step = -11
            pid.setPoint(temp_gate)
213

214 215 216 217 218 219 220 221 222 223
        # only one device can read the TEMPer at a time
        # collisions will happen, so retry a few times
        for attempt in range(0, 3):
            # grab the needed output
            fail = False
            output = run_binary(["temper-poll", "-c"])
            try:
                # make sure it is a temperature
                temp = float(output[0])
                break
224
            except ValueError:
225 226 227 228 229 230 231 232
                # bad data, try aagin
                fail = True
                if args.verbose:
                    print("temper read failed: %s" % output)

        if fail:
            # give up
            print("temper fatal error")
233 234
            sys.exit(1)

235
        # the +20 is to create an 80/20 band around the setpoint
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287
        p_val = pid.update(temp) + 20
        p_val1 = p_val
        if p_val > 100:
            p_val1 = 100
        elif p_val < 0:
            p_val1 = 0

        if temp > temp_gate:
            perc_t = 0
        elif temp < (temp_gate - 3):
            perc_t = 100
        else:
            perc_t = ((temp_gate - temp) / 3) * 100

        if args.pid:
            # use PID controller
            perc = p_val1
        else:
            # use P controller
            perc = perc_t

        if perc > 0:
            output = run_binary(usbrelay_on)
            time_on = period * (perc / 100)
            time.sleep(time_on)
        else:
            time_on = 0

        time_off = period - time_on
        output = run_binary(usbrelay_off)

        if args.verbose:
            print("Temp %s, perc %.2f, p_val %s/%s"
                  % (temp, perc_t, p_val, p_val1))
            print("on %s, off %s" % (time_on, time_off))
            print(pid)

        if 0 < time_off:
            time.sleep(time_off)

except KeyboardInterrupt:
    print("\nCaught ^C")
    run_binary(usbrelay_off)
    sys.exit(1)
    # can't fall through ???

except IOError:
    # exception catcher
    # turn off the heat!
    run_binary(usbrelay_off)
    print("\nCaught exception, exiting\n")
    sys.exit(1)