Getting Number of Laps from GPS using vectors? Data Acquisition System project for an off road racing

Hey everyone, I would like some advice (software and hardware ) for my Data Acquisition System project for an off road racing club.

The project:
This project is creating a system that can collect data for an off road racing vehicle for the SAE Baja club at the college I am attending. The data will be used to help design, confirm design, and improve future designs for the club. The system so far is collecting GPS coordinates, speed from GPS, acceleration in X, Y, and Z, and rpm of the engine. There is a screen that will provide the drive with some information like speed, number of laps, timer, and rpm.

Materials:
Arduino Mega, NEO GPS, MPU 6050, TFT 3.5" screen, SD card module

The plan is to make a lap counter by the GPS coordinates, which is easy enough, but I would like to create it in a way that it will "build" the track on the first lap. This way it will have easy set up and won't require someone to set up a path for tracking, which I would also like to have some live tracking view that goes to a station at our pit section. I will also be graduating in the spring and most members are mechanical engineers so I am trying to make a system that is easy to use. But anyways, my question is will it be able to use vector to create an undefined array size to collect GPS coordinates until there is an overlap which will be where it creates a start/finish line?

Endurance races are 4 hours long and tend to be on off-road tracks that can vary from length. The vectors only need to be for the first lap to set the start/finish line but after the line has been created, it will start recording data to the SD card. The way I plan to find a start/ finish line is by asking if the current GPS coordinates are within a tolerance and if not they will be stored in the vector array. Once there is an overlap, it will find the mid point between them and have that point be the center of SQ2. From there, it will find a line that best fits the last 10 reads or more and sets the center of SQ1 2*tolerance away from the center of SQ2 on that line of best fit. To count laps it would only add laps if it enters SQ1 then into SQ2. Refer to the first picture for a visual of what I am thinking.

From a race, I was able to collect data, average 1470 data points per minute ( 51 char per data point ), roughly 25 Hz to a text file on the SD card. Arduino baud rate is 115200 and the GPS baud rate is 9600.

So I am curious what are people's thoughts on this idea? What is good with it? Where could it go wrong?

According to "Analysis Techniques for Racecar Data Acquisition" by Jorge Segers, he says 20 Hz for GPS is good so I think 25 Hz will be fine but I would like to add a few more sensors such as fuel level, engine temp, CVT secondary rpm and CVT temp and possibly suspension travel and transmit some of this information to a station at our pit area so that people in the pits can have an idea of where the car is at and what might be happening to the car. If there are ways which could make the code collect faster that would be great! Or if there are ways to have different collecting speeds for different sensors without using delay or anything to slow down the program. I know I have two interrupts in the code but those are necessary as far as I know.

image

I am considering on expanding to a better board like DUE or share the load between two MEGAS or connect to a raspberry pi 3 but I am unsure about speeds and would like some advice on which route to go down. This is a project that would need to be done by late spring before national competition happens.

I appreciate any help or advise that people are willing to give!

Here is the code I used for the last race. There are some empty functions which are potential holders for future development.

[code]





//Libraries
#include <TinyGPS++.h>
#include <SPI.h>
#include <Wire.h>
#include <SD.h>
#include <MCUFRIEND_kbv.h>   // Hardware-specific library
#include <basicMPU6050.h> 
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSans12pt7b.h>
#include <Fonts/FreeSerif12pt7b.h>
#include <FreeDefaultFonts.h>
#define BLACK   0x0000
#define RED     0xF800
#define GREEN   0x07E0
#define WHITE   0xFFFF
#define GREY    0x8410
//#include <Adafruit_GFX.h>    // Core graphics library
#include <math.h>


//Varibles

//GPS
// Pin layout: for Mega
//  VCC - +5V
//  GND - GND
//  TX - 19
//  RX - 18

static const uint32_t GPSBaud = 9600;
TinyGPSPlus gps;
int satelliteCount = 0;
int speedMph;
int lapCount;
int fastestLap;
int previousLat;
int currentLat;
int previousLong;
int currrentLong;


//SD Card
// Pin layout: for Mega
//  GND - GND
//  
File myFile;
const int chipSelect = 53;
int recordPin;
int recordLED;
bool record;


