6 Replies Latest reply on Sep 22, 2018 2:02 PM by jack.chaney56

    More Engine Management

    jack.chaney56

      In the previous discussion, I presented a base system for engine management. The problem with what was created is, it only runs in steady state. Steady state for an engine is constant rpm and load, no acceleration, and no change to demand. The software components of the system have been intentionally designed to be independent of hardware processor. The specific platform is the Arduino Nano and the 328p processor. The choice was as an exercise to see if it could be done, and to force a level of discipline for the programming model. The other item of note from the previous discussion is, I am a software person primarily, with a working knowledge of electronic hardware. I am happy to admit to shortcomings in any circuits that I present, and encourage correction.

       

      This second section will deal with making improvements to the basic software to manage the asynchronous events. Some items will make reference to the previous discussion, so please review for a full description. If something was left out, I am happy to fill in the detail. Jumping in to the first item which is a one time event. The engine start. When the engine is cranking, before ignition timing is captured, fuel is being input, and not burned. To correct the state, once the engine starts, the acceleration is raised temporarily to "burn off" the excess fuel. This is done by adjusting the advance, first increasing, then reducing back to steady state.  Also, during crank (before ignition capture), the advance is reduced to almost zero. Because the advance goes from near zero to a large angle, a measured transition is needed. This is called the ramp in rate. Also, the recovery from the large angle to the steady state angle, is again a measured value. This is called the decay. The startup sequence then becomes a small state operation. Crank > Ramp to max > Decay to steady > Steady State. In each of the states a value is combined as part of the total advance.

       

      The ramp in and decay are governed by the RPM of the motor, so a counter tied to the cam signal is implemented. The four states are monitored with a simple switch operation. The values for ramp in and decay are defined in calibration, as well as the values for max and minimum advance. The other part is to implement a counter of some sort that is updated during the cam signal detect (same place as setting cam detected flag). The sequence works sort of like this:

      • Engine off - Retrieve from calibration values for min, max, ramp, and decay.
      • Crank to start - Set advance to minimum until ignition is detected, also set counter to ramp in value when ignition is detected change state.
      • Ramp in - Adjust advance from minimum to maximum with a slope = counter / ramp until max is reached then set counter to decay and change state
      • Decay out - Adjust advance from max to steady state with a slope = counter / decay until steady state is reached then change state
      • Steady State - watch for engine stop

       

      SWord updateStartupTrim(SWord t) {
       static SWord ramp;
       static SWord min = 0;
       static SWord max = 0;
       static UByte stState = 0;
       switch(stState) {
       case 0:  // engine off, crank to start
        min = getIgnStartupAdjustment() - t; ramp = getIgnStartupRampIn(); if (ramp == 0) { ramp = 1; }
        ignStTrim = getRpm() > 0 ? min : 0;
        if (getRpm() > getIgnRpmMinRun()) { camTrimCt = ramp; stState = 1; }
        break;
       case 1:  // ramp in
        max = getIgnStartupTrim();
        if (camTrimCt > 0) { ignStTrim = max - (SWord)((SLong)camTrimCt * (SLong)(max - min) / (SLong)ramp); }
        else { ignStTrim = max; ramp = getIgnStartupDecay(); if (ramp == 0) { ramp = 1; } camTrimCt = ramp; stState = 2; }
        break;
       case 2:  // decay
        if (camTrimCt > 0) { ignStTrim = (SWord)((SLong)max * (SLong)camTrimCt / (SLong)ramp); }
        else { ignStTrim = 0; stState = 3; }
        break;
       default: // running
        ignStTrim = 0;
        if (isEngStopped()) { stState = 0; }
        break;
       }
       return ignStTrim;
      }
      

       

      The variable camTrimCt is the counter which gets decremented on each cam.

       

      Good news is, there are lots of exception and asynchronous activities to manage, so there will be lots offered here

       

      Jack

        • Re: More Engine Management
          jack.chaney56

          Should add the link to the previous discussion as well. Engine Management

          • Re: More Engine Management
            jack.chaney56

            Here is where I get some people upset. I generally have great respect for electrical engineers, and their capabilities to develop really good hardware solutions. I am by training a software development specialist. I have also had a good amount of electrical experience with circuit design.  My experience has shown me, my electrical ability is equivalent to an electrical engineer's programming ability. I am able to hash and bash my way to a hardware solution, but it lacks a level of elegance obtained by expertise.  Likewise, software from most of the electrical engineers that I have experienced, has shown many areas were elegance is a little lacking.

             

            Likewise, there have been a good number of embedded programmers I have worked with, who disprove my observation. These people have learned the lesson, "if the only tool you have is a hammer, all your problems look like nails". Electrical engineers, learn how to make state machines, and they learn how to convert sensor values from a voltage reading. Most cases, they also hold to the idea that floating point numbers solve many of the problems of accuracy.

             

            A course that was taught long ago in the early days of computers, was compiler design. I am not quite sure when it was determined that this discipline was no longer necessary, but I contend it needs to be brought back. The reason is, it provided a good means of showing what instructions did, at a low enough level, to indicate where bottlenecks would occur, and where accuracy is lost. This is where I pull out my soap box and expound the virtues of pure integer math. My premise is, for all except a very few extreme cases, floating point is unnecessary, and for easily greater than half the cases (especially for control systems) more than 16 bits is a luxury.

             

            I like to use the example of a golf course.  On a par 5 hole, the distance from the tee box, to the hole is generally around 500 yards. 500 yards is 1500 feet, which is 18000 inches. Using a 16 bit unsigned number to measure distance, the measurement is a little more than 1/3 of an inch. So it is possible to map a 500 yard field down to 1/3 of an inch using only 16 bit integers. Where elegance is incorporated... remember to always look at the problem from multiple viewpoints.

             

            With the golf problem, the distance to the hole, is actually irrelevant. The important factor is how far can the golfer actually hit the ball. If you were writing a video game to simulate golfing, The behavior is actually to hit the ball, then walk (or ride) to where the ball landed. Simulation being good hits and bad hits. Spin on the ball, loft of the ball (based on club selection), obstacles (hazards). Not to mention other players. The point being, proper mapping of the scope of the problem to be solved, should be the first part of the exercise. However, it is often overlooked, and thus, the problem becomes bigger than it needs to be.

             

            When I set out to develop my engine controller, My goal was to see just how little I could get away with. I proposed to a bunch of my engine experts, that I would be able to develop a suitable controller to manage ignition and fueling, for a 4, 6, or 8 cylinder motor, with TBI or direct fuel injection, and single, dual, or coil on plug configuration. I also proposed to use a simple 8 bit processor with times, EEPROM, and A/D built in (an Arduino Nano).  My friends were skeptical, and some even said it wasn't possible, they were mostly electrical and mechanical engineers.

             

            Step 1: keep it small.  Source code needed to fit on the device, so extraneous operations needed to be eliminated. Because I am a decent programmer, I determined the "Arduino" interface was to be sacrificed. Using a programmer, I would only have my code installed on the Nano. This meant, communication of information would need to be provided by my code, so a simple interface to the serial port would be the first order of business.

             

            Step 2: develop an interface protocol. Many protocols are already available, and can be implemented (including the Arduino interface), but my goal was to keep things small. All I needed was to be able to update calibration (EEPROM), and fetch monitor values. So the first architecture decision became allocation of EEPROM. A putter and getter operation for EEPROM needed to be provided. Luckily, the documentation for the Mega328 has the code, so I didn't need to write the base. I did however, want to access the memory as 16 bit integer instead of bytes, so a little modification was required. The other element was monitor access. Monitors could use the same getter method, with the difference being where the data was obtained.  So the interface protocol, only needed to be a get function with an enumerated value, and a put function with an enumerated value, and the value to save.

             

            Step 3: establish units. I said 16 bit number would be satisfactory for nearly all applications. In the previous discussion, I presented the use of binary radians where a circle is divided into 32768 degrees, The wisdom of this will be shown as I progress. For ratios, I will use a binary fixed point value where 1024 = 100%. This was selected partially for ease of conversion, but primarily because the A/Ds on the 328 are 10 bits each, so 10 bits = 1024 = 100%.  For temperature, most of the calculations are based on degrees K. With no negative values possible (can't go below 0 K) using a 16 bit unsigned value to hold temperature works out well as K * 100, so the value 0C is stored as 27315. Likewise pressure is primarily measured as kilo-Pascals absolute, and saved as kPa * 100. So 1 atmosphere is stored as 10129. It is also possible to hold values for voltage as V * 1000, but I will show that it is actually unnecessary to maintain a value of voltage at all.

             

            Step 4: provide conversions. The calibration values are all stored in tables. The size of the table is balanced by available space in EEPROM, and desired accuracy. A bigger table has more values, so provides improved accuracy. Here is where I emphasize voltage is unnecessary. When an A/D value is converted, it is converted into "counts". I can't begin to tell how many examples I have found, where the first step after reading the A/D counts is to convert the number into volts. If the manufacturer of the sensor provides a table with the value of volts to measurement, what is the problem with taking that table and converting the voltage value into counts, so the table becomes counts to measurement. Now, with this table, it is possible to read the A/D counts and look up in the table the value of the measurement, reducing two conversions, to one. Table size is now the question, because storing 1024 integers in a table for one sensor might prove a problem. The solution is to use linear interpolation and a finite number of integers. By using 8 nodes, dividing the 10 bit A/D by 128 yields the node on one end. using the left and right values and the 7 bit remainder times the difference, a very close approximation of the value can be obtained.

             

            As an example, I have created a sin and cos function where the value is multiplied by 32768. The table is 33 cells in size (0 - 32) and represents 1/4 wave. the circle uses 32768 degrees for the circumference. For n from 0 -8192 the value is read straight from the table. For n from 8192-16384 the table is read from back to front. For n from 16384-24576 the table is read backward and multiplied by -1. And for the last segment, from 24576-32768 the value is multiplied by -1.  For each of the quadrants, the two high order bits can be used with bit 13 determining which direction the table is accessed, and bit 14 determining positive or negative.  Here's the code.  (As a bonus, by adding 90 degrees or 8192 to the value you get the cos.)

             

            static const UWord sinTbl[] = {
                  0,  1608,  3212,  4808,  6393,  7962,  9512, 11039
             ,12540, 14010, 15447, 16846, 18205, 19520, 20788, 22006
             ,23170, 24279, 25330, 26320, 27246, 28106, 28899, 29622
             ,30274, 30853, 31357, 31786, 32138, 32413, 32610, 32729, 32767
            };
            SWord getSin(UWord d) {
             UWord n[2];
             UWord a = (((d & 8192) == 0) ? d : ~d) & 8191;
             UWord i = a >> 8;
             n[0] = sinTbl[i]; n[1] = sinTbl[i + 1];
             i = (UWord)((((ULong)n[1] - (ULong)n[0]) * (ULong)(a & 255)) >> 5) + n[0];
             return (((d & 16384) == 0) ? i : -i);
            }
            SWord getCos(UWord d) {
             return (getSin(d + 8192));
            }

             

            Pretty nifty eh?

             

            More stuff to come tomorrow,

            Jack

            • Re: More Engine Management
              jack.chaney56

              Less is more.

               

              Wow, talking about linear interpolation between points on a curve, takes me back to my Calculus days. ...and at the time, I thought it would never have a practical application. Using a table of values and performing a linear interpolation between table elements is a very good and fast way to perform sensor conversions.  For engine management, it is often necessary to use two variables and create a two dimensional table.  Most often, the variables are pressure and rpm, but as before, do not perform conversions if they are not necessary. In the previous discussion, I presented the function for doing planar interpolation.  If the interpolation is done, using manifold pressure and rpm, a good value can be obtained for base advance. Because the table is in EEPROM space, it is possible through experimentation, or trial and error, to obtain calibration values that provide optimal performance.

               

              From the calculus days, it is noted, the more points along the curve, the better the approximation. The same is also true for calibration tables. a 16x16 table offers better control than a 4x4 table. The problem is, in a small system, memory is a limited resource, so size of the table needs to be regulated.  Again, speed is the key when working out the algorithm for table interpolation. The other feature of the code is to make it, so the same function would work for any type of table, and any size of table. To do this, a secondary array is created containing the table type (0=discrete, 1=linear, 2=planar) where discrete is accessed by individual cell, linear is a 1 dimensioned linear interpolation, and planar is a two dimensioned array. To reduce complexity, all tables will be maintained as two dimensioned arrays. and the retrieval of data is based on type. Type 0 returns the value of the cell at array coordinates (x,y). Type 1 returns the linear interpolated value of a row (y), and Type 2 returns the planar interpolated value.

               

              The other effort was to provide portability and scalability to the code, so a level of decoupling is implemented. The fetching of the cell is done as a function call. The function can create any amount of detachment including one table existing in internal memory, while another references a value from a remote data store. This would be an extreme case, but the goal is to be fully scalable. The implementation here only uses EEPROM for calibration storage, all the variables are 16 bit integer, and the interface record only has table type, width, height, and eeprom offset.  In an ideal world, using real objects, the elements would more likely be; table type, width, height, table label, and data type. It is not too difficult for a skilled programmer to provide these extensions an a more complex system.

               

              The implementation for my solution is:

               

              /* Tables structure */
              struct tblTbl {
                  UByte type;         /* 0 = discrete, 1 = linear, 2 planar, 3 = index */
                  UByte xLen;         /* x size - width of table */
                  UByte yLen;         /* y size - height of table (linear is numbered row) */
                  UWord offs;         /* memory location offset of the table */
              };
              /* n is the enumerated table to access */
              /* x and y are the cell coordinates of the virtual array */
              SWord getTableCel(UWord n, UByte x, UByte y) {
                  SWord rVal = 0;
                  if ((n < MAX_TBL) && (x < calTbl[n].xLen) && (y < calTbl[n].yLen)) {
                      rVal = getCal(calTbl[n].offs + (y * calTbl[n].xLen) + x);
                  }
                  return rVal;
              }
              /* n is the enumerated table to access */
              /* x and y are the cell coordinates of the virtual array */
              /* d is the data item to insert into the table */
              void putTableCel(UWord n, UByte x, UByte y, SWord d) {
                  if ((n < MAX_TBL) && (x < calTbl[n].xLen) && (y < calTbl[n].yLen)) {
                      putCal(calTbl[n].offs + (y * calTbl[n].xLen) + x, d);
                  }
              }
              /**************************************************************************************************
               Tables are all NxN configuration of 16 bit values, and are accessed by 4 different methods.
               The first is discrete that just returns the value at the cell (x,y).
               The second is a linear interpolation with an x-index of 0-1023, and divided into n equal
                 sections. The y index is for multiple instances of the row. An extra phantom column is
                 added to the end for a right boundary.
               The third is a planar interpolation with indexes of 0-1023, and divided into NxM equal
                 sections. An extra phantom row & column are added to the outer for a boundary.
               The last type is an inverse of the second type performing a best fit from a provided value
                 and returning index of 0-1023, 0 if the provided value is less than the lowest, and 1023
                 if greater than the highest.
              **************************************************************************************************/
              SWord getTableVal(UWord n, SWord x, SWord y) {
                  SWord z[4], rVal = 0;
                  SWord dX, dY, xI, yI;
                  ULong off;
                  SLong base, K[4];
                  if (n < MAX_TBL) { off = calTbl[n].offs;
                      switch (calTbl[n].type) {
                      case 1: /* Linear */
                          y = y < calTbl[n].yLen ? y : (calTbl[n].yLen - 1);
                          dX = ONE / calTbl[n].xLen; xI = x % dX; x /= dX; if (x >= calTbl[n].xLen) { xI = dX; x = calTbl[n].xLen - 1; }
                          z[0] = getTableCel(n, x, y);
                          z[1] = (x + 1) < calTbl[n].xLen ? getTableCel(n, x+1, y) : (z[0] + z[0] - getTableCel(n, x-1, y));
                          base = ((SLong)z[1] - (SLong)z[0]) * (SLong)xI / (SLong)dX;
                          rVal = z[0] + (SWord)base;
                          break;
                      case 2: /* Planar */
                          dY = ONE / calTbl[n].yLen; yI = y % dY; y /= dY; if (y >= calTbl[n].yLen) { yI = dY; y = calTbl[n].yLen - 1; }
                          dX = ONE / calTbl[n].xLen; xI = x % dX; x /= dX; if (x >= calTbl[n].xLen) { xI = dX; x = calTbl[n].xLen - 1; }
                          z[0] = getTableCel(n, x, y);
                          z[1] = (x + 1) < calTbl[n].xLen ? getTableCel(n, x+1, y) : (z[0] + z[0] - getTableCel(n, x-1, y));
                          z[2] = (y + 1) < calTbl[n].yLen ? getTableCel(n, x, y+1) : (z[0] + z[0] - getTableCel(n, x, y-1));
                          z[3] = ((y + 1) < calTbl[n].yLen) && ((x + 1) < calTbl[n].xLen) ? getTableCel(n, x+1, y+1) : (z[2] + z[0] - z[1]);
                          /**************************************************************************************
                          * The equation for a planar surface interpolation for NxM points is
                          * Z0(x2-X)(y2-Y) + Z1(X-x1)(y2-Y) + Z2(x2-X)(Y-y1) + Z3(X-x1)(Y-y1)
                          * -----------------------------------------------------------------
                          *                          (x2-x1)(y2-y1)
                          * X and Y are offsets. Z are the value of the corners
                          **************************************************************************************/
                          K[0] = (SLong)(dX - xI) * (SLong)(dY - yI) * (SLong)z[0];
                          K[1] = (SLong)xI        * (SLong)(dY - yI) * (SLong)z[1];
                          K[2] = (SLong)(dX - xI) * (SLong)yI        * (SLong)z[2];
                          K[3] = (SLong)xI        * (SLong)yI        * (SLong)z[3];
                          base = (SLong)dX * (SLong)dY;
                          rVal = (SWord)((K[0] + K[1] + K[2] + K[3]) / base);
                          break;
                      default: /* Discrete */
                          rVal = getTableCel(n, x, y);
                          break;
                      }
                  }
                  return rVal;
              }
              

               

              by observation, note, the scalar is based on 1024, which is why I use it as a reference ratio.

               

              A last conversion is necessary, because RPM is linear and not limited from 0 to 1023, a ratio value is calculated using a minimum and maximum RPM. The values of min and max rpm can be saved as discrete calibration values, and calculated at runtime.

               

              Fun part of this, so far the source code provided (with the exception of the cam and crank interrupt stuff from the previous), is fully adaptable to nearly any application. Engine management is just one application.

               

              Jack

              • Re: More Engine Management
                jack.chaney56

                Time for a review...

                 

                Control processes for A/D, EEPROM, Serial I/O, Timers, and input triggers.

                Table processing for linear and planar interpolation.

                The 16bit solution

                Basic scheduler for timed events.

                Synchronization of crank and cam signals.

                Steady state fuel calculation.

                Basic principles for ignition timing.

                 

                Communication of values is a good discussion. My observation is, if you count up the number of engineers in a room (n) and pose a problem, you will get at least n+1 solutions. There are a significant number of communication protocols to choose from, and by protocol, I mean what gets sent, not the medium or transfer mechanism. Serial communication being the available data transfer mechanism, and using something simple to start (19200,n,8,1). In case it is not known, that is 19200 bits per second, no parity, 8 data bits, and 1 stop bit. Not going to go into any kind of detail about how serial shift registers work, or any more about the communication hardware than I already presented. The input and output sections are buffered with a put/get interface and background interrupt processing. This leaves only the messages being transferred.

                 

                I have done many different forms of messaging protocols, with varied levels of success. Each implementation has had strengths and weaknesses. I had thought of implementing a full 488.2 SCPI protocol, but decided it was a bit excessive for what I wanted. I had even created a small operation version that I used for testing at an earlier implementation. Looking at the needs of the system, there aren't many messages that need to be sent.

                1. Read calibration data from EEPROM

                2. Write calibration data to EEPROM

                3. Configure monitor list

                4. Read list of monitor values

                 

                The example I like to use for messaging protocols, so I can remember to include everything is the letter (snail mail). Examining the letter, there is a stamp, which represents size and priority. Next there is an address of the receiver, and a return address of the sender. The envelope provides the container for the data, and the letter is the content BLOB (binary logical object block). Going super cheap, and limiting development, the messages should be maintained as human readable strings, or should have a human readable instruction set. This way a standard serial communication terminal program can provide the general interface.  I'll fill in more tomorrow, but for now simple, simple, simple.  No size component, and no priority. Messages will end with either a newline or a semi-colon. Read messages are upper case, write messages are lower case. Messages for calibration start with C or c, and messages for monitor start with M or m. Numeric values only, no labels, and all numeric values are separated by comma.

                 

                This should suffice for the short term, and a more robust protocol can be developed after an interface is constructed. I will provide the code in the course of the next installment. I will also leave it as an exercise to try at home.

                 

                Jack

                • Re: More Engine Management
                  jack.chaney56

                  Darn old work keeps getting in the way of this important stuff. Sorry for any delays for the information flow.

                   

                  The table operation is kind of a key element for the engine management stuff. By using a planar table where the axis are taken from RPM and MAP, the cells can be set as a value for angle of advance.  The value of MAP can actually be a direct read from the A/D because it is a 10 bit device and the range is set from 0 to 1023, and by doing a ratio conversion for RPM it too will be a value from 0 to 1023 for min/max RPM.  Elements of temperature and barometric pressure can be added a little later.

                   

                  I think all the parts are there now. Table size is limited to the amount of EEPROM on the device, so I used an 8x8 table for the base advance.  I used 8x1 tables for each of the sensors (MAP, Throttle position, Engine Coolant Temp, Intake Air Temp, Battery Voltage, Fuel Pressure, and O2). The rest are either simple 1x1 cell values, or bit values for on and off states.

                   

                  The value of dwell is a time that it takes the coil  to charge, so at run time the value is converted to an angle using the magic number BASE, where:

                   

                  BASE = crkDiff * teeth / PART1

                   

                  and the conversion from time to angle is:

                   

                  dwlAngle = baseDwell * PART2 / BASE

                  • Re: More Engine Management
                    jack.chaney56

                    Keep getting interrupted... The piece of information that I wanted to give is about event timing. Several events in an engine system are asynchronous, which means they can happen pretty much any time. The second type of event is the dynamic events that are based on engine speed. These events need to be synchronized to the rotation of the motor. The last type of event are real time, these are tied to timer events providing real time reference. The system here has a 1mS real time operator, and cam and crank signals to provide engine synchronization. Some items need to be calculated during the engine event, but the great majority do not. The engine control operation is actually performed almost entirely via interrupt events.

                     

                    The first layer is the 1mS event. It performs some simple operations. It keeps a countdown timer to determine if the engine is still running. If the countdown is zero the motor is off, and one group of operations can be performed. If the motor is running, updates to values can be determined that do not change significantly but vary during run time. The 1mS event also provides a command to refresh the A/D values.  The A/Ds also operate in an interrupt process. When a conversion is complete, it generates an interrupt. In the interrupt it reads the value from the register, increments the A/D matrix pointer, then starts the next conversion. The value of the A/D read is added to the record for the individual A/D. The third level is the crank and cam interrupt. Depending on the tooth count and the speed of revolution, these events can be long or short in frequency, so only necessary operations should be performed, and only those that are affected by engine speed. The last level is the mainline of the program that operates as a forever loop. This is reserved for operations that take a long time, or only change at a very slow rate. Most notable operation here is basic communications to the user.  Updates to calibration and reading monitor values are done here.

                     

                    Process breakdown

                     

                    1 mS event:

                    Engine stopped - update tooth count,  update tach events, update operational constants

                    Engine running - update rpm, update advance, update dwell angle, update spark events, update start of dwell events

                     

                    crank tooth event:

                    record event time (high speed clock)

                    update Diff (tics between teeth)

                    update rotation angle

                    calculate value of BASE

                    compare angle to tach events (set timer if needed)

                    compare angle to spark events (set timer if needed)

                     

                    timer compare events:

                    turn on or off tach signal

                    turn on or off coil signal

                     

                    The mechanics for the timers is; from outside set the timer compare register value, save the value for which coil, and enable the interrupt. Using one compare to activate coil, and another to shut off the coil, the operation becomes simply, for one interrupt, turn on the specified coil and disable the interrupt, for the other turn off the specified coil and disable the interrupt.

                     

                    For fueling a similar breakdown is performed. In the 1 mS operation; engine off, update operational constants, update injector capacities, set injection strategey. For 1 mS operation; engine running, perform fueling calculation, calculate pulse width for injectors total time converted to angle. For batch or TBI divide by 2 or 4.

                     

                    crank tooth event, compare angle to injector on or off events. Perform similar operation to coils with injectors. Based on strategy, one or several injectors will be acted upon.

                     

                    I think that gives everything. I am going to leave coding the details to the collective. If you are able to come up with a working implementation, send me a note. I look forward to hearing if anyone had success besides me. I am still working on my prototype hardware, but the signals from the scope look promising.

                     

                    Jack