Fox Tuning Related School Project

Well, guys. I've been holding off on the update for a couple reasons. Mainly, I wanted to make a couple more updates, and I wanted a tailored post to this audience, but now I'm square in the middle of the next quarter, and it's looking less and less like I'll be able to devote the time again to this for a while. So, I just wanted to follow up with the executive summary I wrote. This isn't going away. I'm going to continue to work on this project, as well as my tunes in both foxes as the opportunity presents itself. In the end, the project went very well and resulted in an A for the group, even though I did almost all of the work, lol. That's ok, my partners don't have the same enthusiasm for this that I do, and they've helped me significantly in other classes. Without further adieu:


PYTHON PERFORMANCE TUNING
COMPUTATIONAL METHODS
20 MARCH 2018

INTRODUCTION:
Tuning is perhaps the most challenging aspect in modifying a high-performance street car. Tuning is the process of altering controllable variables to achieve the correct mixture of air and fuel throughout the operating range of an engine. Dozens of uncontrolled variables complicate the tuning process to the point that even the vast majority of do-it-yourself hobbyists who take pride in building their own car must still take it to a professional for this step.

Normally, tuning is a trial-and-error process whereby a tuner starts by inputting a best guess at the controllable variables and parameters, conducts a trial during which the tuner revises inputs based on singular observations, and eventually stops based on their best judgment and impression of how the engine feels. This is the process even in high-end, world class performance shops where customers typically pay around $1,000 each time they require tuning services.

OBJECTIVE:
Our objective in this Computational Methods exercise was to automate the tuning process in Python. Our goal was to take output directly from the computer of a high-performance engine with an existing sub-optimal tune and adjust its tuning tables to bring actual fuel injected to within 5% of desired values under normal operating conditions.

For the purposes of this exercise we defined “normal operating conditions” based on our assessment of how vehicles are typically operated on the street. While individual driving patterns vary, we targeted an engine speed range between 700 and 3,000 revolutions per minute (RPM) and an engine load range of between 15 and 85 kPa. At any given RPM, engine load is the capacity of the engine to produce power, and it is directly correlated with the pressure in the engine’s manifold, which is measured in kilopascals. 100 kilopascals is equivalent to atmospheric pressure.

SUMMARY OF FINDINGS:
Figure 1 depicts the AFR Diff Pct output from data collected in our base and final runs. Green cells indicate an AFR Difference within 5%, yellow cells indicate an AFR Difference of 5-16%, red indicates a difference of 16+%, and black indicates cells where not enough data was collected to accurately determine the AFR difference.
Figure 1:

1523042579976.webp

In the data collected from the base tune, the percentage difference between the desired and the actual air-fuel mixtures varied widely. The automated tune corrections after two iterations produced a significant improvement. Whereas the base run produced only 4 cells that fell within our goal, the final iteration resulted in 21 green cells. 7 more remained within 7% of the desired air-fuel mixture, and only 2 cells remained outside of 15% of desired.

During our base run, our initial observations did not gather enough data on many of the cells, because our group had no way to determine how long the engine operated in each cell. Even though approximately 5.5 minutes of observations were collected in each sample, python output allowed us to better target cells after the base run. During both subsequent iterations, enough data was collected on almost every cell. However, the 15 kPa load range proved difficult to target, because it required 0 throttle input and a high rate of deceleration to generate the necessary conditions. Also, the engine idles at approximately 55 kPa, which made targeting 700-900 RPM at 30 kPa also challenging. This is still useful information, because it indicates that the lowest load range should be increased.

METHODOLOGY:
In each iteration, we exported the tuning table from the engine’s computer into a text file in XML format. We imported that into python using the xml.etree.ElementTree package. This provided the RPM, load ranges, and the values that controlled the amount of fuel to be injected in each load-RPM cell.