//Screen
// Pin layout: for Mega
//
//
MCUFRIEND_kbv tft;
int displayCount;
int displayNum;

int cycleButtonState;
int cycleLastButtonState;
int redLED = 40;
int greenLED = 42;
int blueLED = 44; 

int cycleButton = 38;
int enableButton = 39;
int enableButtonState;
int enableLastButtonState;
bool enable;

int previousCase;
int newCase;


//Accelometer
//
//
//
basicMPU6050<> imu;


//
//
//

//RPM
// RPM declarations and varibales.
// To change the number of times a pulse happens for every pulse go to PulsesPerRevolution
// Input signal goes to the RPMinputSig and be sure it is a pin that can be used as an interrupt pin, currently it is set to 21

const byte PulsesPerRevolution = 1;  // Set how many pulses there are on each revolution. Default: 2.

// If the period between pulses is too high, or even if the pulses stopped, then we would get stuck showing the
// last value instead of a 0. Because of this we are going to set a limit for the maximum period allowed.
// If the period is above this value, the RPM will show as 0.
// The higher the set value, the longer lag/delay will have to sense that pulses stopped, but it will allow readings
// at very low RPM.
// Setting a low value is going to allow the detection of stop situations faster, but it will prevent having low RPM readings.
// The unit is in microseconds.
const unsigned long ZeroTimeout = 100000;  // For high response time, a good value would be 100000.
// For reading very low RPM, a good value would be 300000.


// Calibration for smoothing RPM:
const byte numReadings = 4;  // Number of samples for smoothing. The higher, the more smoothing, but it's going to
// react slower to changes. 1 = no smoothing. Default: 2.

/////////////
// Variables:
/////////////
int RPMinputSig = 17;   //-----------------------------------------------------------------------------------------------------------------------------------------------------------------------

volatile unsigned long LastTimeWeMeasured;  // Stores the last time we measured a pulse so we can calculate the period.
volatile unsigned long PeriodBetweenPulses = ZeroTimeout + 1000; // Stores the period between pulses in microseconds.
// It has a big number so it doesn't start with 0 which would be interpreted as a high frequency.
volatile unsigned long PeriodAverage = ZeroTimeout + 1000; // Stores the period between pulses in microseconds in total, if we are taking multiple pulses.
// It has a big number so it doesn't start with 0 which would be interpreted as a high frequency.
unsigned long FrequencyRaw;  // Calculated frequency, based on the period. This has a lot of extra decimals without the decimal point.
unsigned long FrequencyReal;  // Frequency without decimals.
unsigned long RPM;  // Raw RPM without any processing.
unsigned int PulseCounter = 1;  // Counts the amount of pulse readings we took so we can average multiple pulses before calculating the period.

unsigned long PeriodSum; // Stores the summation of all the periods to do the average.

unsigned long LastTimeCycleMeasure = LastTimeWeMeasured;  // Stores the last time we measure a pulse in that cycle.
// We need a variable with a value that is not going to be affected by the interrupt
// because we are going to do math and functions that are going to mess up if the values
// changes in the middle of the cycle.
unsigned long CurrentMicros = micros();  // Stores the micros in that cycle.
// We need a variable with a value that is not going to be affected by the interrupt
// because we are going to do math and functions that are going to mess up if the values
// changes in the middle of the cycle.

// We get the RPM by measuring the time between 2 or more pulses so the following will set how many pulses to
// take before calculating the RPM. 1 would be the minimum giving a result every pulse, which would feel very responsive
// even at very low speeds but also is going to be less accurate at higher speeds.
// With a value around 10 you will get a very accurate result at high speeds, but readings at lower speeds are going to be
// farther from eachother making it less "real time" at those speeds.
// There's a function that will set the value depending on the speed so this is done automatically.
unsigned int AmountOfReadings = 1;

unsigned int ZeroDebouncingExtra;  // Stores the extra value added to the ZeroTimeout to debounce it.
// The ZeroTimeout needs debouncing so when the value is close to the threshold it
// doesn't jump from 0 to the value. This extra value changes the threshold a little
// when we show a 0.

