
Library for Arduino for creating damned accurate lap-timings using GPS data, on par with other commercial solutions. Once the driver is within a specified threshold of the line, it begins logging gps lat/lng/alt/speed. Once past the threshold, the 4 points closest to the line are used to interpolate the exact crossing — see Interpolation modes below for the linear vs. Catmull-Rom trade-off.
Curious how the crossing detection actually works — and why the obvious approaches (distance-to-line, heading, compass) all fail? `DETECTION.md` is the deep-dive, including the MyLaps validation results.
Quickstart
Serial.begin(115200);
28.41273, -81.37957);
}
Serial.print("Last lap (ms): ");
}
}
void setup()
Definition basic_oled_example.ino:85
DovesLapTimer lapTimer(crossingThresholdMeters)
void loop()
Definition basic_oled_example.ino:116
Definition DovesLapTimer.h:103
unsigned long getLastLapTime() const
Gets the last lap time.
Definition DovesLapTimer.cpp:557
void updateCurrentTime(unsigned long currentTimeMilliseconds)
Updates the current GPS time since midnight.
Definition DovesLapTimer.cpp:534
int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots)
Updates a few internal stats and then checks the status of crossing a line.
Definition DovesLapTimer.cpp:28
void setStartFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the start/finish line using two points (A and B).
Definition DovesLapTimer.cpp:504
int getLaps() const
Gets the total number of laps completed.
Definition DovesLapTimer.cpp:581
That's the minimum. Add sector lines via setSector2Line() / setSector3Line() for split times. For multi-course tracks with automatic course detection, use CourseManager instead — see the API below.
The library does not talk to GPS hardware directly. You feed it (lat, lng, alt_m, speed_knots, time_ms_since_midnight), it returns lap timing. See examples/ for full sketches wiring up Adafruit_GPS and an OLED.
Testing & validation
Three layers of regression tests run on every push and PR (see `test/README.md` for the full layout):
- Layer 1 — structural (
arduino-lint + compile-examples): every example compiles across AVR Mega, AVR Uno, ESP32, and XIAO nRF52840.
- Layer 2 — module unit tests (43 tests, host-native):
GeoMath, DirectionDetector, CourseDetector state machine, plus a synthetic-track integration pass over the full lap-timer pipeline.
- Layer 3 — NMEA replay regression (23 tests, host-native): four real GPS recordings from Orlando Kart Center are replayed through the lap timer; lap times must match pinned goldens within ±10 ms, plus a ±200 ms sanity bound against MyLaps magnetic-loop ground truth where it's available.
To run locally: cd test && make run.
What's New in v4.0
- Automatic Course Detection - Drive a lap at any track and the library figures out which course layout you're on by matching driven distance against known courses. No manual selection needed.
- Multi-Course Support - Define up to 8 course layouts per track (different configurations, rental vs. pro layouts, etc).
CourseManager orchestrates them all.
- Direction Detection - Automatically detects whether you're driving the course forward or reverse from the temporal order of sector-line crossings within a lap window. Glitch-resistant — needs both physical sector lines crossed in a lap before resolving, so a single GPS teleport can't lock the wrong direction.
- **"Lap Anything" Fallback** - If course detection fails (3 rejected candidate sets, 3 laps matching no configured course length, or too much distance driven without detection resolving), falls back to
WaypointLapTimer which drops a waypoint and uses proximity-based timing. Works on any track, anywhere - no pre-configured lines needed.
- Configurable Thresholds - Speed, proximity, and detection thresholds are now adjustable at runtime via setter methods.
Supported Hardware: MCU
While this is technially an arduino library, this needs a device with a large amount of ram and processing power due to all the floating point math.
- Arduino Mega+
- Compiles and counts laps, but runs degraded: on classic AVR
double is a 32-bit float (~7 significant digits), which quantizes real-world GPS coordinates to roughly 0.2–0.75 m and makes per-fix odometer increments and interpolated crossing times noticeably less accurate. The compiler emits a #warning on these targets. CourseManager does not fit in a Mega's 8 KB SRAM at all — see "Memory Usage" below. Full advertised precision needs a true 64-bit-double MCU.
- Seed NRF52840 (Recommended)
- Has a dedicated high speed FPU for both floats and doubles
- Fast enough to support GPS/Display/SDCard Logging
- Really low power
- 65mA~ with screen, gps, and bluetooth
- 256KB RAM, 1MB Flash
- Sense version not required
Supported Hardware: GPS
Getting GPS data is your job, not mine, but here are a couple I recommend that work well with the Adafruit GPS library.
Note: The Basic Oled Example has an example on how to send ublox configuration commands while receiving only NMEA sentences.
Note: If GPS is not an authentic UBLOX module, sending configuration commands might, fail but receiving data should probably still work.
- Matek SAM-M10Q
- 25hz GPS only
- 16hz GPS+GALILEO+GLONASS
- Uses NMEA or UBLOX commands (NMEA for all included examples)
- Matek SAM-M8Q
- 18hz GPS only
- 10hz GPS+GLONASS
- Uses NMEA or UBLOX commands (NMEA for all included examples)
- Check your local/regional RC plane/drone resources for a serial compatible GPS!
Supported Hardware: Display
- 128x64 i2c 110X display
- Note: Only here for the included demo
- Note: Demo also includes pre-compile switch for 1306 displays
Supported Functions
- Current lap
- Last lap
- Best lap
- Pace difference against current and best lap
- Sector timing (optional)
- 3 sectors per lap (Sector 1, 2, and 3)
- Best time for each sector
- Current lap sector times
- Optimal lap time (sum of best sectors)
- Track which lap achieved best sector times
- Direction detection (v4.0)
- Automatic forward/reverse detection based on sector crossing order
- Automatic course detection (v4.0)
- Detects which course layout the driver is on by matching driven distance
- Supports up to 8 course layouts per track
- **"Lap Anything" fallback** (v4.0)
- Proximity-based lap timing when no course is detected
- Works on any track without pre-configured crossing lines
- List lap times
Architecture (v4.0)
The library is organized into a hierarchy of components:
CourseManager (orchestrator - optional, use for multi-course tracks)
├── DovesLapTimer[8] # One per course layout, line-crossing detection
│ └── DirectionDetector # Detects forward/reverse driving direction
├── CourseDetector # State machine: speed → waypoint → distance match
├── WaypointLapTimer # Fallback "Lap Anything" proximity-based timer
└── GeoMath.h # Shared haversine distance functions
You can still use DovesLapTimer standalone if you only have one course and know your crossing lines up front. CourseManager is for when you want automatic course detection and multi-course support.
Interpolation modes
When the driver exits the crossing zone, the library has a buffer of GPS fixes straddling the line and needs to compute the exact crossing time + position. Two modes are available:
| Affects lap time? | Affects crossing-point coords? | When to use |
forceLinearInterpolation() _(default)_ | yes — linear blend by distance + speed | yes — straight-line blend | Always works. The right default for kart timing — sub-10ms regression-tested against real fixtures. |
forceCatmullRomInterpolation() | no — falls back to linear for time/odometer | yes — smooth spline if 4 control points available, else linear | Useful only if you care about the reported crossing lat/lng coords (e.g. plotting where on the line you crossed). Lap-time output is identical to linear. |
Important context: Catmull-Rom used to interpolate everything including time, but spline overshoot occasionally produced wrong lap times. The fix limited the spline to lat/lng of the crossing point — time and odometer are always linear. So switching modes has no effect on getLastLapTime() / getBestLapTime() / sector times. If you only care about timing, leave the default alone.
API
Option 1: DovesLapTimer (standalone, single course)
Use this if you know your track and just want lap timing. This is the original API.
See the source code, specifically the DovesLapTimer.h file. The code should have clarifying comments wherever there are any unclear bits.
Initialize
#define DEBUG_SERIAL Serial
double crossingThresholdMeters
Definition basic_oled_example.ino:75
#define DEBUG_SERIAL
Definition basic_oled_example.ino:26
Setup()
const double crossingPointBLat
Definition basic_oled_example.ino:21
const double crossingPointBLng
Definition basic_oled_example.ino:22
const double crossingPointALat
Definition basic_oled_example.ino:19
const double crossingPointALng
Definition basic_oled_example.ino:20
void forceLinearInterpolation()
forces linear interpolation when checking crossing line
Definition DovesLapTimer.cpp:537
void reset()
Reset all parameters back to 0.
Definition DovesLapTimer.cpp:442
void setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the sector 2 line using two points (A and B).
Definition DovesLapTimer.cpp:514
void setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the sector 3 line using two points (A and B).
Definition DovesLapTimer.cpp:524
void forceCatmullRomInterpolation()
Forces Catmull-Rom spline interpolation when checking crossing line.
Definition DovesLapTimer.cpp:540
const double sector3PointBLat
Definition sector_timing_example.ino:77
const double sector3PointBLng
Definition sector_timing_example.ino:78
const double sector2PointBLat
Definition sector_timing_example.ino:71
const double sector3PointALat
Definition sector_timing_example.ino:75
const double sector2PointALng
Definition sector_timing_example.ino:70
const double sector3PointALng
Definition sector_timing_example.ino:76
const double sector2PointALat
Definition sector_timing_example.ino:69
const double sector2PointBLng
Definition sector_timing_example.ino:72
Loop()->gpsLoop()
create a simple method with the signature unsigned long getGpsTimeInMilliseconds(); to... as it says, get the current time from the gps in milliseconds.
Now inside of your gps loop, add something like the following
All of the lap timing magic is happening inside of checkStartFinish consider that our "timing loop".
float altitudeMeters =
gps->altitude;
float speedKnots =
gps->speed;
}
unsigned long getGpsTimeInMilliseconds()
Returns the GPS time since midnight in milliseconds.
Definition basic_oled_example.ino:58
Adafruit_GPS * gps
Definition basic_oled_example.ino:39
Input robustness: loop() validates every fix before it touches timing state — NaN/Inf, out-of-range, and exact (0,0) coordinates are rejected, and single-fix teleports (>500m between consecutive fixes) are dropped as glitches (a sustained relocation is re-accepted after 3 consecutive fixes). Lap, sector, and current-lap times are also normalized across the UTC midnight rollover of the GPS time-of-day, so sessions running through midnight UTC time correctly.
Here is an example getGpsTimeInMilliseconds()
unsigned long timeInMillis = 0;
timeInMillis +=
gps->hour * 3600000ULL;
timeInMillis +=
gps->minute * 60000ULL;
timeInMillis +=
gps->seconds * 1000ULL;
timeInMillis +=
gps->milliseconds;
return timeInMillis;
}
Retrieving Data
Now if you want any running information, you have the following...
bool isStartFinishLineConfigured() const;
bool getRaceStarted() const;
bool getCrossing() const;
unsigned long getCurrentLapStartTime() const;
unsigned long getCurrentLapTime() const;
unsigned long getLastLapTime() const;
unsigned long getBestLapTime() const;
float getPaceDifference() const;
float getCurrentLapOdometerStart() const;
float getCurrentLapDistance() const;
float getLastLapDistance() const;
float getBestLapDistance() const;
float getTotalDistanceTraveled() const;
float getCurrentSpeedKmh() const;
float getCurrentSpeedMph() const;
int getBestLapNumber() const;
int getLaps() const;
bool areSectorLinesConfigured() const;
int getCurrentSector() const;
unsigned long getBestSector1Time() const;
unsigned long getBestSector2Time() const;
unsigned long getBestSector3Time() const;
unsigned long getCurrentLapSector1Time() const;
unsigned long getCurrentLapSector2Time() const;
unsigned long getCurrentLapSector3Time() const;
unsigned long getOptimalLapTime() const;
int getBestSector1LapNumber() const;
int getBestSector2LapNumber() const;
int getBestSector3LapNumber() const;
int getDirection() const;
bool isDirectionResolved() const;
Option 2: CourseManager (multi-course, automatic detection)
Use this when your track has multiple course layouts and you want the library to figure out which one you're on automatically. The CourseManager feeds GPS data to all course timers simultaneously, uses CourseDetector to identify the course by driven distance, and falls back to WaypointLapTimer ("Lap Anything") if detection fails.
Define Your Track
"Orlando Kart Center",
"OKC",
{
{
"Pro Layout",
2100.0,
28.4192, -81.4301, 28.4193, -81.4300,
28.4195, -81.4305, 28.4196, -81.4304,
28.4190, -81.4298, 28.4191, -81.4297,
true,
true
},
{
"Rental Layout",
1800.0,
28.4192, -81.4301, 28.4193, -81.4300,
0, 0, 0, 0,
0, 0, 0, 0,
false,
false
}
},
2
};
Definition CourseManager.h:30
Initialize and Use
manager.loop(
gps->latitudeDegrees,
gps->longitudeDegrees,
gps->altitude,
gps->speed);
if (manager.isDetectionComplete()) {
if (manager.isLapAnythingActive()) {
} else {
Serial.println(manager.getActiveCourseName());
}
}
}
}
Definition CourseManager.h:44
Definition WaypointLapTimer.h:29
CourseManager API
void updateCurrentTime(unsigned long ms);
int loop(
double lat,
double lng,
float altMeters,
float speedKnots);
void reset();
bool isDetectionComplete() const;
int getActiveCourseIndex() const;
const char* getActiveCourseName() const;
int getCourseCount() const;
int getDetectionRejectionCount() const;
bool isLapAnythingActive() const;
const char* getTrackName() const;
const char* getShortName() const;
void pruneInactiveCourses();
void setSpeedThresholdMph(float mph);
void setWaypointProximityMeters(float meters);
void setDetectionProximityMeters(float meters);
WaypointLapTimer ("Lap Anything")
The WaypointLapTimer is the fallback that kicks in when no pre-configured course matches. It can also be used standalone if you just want proximity-based timing without any crossing lines.
How it works:
- Wait for speed >= 20 mph, drop a waypoint at that position
- Drive away (minimum 100m traveled)
- When you return near the waypoint (within 30m), it buffers approach points
- On exit from the proximity zone, it uses the closest-approach point's time as the lap split
- Repeat for subsequent laps
bool getRaceStarted() const;
int getLaps() const;
unsigned long getCurrentLapTime() const;
unsigned long getLastLapTime() const;
unsigned long getBestLapTime() const;
float getPaceDifference() const;
float getCurrentLapDistance() const;
float getTotalDistanceTraveled() const;
bool hasWaypoint() const;
double getWaypointLat() const;
double getWaypointLng() const;
SprintTimer (point-to-point / "sprint mode")
For autocross, hillclimbs, and rally-stage style events there are no laps: a run starts at a START line and ends at a separate FINISH line, with up to two optional split lines between them. SprintTimer handles exactly that.
Run semantics (deliberately simple):
- Crossing the start line begins a run — including while a run is already active: the in-progress run is cancelled and a new one starts (you botched the course and drove back around to re-launch).
- Crossing the finish line completes the run. Finish crossings with no active run are ignored, so driving back past the finish on the return loop does nothing.
- A DNF is nothing special — an abandoned run never completes and records nothing.
Every line is watched on every GPS fix with its own independent crossing buffer (no shared-buffer mutual exclusion like the circuit timer), so the start and finish lines may sit arbitrarily close together — common in autocross paddocks.
Splits are optional and flexible: zero, one, or two lines are all legal (unlike the circuit timer's both-or-nothing sector pair). Segments are numbered in crossing order; a missed or out-of-order split invalidates that run's segment data but never the run time itself.
bool isRunActive() const;
int getRuns() const;
int getCancelledRunCount() const;
unsigned long getCurrentRunTime() const;
unsigned long getLastRunTime() const;
unsigned long getBestRunTime() const;
int getBestRunNumber() const;
Definition SprintTimer.h:61
void setStartLine(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the START line (a run begins when it is crossed).
Definition SprintTimer.cpp:273
void updateCurrentTime(unsigned long currentTimeMilliseconds)
Updates the current GPS time since midnight (milliseconds).
Definition SprintTimer.cpp:137
void setFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the FINISH line (a run completes when it is crossed while a run is active).
Definition SprintTimer.cpp:282
void setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the optional sector 2 split line (closes segment 1).
Definition SprintTimer.cpp:291
void setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng)
Sets the optional sector 3 split line (closes the segment after sector 2's, or segment 1 if sector 2 ...
Definition SprintTimer.cpp:300
int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots)
Feed a GPS fix. Call updateCurrentTime() first, then this, on every fix — identical contract to Doves...
Definition SprintTimer.cpp:23
SprintTimer sprintTimer(7.0)
SprintTimer targets 32-bit MCUs — each configured line's independent buffer is ~2.9 KB, ~11.6 KB for a fully-configured instance. See examples/sprint_timing_example for a complete synthetic-course demo.
If your caller already knows which course layout is in play (e.g. an app-selected course), CourseManager::selectCourse(int index) skips automatic detection entirely — useful for circuit timing too, and required for sprint courses, where detection's drive-a-lap-back-to-your-waypoint premise can never be satisfied.
How Course Detection Works
The CourseDetector is a state machine that runs inside CourseManager:
- IDLE - Waiting to start
- WAITING_FOR_SPEED - Waiting for driver to reach 20 mph
- WAYPOINT_SET - Speed threshold hit, waypoint dropped. Now waiting for the driver to travel 200m+ and return within 10m of the waypoint
- CANDIDATES_READY - Driver returned. Driven distance is compared to each course's
lengthFt (within 25% tolerance). Ranked candidates are built
- DETECTED -
CourseManager validated a candidate (the course's timer saw raceStarted = true)
If no candidates match or validation fails 3 times, CourseManager activates "Lap Anything" mode.
Direction Detection
When sector lines are configured, the library automatically detects whether you're driving the course forward or in reverse:
- After the start/finish line is first crossed, the library collects the timestamps of the physical sector 2 and sector 3 crossings during each lap
- At the next start/finish crossing, if both sector lines were crossed that lap, their temporal order resolves the direction: sector 2 before sector 3 = forward (
DIR_FORWARD), sector 3 before sector 2 = reverse (DIR_REVERSE)
- Laps where only one sector line was seen (missed zone, low GPS rate) are discarded and detection retries on the next lap; within a lap, the latest crossing of each sector line wins, so an early GPS-glitch phantom gets overwritten by the real crossing
- Once resolved, direction is locked and sector lines are remapped internally so timing stays correct
int getDirection() const;
bool isDirectionResolved() const;
Examples
- WokWi Emulator (basic oled example)
- Includes 4 laps of data
- Custom Chip included in repo ./wokwi/
- in-browser demo does not include/support uBlox configuration commands
- Basic Oled Example
- Shows all basic functionality, along with a simple display literally showing all basic functionality.
- Assumes adafruit compatible authentic ublox GPS
- If not authentic, sending configuration commands might fail but receiving data should probably still work.
- Originally for Seed NRF52840
- Might need to remove/change LED_GREEN blinker
- 128x64 i2c 110X display.
- Display is NOT PRETTY, it is an EXAMPLE / DEBUG SCREEN.
- Too tired to make serial only logger, but you can very easily remove it.
- Borb load screen
- Sector Timing Example
- Demonstrates sector split timing functionality
- Shows how to configure sector 2 and sector 3 lines
- Displays best sector times and optimal lap calculation
- Tracks which lap achieved each best sector time
- Serial output only (easy to integrate into existing projects)
- Sprint Timing Example
- Point-to-point "sprint mode" (autocross / hillclimb): separate start and finish lines, runs instead of laps
- Synthetic 500m course, no GPS hardware required, Serial output only
- Demonstrates run cancel/restart, ignored finish crossings, and optional splits
- Real Track Data Debug
- REQUIRES A LOT OF RAM TO STORE SAMPLE DATA
- Serial Only No GPS Required
- Simple test using data recorded at Orlando Kart Center
- MyLaps : 1:08:807 (magnetic/official)
- DovesTimer: 1:08:748 (LINEAR)
- DovesTimer: 1:08.745 (CATMULLROM)
Memory Usage
A CourseManager instance is ~29 KB on a 64-bit-double MCU, dominated by a fixed by-value array of MAX_COURSES (8) course timers at ~3.6 KB each. That memory is statically allocated for all 8 slots regardless of how many courses you configure, and it is never released: pruneInactiveCourses() (and the automatic deactivation when "Lap Anything" activates) stop deactivated timers from being processed — saving CPU per GPS fix — but free zero bytes. CourseManager therefore does not fit on AVR-class boards (Mega: 8 KB SRAM); it needs an MCU like the XIAO nRF52840 (256 KB).
If using DovesLapTimer standalone (no CourseManager), memory usage is much lower — a single timer instance is ~3.6 KB, mostly its crossing point buffer (100 entries on boards with >3KB RAM, 25 entries otherwise).
A SprintTimer instance is ~11.6 KB on 32-bit targets: unlike DovesLapTimer's shared buffer, every line (start, finish, and the two optional splits) owns an independent ~2.9 KB crossing buffer so all lines stay hot on every fix and zones may overlap. It is meant to be instantiated once — not pooled 8-wide like CourseManager's timer array — and does not fit small AVRs.
License
This library is [licensed](LICENSE) under the GNU General Public License v3.0.
Dependencies
- Auto-Included/Required
- Nice To Use / Used in examples
More features?
If you want more features, go and download this dudes app RaceChrono (available on both iPhone and Android), and send the data to your phone, or log it and send it after the race.
RaceChrono is not a sponsor or affiliated, I just really enjoy the app, but don't like keeping my phone in a go-kart. If you are looking for a "proper racing solution", you can log canbus data through the NRF52840, to the RaceChrono app. This will allow you to use a much more affordable GPS module, and have a fully fledged data logger. You can also send this data back to another(or the same) BLE device to create custom digital gauge clusters!
Paid version required for DIY loggers and importing NMEA logs, worth every penny.
RaceChrono Website | RaceChrono iPhone | RaceChrono Android
Source code for: can-bus logger/gps logger/digital gauges https://github.com/aollin/racechrono-ble-diy-device
Pairs wonderfully with the previously mentioned Seed NRF52840
Update
Full datalogger + STL case files dropped over at https://github.com/TheAngryRaven/DovesDataLogger
Dataviewer: https://github.com/TheAngryRaven/DovesDataViewer