Skip to content

Commit 5ced5bc

Browse files
committed
Add class EPAVin with magical EPA vin decoding powers. MPG? Got it! CO2? Right here!
1 parent 31d6bef commit 5ced5bc

3 files changed

Lines changed: 609 additions & 70 deletions

File tree

libvin/epa.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
"""
2+
Fetch data from fueleconomy.gov
3+
(c) Copyright 2016 Dan Kegel <dank@kegel.com>
4+
License: AGPL v3.0
5+
"""
6+
7+
# Note: client app may wish to 'import requests_cache' and install a cache
8+
# to avoid duplicate fetches
9+
import requests
10+
import itertools
11+
import json
12+
import xmltodict
13+
14+
# Local
15+
from decoding import Vin
16+
from nhtsa import *
17+
18+
class EPAVin(Vin):
19+
20+
# Public interfaces
21+
22+
def __init__(self, vin):
23+
super(EPAVin, self).__init__(vin)
24+
25+
self.__nhtsa = nhtsa_decode(vin)
26+
self.__attribs = self.__get_attributes()
27+
self.__model = self.__get_model()
28+
if (self.__model != None):
29+
self.__ids, self.__trims = self.__get_ids()
30+
self.__eco = self.__get_vehicle_economy()
31+
32+
@property
33+
def nhtsa(self):
34+
'''
35+
NHTSA info dictionary for this vehicle.
36+
'''
37+
return self.__nhtsa
38+
39+
@property
40+
def nhtsaModel(self):
41+
'''
42+
NHTSA model name for this vehicle.
43+
'''
44+
return self.nhtsa['Model']
45+
46+
@property
47+
def model(self):
48+
'''
49+
EPA model name for this vehicle.
50+
'''
51+
return self.__model
52+
53+
@property
54+
def id(self):
55+
'''
56+
EPA id for this vehicle.
57+
'''
58+
# FIXME: If we don't know which trim exactly, just pick the
59+
# first one. We're guessing anyway, what with the fuzzy matching and all...
60+
return self.__ids[0]
61+
62+
@property
63+
def trim(self):
64+
'''
65+
EPA trim for this vehicle.
66+
'''
67+
# FIXME: If we don't know which trim exactly, just pick the
68+
# first one. We're guessing anyway, what with the fuzzy matching and all...
69+
return self.__trims[0]
70+
71+
@property
72+
def eco(self):
73+
'''
74+
EPA fuel economy info dictionary for this vehicle.
75+
Fields of interest:
76+
- co2TailpipeGpm - present for most vehicles
77+
- co2TailpipeAGpm - present for some vehicles, matches EPA website
78+
'''
79+
return self.__eco
80+
81+
# Private interfaces
82+
83+
def __get_attributes(self):
84+
'''
85+
Returns a list of adjectives for this vehicle that might help identify it in EPA model or trim names
86+
'''
87+
# Strongest attribute: the model name!
88+
attributes = [self.nhtsa['Model']]
89+
90+
driveType = self.nhtsa['DriveType']
91+
if 'AWD' in driveType:
92+
attributes.append("AWD")
93+
elif '4WD' in driveType or '4x4' in driveType:
94+
attributes.append("4WD")
95+
elif '4x2' in driveType:
96+
attributes.append("2WD")
97+
elif 'Front' in driveType or 'FWD' in driveType:
98+
attributes.append("FWD")
99+
attributes.append("2WD")
100+
101+
if 'Trim' in self.nhtsa and self.nhtsa['Trim'] != "":
102+
attributes.append(self.nhtsa['Trim'])
103+
if 'BodyClass' in self.nhtsa and self.nhtsa['BodyClass'] != "":
104+
attributes.append(self.nhtsa['BodyClass'])
105+
if 'Series' in self.nhtsa and self.nhtsa['Series'] != "":
106+
attributes.append(self.nhtsa['Series'])
107+
if 'Series2' in self.nhtsa and self.nhtsa['Series2'] != "":
108+
attributes.append(self.nhtsa['Series2'])
109+
110+
if 'DisplacementL' in self.nhtsa and self.nhtsa['DisplacementL'] != '':
111+
attributes.append('%s L' % self.nhtsa['DisplacementL'])
112+
# EPA sometimes likes to go all precise
113+
if '.' not in self.nhtsa['DisplacementL']:
114+
attributes.append('%s.0 L' % self.nhtsa['DisplacementL'])
115+
if 'EngineCylinders' in self.nhtsa and self.nhtsa['EngineCylinders'] != '':
116+
attributes.append('%s cyl' % self.nhtsa['EngineCylinders'])
117+
118+
if 'Manual' in self.nhtsa['TransmissionStyle']:
119+
attributes.append('MAN')
120+
elif 'Auto' in self.nhtsa['TransmissionStyle']:
121+
attributes.append('AUTO')
122+
elif 'CVT' in self.nhtsa['TransmissionStyle']:
123+
attributes.append('CVT')
124+
attributes.append('Variable')
125+
126+
# Twin turbo is "Yes, Yes"!
127+
if 'Turbo' in self.nhtsa and 'Yes' in self.nhtsa['Turbo']:
128+
attributes.append('Turbo')
129+
130+
return attributes
131+
132+
def __get_possible_models(self):
133+
'''
134+
Return dict of possible models for given year of given make.
135+
The key and value are the same, and are the values needed by get_vehicle_ids().
136+
'''
137+
138+
key2model = dict()
139+
url = 'http://www.fueleconomy.gov/ws/rest/vehicle/menu/model?year=%s&make=%s' % (self.year, self.make)
140+
try:
141+
r = requests.get(url)
142+
except requests.Timeout:
143+
print "epa:__get_possible_models: connection timed out"
144+
return None
145+
except requests.ConnectionError:
146+
print "epa:__get_possible_models: connection failed"
147+
return None
148+
try:
149+
content = r.content
150+
# You can't make this stuff up. I love xml.
151+
for item in xmltodict.parse(content).popitem()[1].items()[0][1]:
152+
model = item.popitem()[1]
153+
key2model[model] = model
154+
except AttributeError:
155+
print "epa:__get_possible_models: no models for year %s, make %s" % (self.year, self.make)
156+
return None
157+
except ValueError:
158+
print "epa:__get_possible_models: could not parse result"
159+
return None
160+
return key2model
161+
162+
def __get_possible_ids(self):
163+
'''
164+
Return dictionary of id -> vehicle trim string from fueleconomy.gov, or None on error.
165+
The id's are those needed by get_vehicle_economy().
166+
'''
167+
168+
id2trim = dict()
169+
url = 'http://www.fueleconomy.gov/ws/rest/vehicle/menu/options?year=%s&make=%s&model=%s' % (self.year, self.make, self.model)
170+
try:
171+
r = requests.get(url)
172+
except requests.Timeout:
173+
print "epa:__get_possible_ids: connection timed out"
174+
return None
175+
except requests.ConnectionError:
176+
print "epa:__get_possible_ids: connection failed"
177+
return None
178+
try:
179+
content = r.content
180+
# You can't make this stuff up. I love xml.
181+
parsed = xmltodict.parse(content)
182+
innards = parsed.popitem()[1].items()[0][1]
183+
# special case for N=1
184+
if not isinstance(innards, list):
185+
innards = [ innards ]
186+
for item in innards:
187+
id = item.popitem()[1]
188+
trim = item.popitem()[1]
189+
id2trim[id] = trim
190+
except ValueError:
191+
print "epa:__get_possible_ids: could not parse result"
192+
return None
193+
return id2trim
194+
195+
def __fuzzy_match(self, mustmatch, attributes, choices):
196+
'''
197+
Given a base name and a bunch of attributes, find the choice that matches them the best.
198+
mustmatch : string
199+
attributes : string[]
200+
choices : dict mapping id to string
201+
Returns: array of ids of best matching choices
202+
'''
203+
204+
best_ids = [] # id of best matching trims
205+
best_len = 0 # len of best matching trims
206+
best_matched = 0
207+
for (key, val) in choices.iteritems():
208+
# optional mandatory attribute
209+
# to prevent [Q60 AWD] from matching Q85 AWD instead of Q60 AWD Coupe
210+
if mustmatch != None and mustmatch.upper() not in val.upper():
211+
continue
212+
# Find choice that matches most chars from attributes.
213+
# In case of a tie, prefer shortest choice.
214+
chars_matched = 0
215+
for attrib in attributes:
216+
if attrib != "" and attrib.upper() in val.upper():
217+
if chars_matched == 0:
218+
chars_matched = len(attrib)
219+
else:
220+
chars_matched += len(attrib) + 1 # for space
221+
#print "chars_matched %d, for %s" % (chars_matched, val)
222+
if (chars_matched > best_matched):
223+
best_ids = [key]
224+
best_len = len(val)
225+
best_matched = chars_matched
226+
elif (chars_matched > 0 and chars_matched == best_matched):
227+
if len(val) < best_len:
228+
#print "chars %d == %d, len %d < %d, breaking tie in favor of shorter trim" % (chars_matched, best_matched, len(val), best_len)
229+
best_ids = [key]
230+
best_len = len(val)
231+
best_matched = chars_matched
232+
elif len(val) == best_len:
233+
#print "chars %d == %d, len %d == %d, marking tie" % (chars_matched, best_matched, len(val), best_len)
234+
best_ids.append(key)
235+
if len(best_ids) == 0:
236+
print "epa:__fuzzy_match: no match found for vin %s" % self.vin
237+
elif len(best_ids) > 1:
238+
print "epa:__fuzzy_match: multiple matches for vin %s: " % self.vin + " / ".join(best_ids)
239+
return best_ids
240+
241+
def __get_model(self):
242+
'''
243+
Given a decoded vin and its nhtsa data, look up its epa model name
244+
'''
245+
# Get candidate modifier strings
246+
id2models = self.__get_possible_models()
247+
if id2models == None:
248+
return None
249+
#print "Finding model for vin %s" % self.vin
250+
# Special case for Mercedes-Benz, which puts the real model in Series
251+
oldmodel = self.nhtsa['Model']
252+
model = oldmodel.replace('-Class', '')
253+
ids = self.__fuzzy_match(model, self.__attribs, id2models)
254+
if len(ids) != 1:
255+
# Second chance for alternate spellings
256+
if '4WD' in self.__attribs:
257+
tribs = self.__attribs
258+
tribs.append('AWD')
259+
print "Searching again with AWD"
260+
ids = self.__fuzzy_match(self.nhtsa['Model'], tribs, id2models)
261+
elif '2WD' in self.__attribs and 'FWD' not in self.__attribs:
262+
tribs = self.__attribs
263+
tribs.append('RWD')
264+
print "Searching again with RWD"
265+
ids = self.__fuzzy_match(self.nhtsa['Model'], tribs, id2models)
266+
elif 'Mazda' in self.nhtsa['Model']:
267+
oldmodel = self.nhtsa['Model']
268+
model = oldmodel.replace('Mazda', '')
269+
tribs = self.__attribs
270+
tribs.append(model)
271+
print "Searching again with %s instead of %s" % (model, oldmodel)
272+
ids = self.__fuzzy_match(model, tribs, id2models)
273+
274+
if len(ids) != 1:
275+
print "epa:__get_model: Failed to find model for vin %s" % self.vin
276+
return None
277+
278+
modelname = ids[0] # key same as val
279+
#print "VIN %s has model %s" % (self.vin, modelname)
280+
return modelname
281+
282+
def __get_ids(self):
283+
'''
284+
Given a decoded vin, look up the matching epa id(s) and trims, or return None on failure
285+
'''
286+
if self.model == None:
287+
return None
288+
id2trim = self.__get_possible_ids()
289+
if id2trim == None:
290+
return None
291+
#print "Finding trims for vin %s" % self.vin
292+
ids = self.__fuzzy_match(None, self.__attribs, id2trim)
293+
if len(ids) == 0:
294+
print "epa:__get_id: No trims found for vin %s" % self.vin
295+
return None
296+
trims = map(lambda x: id2trim[x], ids)
297+
#print("VIN %s has trim names %s" % (self.vin, " / ".join(trims)))
298+
return [ids, trims]
299+
300+
def __get_vehicle_economy(self):
301+
'''
302+
Return dictionary of a particular vehicle's economy data from fueleconomy.gov, or None on error.
303+
id is from __get_vehicle_ids().
304+
'''
305+
306+
url = 'http://www.fueleconomy.gov/ws/rest/vehicle/%s' % self.id
307+
try:
308+
r = requests.get(url)
309+
except requests.Timeout:
310+
print "epa:__get_vehicle_economy: connection timed out"
311+
return None
312+
except requests.ConnectionError:
313+
print "epa:__get_vehicle_economy: connection failed"
314+
return None
315+
try:
316+
content = r.content
317+
return xmltodict.parse(content).popitem()[1]
318+
except ValueError:
319+
print "epa:__get_vehicle_economy: could not parse result"
320+
return None
321+
return None

0 commit comments

Comments
 (0)