We collected roughly 5.5 minutes of logs while driving on roads in and around Monterey. The logs provide all available sensor readings 50 times per second for up to 30 seconds per log, which were output into csv files. Next, we imported and parsed the data into a Python Pandas data frame. After binning the data by RPM and load appropriate to the cells in the tuning table, we filtered out the cells that did not contain at least total 150 observations, an amount equivalent to spending at least 3 seconds in each cell. In Pandas, we calculated the AFR diff pct values for each cell, calculated the new recommended fuel table values, and exported them into a new tune file in XML format. Finally, we imported the new tune into the engine’s computer.

After the base tune data was collected, we ran two iterations to test our code and produced the results summarized in Figure 1.

CONCLUSION AND FUTURE WORK:
The automated tuning process has many significant advantages over the standard tuning method. Primarily, the standard method depends on single observations, whereas using the automated process the tuner can specify the amount of time required in each cell before adjustments are recommended. The automated process also helps in data collection, because with a quick run of our code, the tuner can see how many observations still need to be collected in each cell. Due to these advantages, the final tune resulted in the smoothest driving experience in this vehicle to date.

In the future, we will incorporate data filters that will provide a powerful tool to the tuner to understand the impact of the many uncontrollable variables that simultaneously affect the tune in previously unpredictable ways. Standard tuning using single observations make these uncontrollable variables infeasible to account for on an individual basis. Additionally, we will generate more analysis of the data using more sophisticated statistical techniques. Finally, we are currently working with the engine computer manufacturer and hope to gain access to the computer’s API to work towards automated in real time while driving.
 
  • Like
Reactions: Davedacarpainter
For those who are interested, I used python, and specifically jupyter notebooks in the creation of the code. I admit, my coding is sloppy, but I was in "get it done" mode. I will eventually clean it up. Still, if you're a coder and you want to see under the hood, here ya go. I couldn't attach the files directly. I could probably build a text file, but I'll just copy/paste here for now. Any recommendations are welcome.


import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import os
import os.path
import glob
import shutil
from sortedcontainers import SortedDict
import csv
import xml.etree.ElementTree as ET
import sys
import math


#iteration updates
obs_count_input = 150

input_VE_table = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - \
Tuning group\\Tunes\\Iteration2_update.Table'

infile = "C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - \
Tuning group\\Tunes\\Iteration2_update.Table"

outfile = "C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - \
Tuning group\\Tunes\\Iteration3_update.Table"

logs_to_process = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group\\Logs\\Iteration2Results'


#OS navigation variables for Chris' Surface Pro:
dir_lab6 = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group'
dir_initial_logs = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group\\Logs\\InitialLogs'
dir_logs = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group\\Logs'
dir_iteration1_logs = 'Iteration1Results'
dir_tunes = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group\\Tunes'
dir_coding_and_output = 'C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - Tuning group\\JupyterNotebooks'

formatfile = "C:\\Users\\Chris\\Google Drive\\NPS\\OA3801_Comp_Methods\\Labs\\Lab6 - \
Tuning group\\Tunes\\Fuel_Table1_2018-03-09_19.09.51.table"

os.getcwd()

os.chdir(dir_logs)
os.listdir()


def print_full(x):
pd.set_option('display.max_rows', len(x))
pd.set_option('display.max_columns', None)
print(x)
pd.reset_option('display.max_rows')
pd.reset_option('display.max_columns')


def truncate(f, n):
'''Truncates/pads a float f to n decimal places without rounding'''
s = '{}'.format(f)
if 'e' in s or 'E' in s:
return '{0:.{1}f}'.format(f, n)
i, p, d = s.partition('.')
result = '.'.join([i, (d+'0'*n)[:n]])
return float(result)


def df_builder(log_directory):
os.chdir(log_directory)

csv_list = glob.glob('./*.csv*')
column_list = ['TPS', 'Coolant', 'Vbat', 'RPM', 'Act AFR', 'Des AFR', 'MAP', 'Vol Eff', 'Air Temp', 'Inj %Duty Cycle']
all_logs_list = []