// Variables for smoothing tachometer:
unsigned long readings[numReadings];  // The input.
unsigned long readIndex;  // The index of the current reading.
unsigned long total;  // The running total.
unsigned long average;  // The RPM value after applying the smoothing.













void setup() {

  Serial.begin(115200);
  Serial2.begin(GPSBaud);
  
  pinMode(chipSelect, OUTPUT);
  SD.begin(chipSelect);
  if(!SD.begin(chipSelect))
  {
    // does this if sd reader not loaded
  }
  else
  {
    myFile = SD.open("datalog.txt", FILE_WRITE);  // 
    myFile.println("\n\nNEW DATALOG ENTRY\n");
  }
  
  Wire.begin(); //initiate wire library and I2C
  
  Wire.write(0x6B); // PWR_MGMT_1 register
  Wire.write(0); // set to zero (wakes up the MPU-6050)  
  Wire.endTransmission(true); //ends transmission to I2C slave device

  // Set registers - Always required
  imu.setup();
  // Initial calibration of gyro
  imu.setBias();
  
  pinMode(recordPin, INPUT);
  pinMode(recordLED, OUTPUT);


  pinMode(cycleButton, INPUT_PULLUP);
  pinMode(enableButton, INPUT_PULLUP);
  
  


while(!Serial2)
{
  displayStartUp();
}





  attachInterrupt(digitalPinToInterrupt(RPMinputSig), Pulse_Event, RISING);  // Enable interruption pin 21 when going from LOW to HIGH.
  attachInterrupt(digitalPinToInterrupt(18), displayAccel, RISING); //pin 18
  delay(1000);

}

void loop() {
  
  CycleButtonDebounce();
  Record ();
  while (Serial2.available() > 0)
  {
    gps.encode(Serial2.read());
  }
  if (millis() > 5000 && gps.charsProcessed() < 10)
  {
    Serial.println("BAD");
    while(true)
    {
      Serial.println("#1");
      if(Serial1.available() > 0)
      {
        
        Serial.println("#2");
      }
    }
  }


}

void CycleButtonDebounce()
{
  cycleButtonState = digitalRead(cycleButton);
  if (cycleButtonState != cycleLastButtonState)
  {
    if (cycleButtonState == LOW)
    {
      displayCount ++;
      if (displayCount < 10)
      {
        displayNum = displayCount;
      }
      else
      {
        displayNum = (displayCount % 10);
      }     
    }
  }
  
  cycleLastButtonState = cycleButtonState;
  buttonSelect();
}

void EnableButtonDebounce()
{
  enableButtonState = digitalRead(enableButton);
  if (enableButtonState != enableLastButtonState)
  {
    if (enableButtonState == LOW)
    {
      enable = true;
          
    }
    else
    {
      enable = false;
    }
  }
  
  enableLastButtonState = enableButtonState;
}

void buttonSelect()
{
  
  switch (displayNum)
  {
    case 1:
      resetScreenFunction();
      displaySpeed();
      break;
    case 2:
      resetScreenFunction();
      displayRPM();
      break;
    case 3:
      resetScreenFunction();
      displayAccel();
      break;
    case 4:
      resetScreenFunction();
      displayGPS();
      break;
    case 5:
      resetScreenFunction();
      displayTimer();
      break;
    case 6:
      resetScreenFunction();
      displaySatellite();
      break;
    case 7:
      resetScreenFunction();
      displayGPS();
      break;
    case 8:
      resetScreenFunction();
      displayLap();
      break;
    case 9:
      resetScreenFunction();
      displayAll();
      break;
    case 10:
      resetScreenFunction();
      displayRecord();
      break;
    default:
      PreReqScreen();
      break;
  }
}

void resetScreenFunction()
{
  newCase = displayNum;
  
  if (previousCase != newCase)
  {
    tft.fillScreen(BLACK);
    previousCase = newCase;
    
  }
}

