├── .gitignore ├── LICENSE ├── README.md ├── pumpy.py ├── pumpy.vi └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | *.egg 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Thomas W. Phillips 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pumpy: computer control of your syringe pumps 2 | 3 | Pumpy allows you to control your Harvard syringe pump or Mighty Mini piston pump from your computer over an RS-232 interface. 4 | 5 | ## pumpy is no longer maintained 6 | 7 | Occasionally I receive pull requests and issues but because I don't work in a lab anymore I have no way to test them. 8 | I also wrote Pumpy when I was a relatively novice Python programmer (there aren't any automated tests for a start) so it needs a lot of work. 9 | Hence I'm no longer maintaining pumpy. 10 | 11 | That said, there's no reason why it shouldn't still work with the pumps below. If it doesn't work, pumpy is MIT licensed so you're free to make and distribute your own changes. 12 | 13 | ## Supported pumps 14 | 15 | * Harvard Pump 11 16 | * Harvard Pump 11 Plus 17 | * Harvard PHD2000 18 | * Mighty Mini piston pump 19 | 20 | ## Features 21 | 22 | * For Harvard Pump 11, Pump 11 Plus, and PHD2000: 23 | * infuse 24 | * withdraw 25 | * set diameter 26 | * set flow rate 27 | * set target volume 28 | * wait until target volume 29 | * For Mighty Mini: 30 | * set flow rate 31 | * start 32 | * top 33 | * Supports [`logging`](https://ocs.python.org/2/library/logging.html) to record all operations. 34 | 35 | ## Requirements 36 | * Python 2.7.3 or higher 37 | * [PySerial](http://pyserial.sourceforge.net) 2.6 or higher 38 | * Computer with RS-232 port or a USB-serial adapter 39 | * Cable to connect your pump. See the pump manual for the correct wiring. 40 | 41 | ## Install 42 | 43 | `pip install pumpy` 44 | 45 | ## Usage 46 | 47 | Run `python -m pumpy --help` to see command line options. 48 | 49 | Alternatively you can use it in your existing code: 50 | 51 | ``` 52 | chain = pumpy.Chain('/dev/tty.usbserial-FTWOFH91A') 53 | 54 | p11 = pumpy.Pump(chain,address=1) 55 | p11.setdiameter(10) # mm 56 | p11.setflowrate(2000) ## microL/min 57 | p11.settargetvolume(200) ## microL 58 | p11.infuse() 59 | p11.waituntiltarget() ## blocks until target reached 60 | p11.withdraw() 61 | p11.waituntiltarget() 62 | 63 | phd = pumpy.PHD2000(chain,address=4) 64 | phd.setdiameter(24) 65 | phd.setflowrate(600) 66 | phd.infuse() 67 | phd.stop() 68 | phd.withdraw() 69 | phd.stop() 70 | phd.settargetvolume(100) 71 | phd.infuse() 72 | phd.waituntiltarget() 73 | 74 | chain.close() 75 | ``` 76 | 77 | ## Known Issues 78 | 79 | 1. Harvard PHD2000 supports higher precision when setting flow rates/diameters than the Pump 11. At present everything is truncated for compatibility with the Pump 11. 80 | 2. PHD2000 requires "withdraw, stop, infuse" rather than "withdraw, infuse" otherwise it doesn't respond. 81 | 3. PHD2000 will only take notice of target volumes when it has been put into volume mode using the keypad. 82 | 83 | ## Acknowledgements 84 | 85 | Thanks to [Sam Macbeth](https://github.com/sammacbeth) for adding support for the Mighty Mini. 86 | -------------------------------------------------------------------------------- /pumpy.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import serial 3 | import argparse 4 | import logging 5 | 6 | def remove_crud(string): 7 | """Return string without useless information. 8 | 9 | Return string with trailing zeros after a decimal place, trailing 10 | decimal points, and leading and trailing spaces removed. 11 | """ 12 | if "." in string: 13 | string = string.rstrip('0') 14 | 15 | string = string.lstrip('0 ') 16 | string = string.rstrip(' .') 17 | 18 | return string 19 | 20 | class Chain(serial.Serial): 21 | """Create Chain object. 22 | 23 | Harvard syringe pumps are daisy chained together in a 'pump chain' 24 | off a single serial port. A pump address is set on each pump. You 25 | must first create a chain to which you then add Pump objects. 26 | 27 | Chain is a subclass of serial.Serial. Chain creates a serial.Serial 28 | instance with the required parameters, flushes input and output 29 | buffers (found during testing that this fixes a lot of problems) and 30 | logs creation of the Chain. 31 | """ 32 | def __init__(self, port): 33 | serial.Serial.__init__(self,port=port, stopbits=serial.STOPBITS_TWO, parity=serial.PARITY_NONE, timeout=2) 34 | self.flushOutput() 35 | self.flushInput() 36 | logging.info('Chain created on %s',port) 37 | 38 | class Pump: 39 | """Create Pump object for Harvard Pump 11. 40 | 41 | Argument: 42 | Chain: pump chain 43 | 44 | Optional arguments: 45 | address: pump address. Default is 0. 46 | name: used in logging. Default is Pump 11. 47 | """ 48 | def __init__(self, chain, address=0, name='Pump 11'): 49 | self.name = name 50 | self.serialcon = chain 51 | self.address = '{0:02.0f}'.format(address) 52 | self.diameter = None 53 | self.flowrate = None 54 | self.targetvolume = None 55 | 56 | """Query model and version number of firmware to check pump is 57 | OK. Responds with a load of stuff, but the last three characters 58 | are XXY, where XX is the address and Y is pump status. :, > or < 59 | when stopped, running forwards, or running backwards. Confirm 60 | that the address is correct. This acts as a check to see that 61 | the pump is connected and working.""" 62 | try: 63 | self.write('VER') 64 | resp = self.read(17) 65 | 66 | if int(resp[-3:-1]) != int(self.address): 67 | raise PumpError('No response from pump at address %s' % 68 | self.address) 69 | except PumpError: 70 | self.serialcon.close() 71 | raise 72 | 73 | logging.info('%s: created at address %s on %s', self.name, 74 | self.address, self.serialcon.port) 75 | 76 | def __repr__(self): 77 | string = '' 78 | for attr in self.__dict__: 79 | string += '%s: %s\n' % (attr,self.__dict__[attr]) 80 | return string 81 | 82 | def write(self,command): 83 | self.serialcon.write(self.address + command + '\r') 84 | 85 | def read(self,bytes=5): 86 | response = self.serialcon.read(bytes) 87 | 88 | if len(response) == 0: 89 | raise PumpError('%s: no response to command' % self.name) 90 | else: 91 | return response 92 | 93 | def setdiameter(self, diameter): 94 | """Set syringe diameter (millimetres). 95 | 96 | Pump 11 syringe diameter range is 0.1-35 mm. Note that the pump 97 | ignores precision greater than 2 decimal places. If more d.p. 98 | are specificed the diameter will be truncated. 99 | """ 100 | if diameter > 35 or diameter < 0.1: 101 | raise PumpError('%s: diameter %s mm is out of range' % 102 | (self.name, diameter)) 103 | 104 | # TODO: Got to be a better way of doing this with string formatting 105 | diameter = str(diameter) 106 | 107 | # Pump only considers 2 d.p. - anymore are ignored 108 | if len(diameter) > 5: 109 | if diameter[2] is '.': # e.g. 30.2222222 110 | diameter = diameter[0:5] 111 | elif diameter[1] is '.': # e.g. 3.222222 112 | diameter = diameter[0:4] 113 | 114 | diameter = remove_crud(diameter) 115 | logging.warning('%s: diameter truncated to %s mm', self.name, 116 | diameter) 117 | else: 118 | diameter = remove_crud(diameter) 119 | 120 | # Send command 121 | self.write('MMD' + diameter) 122 | resp = self.read(5) 123 | 124 | # Pump replies with address and status (:, < or >) 125 | if (resp[-1] == ':' or resp[-1] == '<' or resp[-1] == '>'): 126 | # check if diameter has been set correctlry 127 | self.write('DIA') 128 | resp = self.read(15) 129 | returned_diameter = remove_crud(resp[3:9]) 130 | 131 | # Check diameter was set accurately 132 | if returned_diameter != diameter: 133 | logging.error('%s: set diameter (%s mm) does not match diameter' 134 | ' returned by pump (%s mm)', self.name, diameter, 135 | returned_diameter) 136 | elif returned_diameter == diameter: 137 | self.diameter = float(returned_diameter) 138 | logging.info('%s: diameter set to %s mm', self.name, 139 | self.diameter) 140 | else: 141 | raise PumpError('%s: unknown response to setdiameter' % self.name) 142 | 143 | def setflowrate(self, flowrate): 144 | """Set flow rate (microlitres per minute). 145 | 146 | Flow rate is converted to a string. Pump 11 requires it to have 147 | a maximum field width of 5, e.g. "XXXX." or "X.XXX". Greater 148 | precision will be truncated. 149 | 150 | The pump will tell you if the specified flow rate is out of 151 | range. This depends on the syringe diameter. See Pump 11 manual. 152 | """ 153 | flowrate = str(flowrate) 154 | 155 | if len(flowrate) > 5: 156 | flowrate = flowrate[0:5] 157 | flowrate = remove_crud(flowrate) 158 | logging.warning('%s: flow rate truncated to %s uL/min', self.name, 159 | flowrate) 160 | else: 161 | flowrate = remove_crud(flowrate) 162 | 163 | self.write('ULM' + flowrate) 164 | resp = self.read(5) 165 | 166 | if (resp[-1] == ':' or resp[-1] == '<' or resp[-1] == '>'): 167 | # Flow rate was sent, check it was set correctly 168 | self.write('RAT') 169 | resp = self.read(150) 170 | returned_flowrate = remove_crud(resp[2:8]) 171 | 172 | if returned_flowrate != flowrate: 173 | logging.error('%s: set flowrate (%s uL/min) does not match' 174 | 'flowrate returned by pump (%s uL/min)', 175 | self.name, flowrate, returned_flowrate) 176 | elif returned_flowrate == flowrate: 177 | self.flowrate = returned_flowrate 178 | logging.info('%s: flow rate set to %s uL/min', self.name, 179 | self.flowrate) 180 | elif 'OOR' in resp: 181 | raise PumpError('%s: flow rate (%s uL/min) is out of range' % 182 | (self.name, flowrate)) 183 | else: 184 | raise PumpError('%s: unknown response' % self.name) 185 | 186 | def infuse(self): 187 | """Start infusing pump.""" 188 | self.write('RUN') 189 | resp = self.read(5) 190 | while resp[-1] != '>': 191 | if resp[-1] == '<': # wrong direction 192 | self.write('REV') 193 | else: 194 | raise PumpError('%s: unknown response to to infuse' % self.name) 195 | resp = self.serialcon.read(5) 196 | 197 | logging.info('%s: infusing',self.name) 198 | 199 | def withdraw(self): 200 | """Start withdrawing pump.""" 201 | self.write('REV') 202 | resp = self.read(5) 203 | 204 | while resp[-1] != '<': 205 | if resp[-1] == ':': # pump not running 206 | self.write('RUN') 207 | elif resp[-1] == '>': # wrong direction 208 | self.write('REV') 209 | else: 210 | raise PumpError('%s: unknown response to withdraw' % self.name) 211 | break 212 | resp = self.read(5) 213 | 214 | logging.info('%s: withdrawing',self.name) 215 | 216 | def stop(self): 217 | """Stop pump.""" 218 | self.write('STP') 219 | resp = self.read(5) 220 | 221 | if resp[-1] != ':': 222 | raise PumpError('%s: unexpected response to stop' % self.name) 223 | else: 224 | logging.info('%s: stopped',self.name) 225 | 226 | def settargetvolume(self, targetvolume): 227 | """Set the target volume to infuse or withdraw (microlitres).""" 228 | self.write('MLT' + str(targetvolume)) 229 | resp = self.read(5) 230 | 231 | # response should be CRLFXX:, CRLFXX>, CRLFXX< where XX is address 232 | # Pump11 replies with leading zeros, e.g. 03, but PHD2000 misbehaves and 233 | # returns without and gives an extra CR. Use int() to deal with 234 | if resp[-1] == ':' or resp[-1] == '>' or resp[-1] == '<': 235 | self.targetvolume = float(targetvolume) 236 | logging.info('%s: target volume set to %s uL', self.name, 237 | self.targetvolume) 238 | else: 239 | raise PumpError('%s: target volume not set' % self.name) 240 | 241 | def waituntiltarget(self): 242 | """Wait until the pump has reached its target volume.""" 243 | logging.info('%s: waiting until target reached',self.name) 244 | # counter - need it to check if it's the first loop 245 | i = 0 246 | 247 | while True: 248 | # Read once 249 | self.serialcon.write(self.address + 'VOL\r') 250 | resp1 = self.read(15) 251 | 252 | if ':' in resp1 and i == 0: 253 | raise PumpError('%s: not infusing/withdrawing - infuse or ' 254 | 'withdraw first', self.name) 255 | elif ':' in resp1 and i != 0: 256 | # pump has already come to a halt 257 | logging.info('%s: target volume reached, stopped',self.name) 258 | break 259 | 260 | # Read again 261 | self.serialcon.write(self.address + 'VOL\r') 262 | resp2 = self.read(15) 263 | 264 | # Check if they're the same - if they are, break, otherwise continue 265 | if resp1 == resp2: 266 | logging.info('%s: target volume reached, stopped',self.name) 267 | break 268 | 269 | i = i+1 270 | 271 | class PHD2000(Pump): 272 | """Harvard PHD2000 pump object. 273 | 274 | Inherits from Pump class, but needs its own class as it doesn't 275 | stick to the Pump 11 protocol with commands to stop and set the 276 | target volume. 277 | """ 278 | def stop(self): 279 | """Stop pump.""" 280 | self.write('STP') 281 | resp = self.read(5) 282 | 283 | if resp[-1] == '*': 284 | logging.info('%s: stopped',self.name) 285 | else: 286 | raise PumpError('%s: unexpected response to stop', self.name) 287 | 288 | def settargetvolume(self, targetvolume): 289 | """Set the target volume to infuse or withdraw (microlitres).""" 290 | 291 | # PHD2000 expects target volume in mL not uL like the Pump11, so convert to mL 292 | targetvolume = str(float(targetvolume)/1000.0) 293 | 294 | if len(targetvolume) > 5: 295 | targetvolume = targetvolume[0:5] 296 | logging.warning('%s: target volume truncated to %s mL',self.name,targetvolume) 297 | 298 | self.write('MLT' + targetvolume) 299 | resp = self.read(5) 300 | 301 | # response should be CRLFXX:, CRLFXX>, CRLFXX< where XX is address 302 | # Pump11 replies with leading zeros, e.g. 03, but PHD2000 misbehaves and 303 | # returns without and gives an extra CR. Use int() to deal with 304 | if resp[-1] == ':' or resp[-1] == '>' or resp[-1] == '<': 305 | # Been set correctly, so put it back in the object (as uL, not mL) 306 | self.targetvolume = float(targetvolume)*1000.0 307 | logging.info('%s: target volume set to %s uL', self.name, 308 | self.targetvolume) 309 | 310 | class MightyMini(): 311 | def __init__(self, chain, name='Mighty Mini'): 312 | self.name = name 313 | self.serialcon = chain.serialcon 314 | logging.info('%s: created on %s',self.name,self.serialcon.port) 315 | 316 | def __repr__(self): 317 | string = '' 318 | for attr in self.__dict__: 319 | string += '%s: %s\n' % (attr,self.__dict__[attr]) 320 | return string 321 | 322 | def setflowrate(self, flowrate): 323 | flowrate = int(flowrate) 324 | if flowrate > 9999: 325 | flowrate = 9999 326 | logging.warning('%s: flow rate limited to %s uL/min', self.name, 327 | flowrate) 328 | 329 | self.serialcon.write('FM' + "{:04d}".format(flowrate)) 330 | resp = self.serialcon.read(3) 331 | self.serialcon.flushInput() 332 | if len(resp) == 0: 333 | raise PumpError('%s: no response to set flowrate', self.name) 334 | elif resp[0] == 'O' and resp[1] == 'K': 335 | # flow rate sent, check it is correct 336 | self.serialcon.write('CC') 337 | resp = self.serialcon.read(11) 338 | returned_flowrate = int(float(resp[5:-1])*1000) 339 | if returned_flowrate != flowrate: 340 | raise PumpError('%s: set flowrate (%s uL/min) does not match' 341 | ' flowrate returned by pump (%s uL/min)', 342 | self.name, flowrate, returned_flowrate) 343 | elif returned_flowrate == flowrate: 344 | self.flowrate = returned_flowrate 345 | logging.info('%s: flow rate set to %s uL/min', self.name, 346 | self.flowrate) 347 | else: 348 | raise PumpError('%s: error setting flow rate (%s uL/min)', 349 | self.name,flowrate) 350 | 351 | def infuse(self): 352 | self.serialcon.write('RU') 353 | resp = self.serialcon.read(3) 354 | if len(resp) == 0: 355 | raise PumpError('%s: no response to infuse',self.name) 356 | elif resp[0] == 'O' and resp[1] == 'K': 357 | logging.info('%s: infusing',self.name) 358 | 359 | def stop(self): 360 | self.serialcon.write('ST') 361 | resp = self.serialcon.read(3) 362 | if len(resp) == 0: 363 | raise PumpError('%s: no response to stop',self.name) 364 | elif resp[0] == 'O' and resp[1] == 'K': 365 | logging.info('%s: stopping',self.name) 366 | 367 | class PumpError(Exception): 368 | pass 369 | 370 | # Command line options 371 | # Run with -h flag to see help 372 | 373 | if __name__ == '__main__': 374 | parser = argparse.ArgumentParser(description='Command line interface to ' 375 | 'pumpy module for control of Harvard Pump ' 376 | '11 (default) or PHD2000 syringe pumps, or' 377 | ' SSI Mighty Mini Pump') 378 | parser.add_argument('port', help='serial port') 379 | parser.add_argument('address', help='pump address (Harvard pumps)',type=int, 380 | nargs='?', default=0) 381 | parser.add_argument('-d', dest='diameter', help='set syringe diameter', 382 | type=int) 383 | parser.add_argument('-f', dest='flowrate', help='set flow rate') 384 | parser.add_argument('-t', dest='targetvolume', help='set target volume') 385 | parser.add_argument('-w', dest='wait', help='wait for target volume to be' 386 | ' reached; use with -infuse or -withdraw', 387 | action='store_true') 388 | 389 | # TODO: only allow -w if infuse, withdraw or stop have been specified 390 | group = parser.add_mutually_exclusive_group() 391 | group.add_argument('-infuse', action='store_true') 392 | group.add_argument('-withdraw', action="store_true") 393 | group.add_argument('-stop', action="store_true") 394 | 395 | pumpgroup = parser.add_mutually_exclusive_group() 396 | pumpgroup.add_argument('-PHD2000', help='To control PHD2000', 397 | action='store_true') 398 | pumpgroup.add_argument('-MightyMini', help='To control Mighty Mini', 399 | action='store_true') 400 | args = parser.parse_args() 401 | 402 | if args.MightyMini: 403 | chain = Chain(args.port, stopbits=serial.STOPBITS_ONE) 404 | else: 405 | chain = Chain(args.port) 406 | 407 | # Command precedence: 408 | # 1. stop 409 | # 2. set diameter 410 | # 3. set flow rate 411 | # 4. set target 412 | # 5. infuse|withdraw (+ wait for target volume) 413 | 414 | try: 415 | if args.PHD2000: 416 | pump = PHD2000(chain, args.address, name='PHD2000') 417 | elif args.MightyMini: 418 | pump = MightyMini(chain, name='MightyMini') 419 | else: 420 | pump = Pump(chain,args.address, name='11') 421 | 422 | if args.stop: 423 | pump.stop() 424 | 425 | if args.diameter: 426 | pump.setdiameter(args.diameter) 427 | 428 | if args.flowrate: 429 | pump.setflowrate(args.flowrate) 430 | 431 | if args.targetvolume: 432 | pump.settargetvolume(args.targetvolume) 433 | 434 | if args.infuse: 435 | pump.infuse() 436 | if args.wait: 437 | pump.waituntiltarget() 438 | 439 | if args.withdraw: 440 | pump.withdraw() 441 | if args.wait: 442 | pump.waituntiltarget() 443 | finally: 444 | chain.close() 445 | -------------------------------------------------------------------------------- /pumpy.vi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwphillips/pumpy/3e49d71a73d23ea6f16082109bd4fac90a69119b/pumpy.vi -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup 5 | 6 | config = { 7 | 'name': 'pumpy', 8 | 'version': '1.1.3', 9 | 'description': 'Python RS-232 interface for Harvard syringe pumps', 10 | 'url': 'https://github.com/tomwphillips/pumpy', 11 | 'author': 'Tom W Phillips', 12 | 'author_email': 'me@tomwphillips.co.uk', 13 | 'license': 'MIT', 14 | 'install_requires': ['pyserial>=2.7'], 15 | 'py_modules': ['pumpy'] 16 | } 17 | 18 | setup(classifiers=[ 19 | 'Programming Language :: Python', 20 | 'Operating System :: OS Independent', 21 | 'Intended Audience :: Science/Research', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Topic :: Scientific/Engineering'], 24 | **config) 25 | --------------------------------------------------------------------------------