for csvfiledir in csv_list:
df=pd.read_csv(csvfiledir,skiprows=[0,1,2,4], header=[0], encoding= 'ISO-8859-1')
idx = df.index[df["Log #"]=="Raw Log Data..."]
df_filtered = df.iloc[:idx[0],:].reindex(columns=column_list)
all_logs_list.append(df_filtered)

all_logs_df = pd.concat(all_logs_list, axis=0)
all_logs_df = all_logs_df.apply(pd.to_numeric, errors='raise') # errors can be 'coerce' or 'ignore' as well

ttl_time = len(all_logs_df)*.02/60 # total amount of tuning time.
line_per_log = len(all_logs_df)/len(csv_list) # This is average number of lines per log. expecting around 800.
print('Total Time in all logs: %.1f minutes' %ttl_time)
print('Average obs per log: %.1f observations/log' %line_per_log)
return all_logs_df


def import_VE_Table(filename):
#parse .table (xml) file into python
initial_tune_data = ET.parse(filename)

#set the root tag
root = initial_tune_data.getroot()

#blank list
VE_table_list = []

#fill list with data for the tables
for child in root[2]:
child_list = [float(x) for x in child.text.split()]
VE_table_list.append(child_list)

# use numpy to build a 16x16 table from the list containing zValues
VE_values = np.reshape(VE_table_list[2], (16,16))

#build the DataFrame
VE_table = pd.DataFrame(VE_values, columns=VE_table_list[0], index=[VE_table_list[1]]).sort_index(ascending=False)

#Build and return a dictionary
VE = {}
VE['table'] = VE_table.apply(pd.to_numeric, errors='raise')#.sort_index(ascending=False, axis=0)
VE['RPM_axis'] = VE_table_list[0]
VE['load_axis'] = VE_table_list[1]

print('\nVE table converted.')

return VE


def bin_maker_VE(VE_table_dict):
list_VE_RPM = VE_table_dict['RPM_axis']
list_VE_load = VE_table_dict['load_axis']

bin_RPM_dict = {}
i = 0
while i in range(len(list_VE_RPM)):
#variable_name = str('bin_RPM%i' %list_VE_RPM)
if i == 0:
bin_RPM_dict[list_VE_RPM] = [0,(list_VE_RPM+list_VE_RPM[i+1])/2]
elif i == len(list_VE_RPM)-1:
bin_RPM_dict[list_VE_RPM] = [(list_VE_RPM[i-1]+list_VE_RPM)/2, list_VE_RPM*2]
else:
bin_RPM_dict[list_VE_RPM] = [(list_VE_RPM[i-1]+list_VE_RPM)/2, (list_VE_RPM+list_VE_RPM[i+1])/2]
i += 1
#list_VE_RPM

bin_load_dict = {}
i = 0
while i in range(len(list_VE_load)):
if i == 0:
bin_load_dict[list_VE_load] = [0,(list_VE_load+list_VE_load[i+1])/2]
elif i == len(list_VE_load)-1:
bin_load_dict[list_VE_load] = [(list_VE_load[i-1]+list_VE_load)/2, list_VE_load*2]
else:
bin_load_dict[list_VE_load] = [(list_VE_load[i-1]+list_VE_load)/2, (list_VE_load+list_VE_load[i+1])/2]
i += 1

#print(bin_RPM_dict)
#print(bin_load_dict)
bins_dict = {}
bins_dict['RPM_df'] = pd.DataFrame.from_dict(bin_RPM_dict)
bins_dict['load_df'] = pd.DataFrame.from_dict(bin_load_dict)
bins_dict['RPM_dict'] = bin_RPM_dict
bins_dict['load_dict'] = bin_load_dict
return bins_dict


def tune_analysis(VE_table_dict, bins_VE_dict, obs_count = 50, AFR_correction_pct = 0.05, verbose=0):
print('Given a requirement of %d observations, and a minimum correction factor of %.2f:\n\n' %(obs_count,AFR_correction_pct))

#variables to define:
bin_RPM_dict = bins_VE_dict['RPM_dict']
bin_load_dict = bins_VE_dict['load_dict']