void displayStartUp()
{
  uint16_t ID = tft.readID();
  if (ID == 0xD3D3) ID = 0x9481; //force ID if write-only display
  tft.begin(ID);
  tft.setRotation(1);
  tft.fillScreen(BLACK);
  tft.setTextColor(GREEN, BLACK);
  tft.setTextSize(7);
  tft.setCursor(0, 120);
  tft.print("Starting Up");
  delay(3000);
  tft.fillScreen(BLACK);
  Serial.print("Button = ");
  Serial.println(displayNum);
}

void displaySatellite()
{
 if (gps.satellites.isUpdated())
  {
    Serial.print("Button = ");
    Serial.println(displayNum);
    Serial.print("Sat Count = ");
    Serial.println(gps.satellites.value());
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(9);
    tft.setCursor(0, 120);
    tft.print("SAT: ");
    tft.print(gps.satellites.value());
  }
  
}

void displaySpeed()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  if (gps.speed.isUpdated())
  {
    //Serial.print("Speed = ");
    //Serial.println(gps.speed.mph());
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(5);
    tft.setCursor(0, 120);
    tft.print("MPH: ");
    tft.print(gps.speed.mph());
    
    //tft.print(gps.speed.mph());
  }
}

void displayLap()
{
    Serial.print("Button = ");
    Serial.println(displayNum);
   
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(5);
    tft.setCursor(0, 120);
    tft.print("Lap: ");
    tft.print(gps.speed.mph());
}

void displayRPM()
{
  Serial.print("Button = ");
  Serial.println(displayNum);

  tft.setTextColor(GREEN, BLACK);
  tft.setTextSize(7);
  tft.setCursor(0, 120);
  tft.print("RPM: ");
  tft.print(average);
}

void displayTimer()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  if (gps.time.isUpdated())
  {
    
    Serial.print("Time = ");
    Serial.println(gps.time.value());
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(5);
    tft.setCursor(0, 120);
    tft.print("Timer: ");
    tft.print(gps.date.year());
  }
}

void RPMfunction()
{
  // The following is going to store the two values that might change in the middle of the cycle.
  // We are going to do math and functions with those values and they can create glitches if they change in the
  // middle of the cycle.
  LastTimeCycleMeasure = LastTimeWeMeasured;  // Store the LastTimeWeMeasured in a variable.
  CurrentMicros = micros();  // Store the micros() in a variable.

  // CurrentMicros should always be higher than LastTimeWeMeasured, but in rare occasions that's not true.
  // I'm not sure why this happens, but my solution is to compare both and if CurrentMicros is lower than
  // LastTimeCycleMeasure I set it as the CurrentMicros.
  // The need of fixing this is that we later use this information to see if pulses stopped.
  if (CurrentMicros < LastTimeCycleMeasure)
  {
    LastTimeCycleMeasure = CurrentMicros;
  }

  // Calculate the frequency:
  FrequencyRaw = 10000000000 / PeriodAverage;  // Calculate the frequency using the period between pulses.

  // Detect if pulses stopped or frequency is too low, so we can show 0 Frequency:
  if (PeriodBetweenPulses > ZeroTimeout - ZeroDebouncingExtra || CurrentMicros - LastTimeCycleMeasure > ZeroTimeout - ZeroDebouncingExtra)
  { // If the pulses are too far apart that we reached the timeout for zero:
    FrequencyRaw = 0;  // Set frequency as 0.
    ZeroDebouncingExtra = 2000;  // Change the threshold a little so it doesn't bounce.
  }
  else
  {
    ZeroDebouncingExtra = 0;  // Reset the threshold to the normal value so it doesn't bounce.
  }

  FrequencyReal = FrequencyRaw / 10000;  // Get frequency without decimals.
  // This is not used to calculate RPM but we remove the decimals just in case
  // you want to print it.

  // Calculate the RPM:
  RPM = FrequencyRaw / PulsesPerRevolution * 60;  // Frequency divided by amount of pulses per revolution multiply by
  // 60 seconds to get minutes.
  RPM = RPM / 10000;  // Remove the decimals.

  // Smoothing RPM:
  total = total - readings[readIndex];  // Advance to the next position in the array.
  readings[readIndex] = RPM;  // Takes the value that we are going to smooth.
  total = total + readings[readIndex];  // Add the reading to the total.
  readIndex = readIndex + 1;  // Advance to the next position in the array.

  if (readIndex >= numReadings)  // If we're at the end of the array:
  {
    readIndex = 0;  // Reset array index.
  }

  // Calculate the average:
  average = total / numReadings;  // The average value it's the smoothed result.
  
}

