Tuesday, August 27, 2013

Raspberry Pi MPG Fuel Economy Carputer

I’ve always had an interest in getting the best fuel economy out of cars that I drove. On June 14, 2006, I blogged about my Toyota Echo and its gas mileage. I’ve often wanted to buy a device like ScanGauge so I could see the instantaneous fuel economy I was getting.

A few weeks ago, the Raspberry.org site’s main item had to do with carputers. That, along with an article about the Quite Rubbish clock got me to thinking, how about a Raspberry Pi MPG reader?

Here’s the final result, well final, there is no case and I’m also thinking of adding various buttons to vary the output of the display. But for the moment…

The MPG computer in action

The top line lists the average of the last 3 seconds. The bottom line the average for the entire trip, followed by the duration of the trip in minutes. Since I’m in Canada, where fuel economy is expressed in litres consumed per 100 km driven, that’s what it is set up for. However, it would be fairly trivial to change the formula and output based on the user’s preference.

The code is based on Martin O’Hanlon’s obd_capture.py. However, I made significant changes in that the data is being captured into a SQLite3 database (which is actually used to compute the 3 second average). Furthermore, I included the output to the LCD in this class as well. (Yeah, I know, you shouldn’t be mixing data with user interface, but hey, I’m not getting paid for this, so I can do whatever I want.)

Every time the car starts, a new database with a unique name is created. It takes about 15 seconds for the Pi to boot and another 5 seconds for the Python script to start running. This database can be retrieved later for further analysis of the car’s performance.

The fuel economy is derived from the values given by the ELM327 (about $12 from China) OBDII reader for MAF (Mass Airflow meter) and VSS (vehicle speed). The formula for litres per 100 km is:

(3600 * MAF)/(9069.90 * VSS)

Credit for this formula should go to Bruce Lightner, who is probably one of the most knowledgeable people on this planet when it comes to OBDII (and moon rocks and more stuff)

The output goes to a Nokia 5110 LCD (Deal Extreme $5.60). In order to get a fairly large readout for driver visibility, I actually created an image in Python and then place that image on the LCD as a whole.

The OBDII connector in my car points downwards, and that might interfere with the driver feet, so I’ve ordered an extension cable from China for a few dollars which will alleviate this problem. This cable exits at right angles from the connector.

If you intend to build this device using the Nokia 5110 LCD, you’ll need to read my previous post regarding this device.

For Martin O’Hanlon excellent code, and related libraries type this at the command prompt:

sudo apt-get install python-serial
sudo apt-get install git-core
cd ~
git clone https://github.com/martinohanlon/pyobd
cd pyobd
python obd_capture.py