cells_analyzed = 0
adjustments_needed = 0
AFR_diff_df = pd.DataFrame(index=(sorted(bin_load_dict, reverse=True)), columns=sorted(bin_RPM_dict))
obs_count_df = pd.DataFrame(index=(sorted(bin_load_dict, reverse=True)), columns=sorted(bin_RPM_dict))
rec_changes_df = pd.DataFrame(index=(sorted(bin_load_dict, reverse=True)), columns=sorted(bin_RPM_dict))
full_table_with_changes_df = VE_table_dict['table'].copy()

tune_analysis_dict = {}

for key_RPM in sorted(bin_RPM_dict):

for key_load in sorted(bin_load_dict, reverse=False):

# bin by RPM
bool_RPM = ((all_logs_df['RPM'] >= bin_RPM_dict[key_RPM][0]) & (all_logs_df['RPM'] <= bin_RPM_dict[key_RPM][1]))

# bin by MAP
bool_MAP = ((all_logs_df['MAP'] >= bin_load_dict[key_load][0]) & (all_logs_df['MAP'] <= bin_load_dict[key_load][1]))

Act_AFR_mean = all_logs_df[bool_RPM & bool_MAP]['Act AFR'].mean()
Des_AFR_mean = all_logs_df[bool_RPM & bool_MAP]['Des AFR'].mean()
AFR_diff_pct = (Act_AFR_mean - Des_AFR_mean)/Act_AFR_mean
VE_mean = all_logs_df[bool_RPM & bool_MAP]['Vol Eff'].mean()
VE_cell_command = VE_table_dict['table'][key_RPM][key_load][0]

if math.isnan(AFR_diff_pct) == False:
VE_recommended = truncate(round(((VE_cell_command * (1 + AFR_diff_pct))*800),0)/800,5) #round to .00125 increment
else:
VE_recommended = VE_cell_command * (1 + AFR_diff_pct) #because it broke on nan values

observation_count = all_logs_df[bool_RPM & bool_MAP]['Des AFR'].count()

cells_analyzed += 1

if (observation_count > obs_count) & (abs(AFR_diff_pct) > AFR_correction_pct):
adjustments_needed += 1
rec_changes_df[key_RPM][key_load] = VE_recommended
full_table_with_changes_df[key_RPM][key_load] = VE_recommended

if verbose == 1:
print('For the bin including %s RPM and %s load:' %(key_RPM, key_load))
print('Observation count: %d' %observation_count)
print('Act AFR mean: %f' % Act_AFR_mean)
print('Des AFR mean: %f' % Des_AFR_mean)
print('pct difference: %f' % AFR_diff_pct)
print('VE mean: %f' %VE_mean)
print('Commanded VE: %f' %VE_cell_command)
print('Recommended VE: %.5f \n' %VE_recommended)

#Build tables for counts and AFR diff pecents
AFR_diff_df[key_RPM][key_load] = AFR_diff_pct
obs_count_df[key_RPM][key_load] = observation_count

if verbose == 1:
print('AFR_diff_df:')
print(AFR_diff_df)

print('obs_count_df:')
print(obs_count_df)

print('rec_changes_df:')
print(rec_changes_df)

print('full_table_with_changes_df:')
print(full_table_with_changes_df)

print(sorted(bin_load_dict, reverse=False))
print(sorted(bin_RPM_dict))
print('cells analyzed: %d' %cells_analyzed)
print('adjustments needed: %d' %adjustments_needed)

tune_analysis_dict['obs_count_df'] = obs_count_df
tune_analysis_dict['AFR_diff_df'] = AFR_diff_df
tune_analysis_dict['rec_changes_df'] = rec_changes_df
tune_analysis_dict['full_table_with_changes_df'] = full_table_with_changes_df

return tune_analysis_dict


# Convert Logs:
all_logs_df = df_builder(logs_to_process)