void displayBluetooth()
{
      Serial.print("Button = ");
    Serial.println(displayNum);
}

void displayGPS()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  
  if (gps.location.isUpdated())
  {
    tft.setTextSize(5);
    tft.setCursor(0, 120);
    tft.print("Lat: ");
    tft.print(gps.location.lat(), 6);
    tft.setCursor(0, 180);
    tft.print("Long: ");
    tft.print(gps.location.lng(), 6);
  }
}

void displayAll()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  
}

void displayRecord()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  
  EnableButtonDebounce();
  if (record == true)
  {
    myFile = SD.open("datalog.txt", FILE_WRITE);

  String data = "";

    if(myFile)
    {
      data += millis();
      data += ",";
      data += (RPM);
      data += ",";
      data += (gps.speed.mph());
      myFile.println(data);
      myFile.close();
      Serial.println ("SD card sucess");
    }
    else
    {
      Serial.println ("SD card failure");
    }
  }
}

void PreReqScreen()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
  if (gps.satellites.isUpdated())
  {
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(5);
    tft.setCursor(0, 20);
    tft.print("SAT = ");
    tft.print(gps.satellites.value());
  }
  if (gps.date.isUpdated())
  {
    tft.setTextColor(GREEN, BLACK);
    tft.setTextSize(5);
    tft.setCursor(0, 120);
    tft.print("Date: ");
    tft.print(gps.date.value());
    
  }
}

void Pulse_Event()  // The interrupt runs this to calculate the period between pulses:
{

  PeriodBetweenPulses = micros() - LastTimeWeMeasured;  // Current "micros" minus the old "micros" when the last pulse happens.
  // This will result with the period (microseconds) between both pulses.
  // The way is made, the overflow of the "micros" is not going to cause any issue.

  LastTimeWeMeasured = micros();  // Stores the current micros so the next time we have a pulse we would have something to compare with.


  if (PulseCounter >= AmountOfReadings) // If counter for amount of readings reach the set limit:
  {
    PeriodAverage = PeriodSum / AmountOfReadings;  // Calculate the final period dividing the sum of all readings by the
    // amount of readings to get the average.
    PulseCounter = 1;  // Reset the counter to start over. The reset value is 1 because its the minimum setting allowed (1 reading).
    PeriodSum = PeriodBetweenPulses;  // Reset PeriodSum to start a new averaging operation.


    // Change the amount of readings depending on the period between pulses.
    // To be very responsive, ideally we should read every pulse. The problem is that at higher speeds the period gets
    // too low decreasing the accuracy. To get more accurate readings at higher speeds we should get multiple pulses and
    // average the period, but if we do that at lower speeds then we would have readings too far apart (laggy or sluggish).
    // To have both advantages at different speeds, we will change the amount of readings depending on the period between pulses.
    // Remap period to the amount of readings:
    int RemapedAmountOfReadings = map(PeriodBetweenPulses, 40000, 5000, 1, 10);  // Remap the period range to the reading range.
    // 1st value is what are we going to remap. In this case is the PeriodBetweenPulses.
    // 2nd value is the period value when we are going to have only 1 reading. The higher it is, the lower RPM has to be to reach 1 reading.
    // 3rd value is the period value when we are going to have 10 readings. The higher it is, the lower RPM has to be to reach 10 readings.
    // 4th and 5th values are the amount of readings range.
    RemapedAmountOfReadings = constrain(RemapedAmountOfReadings, 1, 10);  // Constrain the value so it doesn't go below or above the limits.
    AmountOfReadings = RemapedAmountOfReadings;  // Set amount of readings as the remaped value.
  }
  else
  {
    PulseCounter++;  // Increase the counter for amount of readings by 1.
    PeriodSum = PeriodSum + PeriodBetweenPulses;  // Add the periods so later we can average.
  }

}  // End of Pulse_Event.