Here is my version of obd_capture.py:

   1: #!/usr/bin/env python
   2:  
   3: import obd_io
   4: import serial
   5: import platform
   6: import obd_sensors
   7: from datetime import datetime
   8: from PIL import Image,ImageDraw,ImageFont
   9: import ImageOps
  10: import nokiaSPI
  11: import time
  12: import os
  13: import sqlite3
  14: #from datetime import timedelta
  15:  
  16: from obd_utils import scanSerial
  17:  
  18: class OBD_Capture():
  19:     def __init__(self):
  20:         self.port = None
  21:         localtime = time.localtime(time.time())
  22:  
  23:     def connect(self):
  24:         portnames = scanSerial()
  25:         print portnames
  26:         for port in portnames:
  27:             self.port = obd_io.OBDPort(port, None, 2, 2)
  28:             if(self.port.State == 0):
  29:                 self.port.close()
  30:                 self.port = None
  31:             else:
  32:                 break
  33:  
  34:         if(self.port):
  35:             print "Connected to "+self.port.port.name
  36:  
  37:     def is_connected(self):
  38:         return self.port
  39:  
  40:     def nokiprint(self,cShortTerm,cLongTerm,cLongTermMinutes):
  41:         cShortTerm = cShortTerm[:4]
  42:         cLongTerm = cLongTerm[:4]
  43:         noki.cls()
  44:         im = Image.new('1', (84,48))
  45:         draw = ImageDraw.Draw(im)
  46:         print cShortTerm
  47:         draw.text((0,0),cShortTerm, font=font, fill=1)
  48:         draw.text((0,24),cLongTerm, font=font, fill=1)
  49:         draw.text((54,7),"3 s", font=fontsmall, fill=1)
  50:         draw.text((54,31),cLongTermMinutes + " m", font=fontsmall, fill=1)
  51:         # Copy it to the display
  52:         noki.show_image(im)
  53:         #noki.next_row()
  54:         
  55:     def ComputeFuelConsumption(self):
  56:         nCurrentTime = time.time()
  57:         
  58:         try:
  59:             nStart = nCurrentTime - 20
  60:             cLimit = " and time_read > " + str(nStart) + " order by time_read desc limit 6"
  61:             cursor.execute('''SELECT maf,speed from SensorReadings where speed > "0" and maf > "0" and rpm != "NODATA" ''' + cLimit)
  62:             data = cursor.fetchall()
  63:         except sqlite3.OperationalError,msg:
  64:             return msg
  65:         #print len(data)
  66:         if (len(data) > 0):
  67:             nFuelConsumption = 0
  68:             for x in range(0,len(data)):
  69:                 nFuelConsumption += (3600 * float(data[x][0]))/(9069.90 * float(data[x][1]))
  70:                 
  71:             nAvgFuelConsumption = nFuelConsumption/len(data)
  72:             print nAvgFuelConsumption
  73:             print type(nAvgFuelConsumption)
  74:             #print data[x][0],data[x][1],data[x][2]
  75:             return "{:5.2f}".format(nAvgFuelConsumption).lstrip()
  76:         else:
  77:             return "No data"
  78:  
  79:         #print nAvgFuelConsum
  80:         
  81:     def is_number(self,DataToTest):
  82:         try:
  83:             float(DataToTest)
  84:             return True
  85:         except ValueError:
  86:             return False    
  87:                 
  88:                 
  89:  
  90:                 
  91:     def capture_data(self):
  92:         #Creating new database
  93:         for kounter in range(10000):
  94:             cKounter = "{0:05d}".format(kounter)
  95:             cNewDatabase = "obdii" + cKounter + ".db"
  96:             #print cNewDatabase
  97:             if not (os.path.exists(cNewDatabase)):
  98:                 #print "New database name: " + cNewDatabase
  99:                 break
 100:  
 101:         global conn
 102:         global cursor
 103:         conn = sqlite3.connect(cNewDatabase)
 104:         cursor = conn.cursor()
 105:             
 106:         #Find supported sensors - by getting PIDs from OBD
 107:         # its a string of binary 01010101010101 
 108:         # 1 means the sensor is supported
 109:         self.supp = self.port.sensor(0)[1]
 110:         self.supportedSensorList = []
 111:         self.unsupportedSensorList = []
 112:         
 113:         
 114:         # loop through PIDs binary
 115:         for i in range(0, len(self.supp)):
 116:             if self.supp[i] == "1":
 117:                 # store index of sensor and sensor object
 118:                 self.supportedSensorList.append([i+1, obd_sensors.SENSORS[i+1]])
 119:             else:
 120:                 self.unsupportedSensorList.append([i+1, obd_sensors.SENSORS[i+1]])
 121:         
 122:         sqlCreateTable = "CREATE TABLE SensorReadings (time_read real, "
 123:         sqlInsertTemplate = "INSERT INTO SensorReadings(time_read, "
 124:         
 125:         for supportedSensor in self.supportedSensorList:
 126:             #print "supported sensor index = " + str(supportedSensor[0]) + " " + str(supportedSensor[1].shortname)
 127:             sqlCreateTable += str(supportedSensor[1].shortname)  + " text,"    
 128:             sqlInsertTemplate += str(supportedSensor[1].shortname)  + ","
 129:             
 130:         sqlCreateTable = sqlCreateTable[:sqlCreateTable.rfind(",")] + ")"
 131:         #print sqlCreateTable
 132:         try:
 133:             cursor.execute(sqlCreateTable)
 134:             conn.commit()
 135:             cursor.execute('''CREATE INDEX time_read_index on SensorReadings(time_read)''')
 136:             cMessage = "Database " + cNewDatabase + " created..."
 137:         except sqlite3.OperationalError,msg:
 138:             cMessage = msg
 139:         noki.cls()
 140:         noki.text(cMessage,wrap=True)
 141:         
 142:         sqlInsertTemplate = sqlInsertTemplate[:sqlInsertTemplate.rfind(",")] + ") VALUES ("
 143:         #print sqlInsertTemplate
 144:         
 145:         time.sleep(3)
 146:  
 147:         if(self.port is None):
 148:             return None
 149:         
 150:         #Loop until Ctrl C is pressed        
 151:         try:
 152:             nRunningTotalFuelConsumption = 0
 153:             nStartTime = time.time()
 154:             x = 0
 155:             while True:
 156:                 current_time = time.time()
 157:                 #current_time = str(localtime.hour)+":"+str(localtime.minute)+":"+str(localtime.second)+"."+str(localtime.microsecond)
 158:                 #log_string = current_time + "\n"
 159:                 sqlInsert = sqlInsertTemplate + '"' + str(current_time) + '",'
 160:                 results = {}
 161:                 for supportedSensor in self.supportedSensorList:
 162:                     sensorIndex = supportedSensor[0]
 163:                     #print sensorIndex
 164:                     (name, value, unit) = self.port.sensor(sensorIndex)
 165:                     #log_string += name + " = " + str(value) + " " + str(unit) + "\n"
 166:                     #print value,type(value)
 167:                     sqlInsert += '"' + str(value) + '",'    
 168:                 
 169:                 
 170:                 sqlInsert = sqlInsert[:sqlInsert.rfind(",")] + ")"
 171:                 #print sqlInsert
 172:  
 173:                 try:
 174:                     cursor.execute(sqlInsert)
 175:                     conn.commit()
 176:                 except sqlite3.OperationalError,msg:
 177:                     noki.cls()
 178:                     noki.text(msg,wrap=True)    
 179:                     continue
 180:                     
 181:                 cFuelConsumption = self.ComputeFuelConsumption()
 182:                 if (cFuelConsumption != "No data"):
 183:                     x += 1
 184:                 if (x > 0):
 185:                     if (self.is_number):
 186:                         nRunningTotalFuelConsumption += float(cFuelConsumption)
 187:                     nTripAverage = nRunningTotalFuelConsumption/x
 188:                     cTripAverage = "{:5.2f}".format(nTripAverage).lstrip()
 189:                 else:
 190:                     cTripAverage = "Nodata"
 191:                 
 192:                 cDurationInMinutes = "{:3.0f}".format((current_time - nStartTime)/60).lstrip()
 193:                 self.nokiprint(cFuelConsumption,cTripAverage,cDurationInMinutes)
 194:                     
 195:                 #print log_string,
 196:                 #time.sleep(0.5)
 197:                 
 198:  
 199:         except KeyboardInterrupt:
 200:             self.port.close()
 201:             print("stopped")
 202:             
 203: if __name__ == "__main__":
 204:     font = ImageFont.truetype("/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",26)
 205:     fontsmall = ImageFont.truetype("/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", 16)
 206:     # New b-w image
 207:     im = Image.new('1', (84,48))
 208:     noki = nokiaSPI.NokiaSPI(brightness=268)              # create display device
 209:     noki.cls()
 210:     noki.text("Initializing..",wrap=True)
 211:  
 212:  
 213:     o = OBD_Capture()
 214:     o.connect()
 215:     time.sleep(3)
 216:     if not o.is_connected():
 217:         print "Not connected"
 218:         noki.cls()
 219:         noki.text("Error: Not connected to OBDII...",wrap=True)
 220:         time.sleep(10)
 221:         noki.set_brightness(0)
 222:         noki.cls()
 223:         exit()
 224:     else:
 225:         o.capture_data()

In order for the Python script to automatically start when the Pi boots up, I added this line to /etc/rc.local :


(cd /home/pi/pythonprogs;python obd_capture.py)&


That line should only be added when you are relatively confident it will all work. First, for testing, you will need to run a network cable to your car (or tap in wirelessly, if you are within range). This is what my setup looked like, during early testing:


IMG_1136


The blue cable was networking, the yellow powered the Pi. Later on, I removed the power cable and ran off the car’s battery. It is quite tricky to have to program for something that has to run headless and detached from everything. So when making changes to your code, check, check and double check again.


I’ve also ordered from MausBerryCircuits.com an illuminated LED shutdown switch, which will gracefully shut down the Pi before power is removed, reducing the chance of corrupting the SD card.


That’s it!


Update 2013-08-31:


I’ve optimized the fuel consumption function and made SQLite do the average calculation, alleviating the need do to the calculation in a Python ‘for’ loop. Also, made the SQLite connection and cursor class attributes rather than global variables, which are generally considered undesirable.