#Convert VE Table:
os.chdir(dir_tunes)
VE_table_dict = import_VE_Table(input_VE_table)


#print up the bins, and the VE Table to have a look:

bins_VE_dict = bin_maker_VE(VE_table_dict)

print('\nVE Table info:')
print('RPM Axis:')
print(VE_table_dict['RPM_axis'])
print('\nload Axis:')
print(VE_table_dict['load_axis'])

print('\n\nRPM bins:')
print(bins_VE_dict['RPM_df'])
print('\nload bins:')
print(bins_VE_dict['load_df'].head())


print('\nVE table:')
VE_table_dict['table']


#Ok, let's see the calculations:

tune_analysis(VE_table_dict, bins_VE_dict, verbose=1, obs_count=obs_count_input)


tune_analysis_dict = tune_analysis(VE_table_dict, bins_VE_dict, obs_count=obs_count_input).copy()
tune_analysis_dict['AFR_diff_df']

tune_analysis_dict['obs_count_df']

tune_analysis_dict['full_table_with_changes_df']


new_cols = []
for item in VE_table_dict['RPM_axis']:
new_cols.append(str(int(item))+'_c')
print(new_cols)
comparison_changes_df = tune_analysis_dict['rec_changes_df'].copy().set_axis(new_cols, axis=1, inplace=False)
comparison_changes_df


#creates and easier comparison of the changes in one table

old_cols = VE_table_dict['table'].columns
new_cols = comparison_changes_df.columns
combine_cols = []
for i in range(len(old_cols)):
combine_cols.append(old_cols)
for j in range(len(new_cols)):
if i!=j:
continue
if i == j:
combine_cols.append(new_cols[j])
combine_cols

#comparison_changes_df['1500_c']
#VE_table_dict['table'][700]
merge_table1 = comparison_changes_df.copy()
merge_table2 = VE_table_dict['table'].copy()


merge_table1['load'] = VE_table_dict['load_axis']
merge_table2['load'] = VE_table_dict['load_axis']

#merge_table2

merge_df = pd.merge(merge_table1,merge_table2, how='left', on='load')

merge_df

merge_df.reindex(columns=combine_cols)


# Now I want to export the full table with changes back into the proper xml format.



#perhaps I should start by simply importing the template:
export_VEtable_format = []
f = open(formatfile, "r")

for line in f:
export_VEtable_format.append(line)

f.close()
#Now I've got a list of lists. Each inner list contains the text for each line imported.

# So, I guess, let's see if I can export these two a new text file:
def export_table_function(outfile, export_list):
g = open(outfile,'w')
for line in export_list:
print(line, end='', file=g)
g.close()



# Now I can read and write back to text. It's time to modify the appropriate lines:
# Produce a list of lists consisting of the values in the full_table_with_changes_df, but I need to be sure values are
# only 5 decimal places:

export_values = []
for load in sorted(VE_table_dict['load_axis'],reverse=False):
inner_list = []
for RPM in sorted(VE_table_dict['RPM_axis'],reverse=False):
cell_val = truncate(tune_analysis_dict['full_table_with_changes_df'][RPM][load][0],5) #grab value and truncate to 5 decimals
inner_list.append(cell_val)
export_values.append(inner_list)


# Ok, I've got my list with the values. Now I need a list of strings.
export_string_values = []
for list in export_values:
str1 = str(' ' + (' '.join(str(e) for e in list) + ' \n'))
export_string_values.append(str1)
export_string_values

# incorportate the list of string values into the list I actually need to export
# The rows I need to update include rows indexed from 42 to 58. So lets just replace the original format in a new list:
export_actual_export_list = export_VEtable_format[:]

k = 0
m = 42
while k < len(export_string_values):
export_actual_export_list[m] = export_string_values[k]
k+=1
m+=1

# *crosses fingers* let's have a look:
#print(export_actual_export_list[42])
#print(export_VEtable_format[42])
#export_actual_export_list

# now export it to the actual file we want to use:
export_table_function(outfile,export_actual_export_list)