void displayAccel()
{
  Serial.print("Button = ");
  Serial.println(displayNum);
    // Update gyro calibration 
  imu.updateBias();
 
  //-- Scaled and calibrated output:
  // Accel
  Serial.print( imu.ax() );
  Serial.print( " " );
  Serial.print( imu.ay() );
  Serial.print( " " );
  Serial.print( imu.az() );
  Serial.print( "    " );
  
  // Gyro
  Serial.print( imu.gx() );
  Serial.print( " " );
  Serial.print( imu.gy() );
  Serial.print( " " );
  Serial.print( imu.gz() );
  Serial.print( "    " );  
  
  // Temp
  Serial.print( imu.temp() );
  Serial.println(); 

  tft.setCursor(0, 60);
  tft.print("X: ");
  tft.print(imu.ax());
  tft.setCursor(0, 120);
  tft.print("Y: ");
  tft.print(imu.ay());
  tft.setCursor(0, 180);
  tft.print("Z: ");
  tft.print(imu.az());
}

void Record ()
{
float Lat;
float Long;

  

  Lat = (gps.location.lat() );
  Long = (gps.location.lng());
  //Serial.println(Lat);
  //Serial.println(Long);
  
  
  String data = "";
  String LatString = String(Lat, 6);
  String LongString = String(Long, 6);
  //LatString.concat(Lat);
  //LongString.concat(Long);
  Serial.println (LatString);
  Serial.println (LongString);
  

  myFile = SD.open("datalog.txt", FILE_WRITE);
  
  if(myFile)
  {
    data += millis();
    data += ",";
    data += (gps.speed.mph());
    data += ",";
    data += (average);
    data += ",";
    data += (LatString);
    data += ",";
    data += (LongString);
    data += ",";
    data += (imu.ax());
    data += ",";
    data += (imu.ay());
    data += ",";
    data += (imu.az());
    myFile.println(data);
    myFile.close();
  }
  else
  {
    // does this if file couldnt be opened
  }




  
}

void LapCounter ()
{
  
}
[/code]

Here is a sample of the data I collected from the last race.
Millies, speed, rpm, Lat, Long, Acceleration X, Y, Z

image

I built an Engine monitor for a friend's Europa Experimental. I used an Atmega2560 stock.

There are 2 2-line OLED displays in the airplane but the serial sensor stream is formatted faux-JSON and feeds into NodeRed on an old laptop.
https://www.stm32duino.com/viewtopic.php?f=42&t=959&p=9015

As there are 2 thermocouples and numerous thermistors and analog inputs being acquired, the 2560 "loop" is approximately 650mS. This certainly suggests that something more robust than a 2569 @16MHz will be required.

This sounds tricky. Dynamic memory allocation to grow your position vector is going to run the risk of running out of memory if the racetrack is long. A Mega doesn't have that much RAM, especially if you're going to get positions at 20Hz.

Then the track layout may be a problem - a U-turn near the start may give a false indication that a lap is over.

Then there's the pits. Can you know if you're there for a tyre change or actually on the track racing?

I expect it can be done, but a Due would be nice for speed and memory.

Have you seen this?

Phone app

1 Like

I like the route that you went. It gives me confidence to do a arduino and raspberry pi combination. I was a bit worried that I would have to try to learn to make a script but the NodeRed looks like a good route to go down.

That project is very similar to what I am try to achieve.

Yeah that is why I asked if it was possible and to bounce ideas off other people.

An idea is to lower the rate till it creates a start/finish line. Or if I do go the route of using arduino raspberry pi combination, maybe could just store the points to create the track on the pi. Though I am not sure if that could run with the NodeRed program that mrburnette suggested.

Very true I had the same thoughts about winding track layouts messing with miscounting laps. If you look at the picture, that is from that race I recorded from, and you can see it has winding path near the start/finish line ( that is the area the path splits into two and the track runs clockwise with the blue dot being the pit area).

An idea I had was to have it read a random number of point before incrementing lap count or create another area that has to be passed through that would be located another spot on the track.

image