From 0c373a259391fe30633bbb2f73617eae28dbd047 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:23:40 +0000 Subject: [PATCH 1/7] refactor: extract CrossingEngine from DovesLapTimer (split-timing portability TODO) Move the in-zone ring buffer, zone state machine (_detectLineCrossing), and crossing interpolation verbatim into a reusable CrossingEngine class; line geometry (side-of-line, segment distance, hypotenuse zone test) becomes GeoMath free functions. DovesLapTimer delegates through thin wrappers - public API, memory layout, and numeric behavior unchanged (Layer-2 suites and all four Layer-3 NMEA replay goldens pass unmodified). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- src/CrossingEngine.cpp | 369 +++++++++ src/CrossingEngine.h | 196 +++++ src/DovesLapTimer.cpp | 1687 ++++++++++++++++------------------------ src/DovesLapTimer.h | 136 +--- src/GeoMath.h | 74 ++ test/Makefile | 2 + 6 files changed, 1319 insertions(+), 1145 deletions(-) create mode 100644 src/CrossingEngine.cpp create mode 100644 src/CrossingEngine.h diff --git a/src/CrossingEngine.cpp b/src/CrossingEngine.cpp new file mode 100644 index 0000000..d91768d --- /dev/null +++ b/src/CrossingEngine.cpp @@ -0,0 +1,369 @@ +/** + * CrossingEngine - the reusable line-crossing detection core. + * + * The detection + interpolation bodies are extracted verbatim from + * DovesLapTimer (_detectLineCrossing / interpolateCrossingPoint / + * interpolateWeight / catmullRom) — the Layer-2 synthetic tests and the + * Layer-3 NMEA replay goldens pin the numeric behavior across the move. + */ + +#include "CrossingEngine.h" + +#define debugln debug_println +#define debug debug_print + +CrossingEngine::CrossingEngine(double crossingThresholdMeters, Stream *debugSerial) { + this->crossingThresholdMeters = crossingThresholdMeters; + _serial = debugSerial; + memset(crossingPointBuffer, 0, sizeof(crossingPointBuffer)); +} + +void CrossingEngine::setThreshold(double meters) { + crossingThresholdMeters = meters; +} + +void CrossingEngine::setDebugSerial(Stream *debugSerial) { + _serial = debugSerial; +} + +void CrossingEngine::setForceLinear(bool linear) { + forceLinear = linear; +} + +bool CrossingEngine::getForceLinear() const { + return forceLinear; +} + +unsigned int CrossingEngine::getRejectedCrossingCount() const { + return rejectedCrossingCount; +} + +void CrossingEngine::reset() { + rejectedCrossingCount = 0; + crossingPointBufferIndex = 0; + crossingPointBufferFull = false; + memset(crossingPointBuffer, 0, sizeof(crossingPointBuffer)); +} + +/** + * Crossing-line detection: hypotenuse threshold method. + * + * Using the width of the crossing line and "crossingThresholdMeters" to form + * a right triangle, the calculated hypotenuse is the effective proximity + * threshold. We measure from the driver to each crossing point; if either + * distance exceeds the hypotenuse we are not in the zone. + * + * Earlier experiments used acute/obtuse-triangle detection (see + * DovesLapTimer::isObtuseTriangle), which worked on OKC but felt brittle. + * Hypotenuse-based threshold has been more reliable across short and long + * track configurations. + */ +LineDetectResult CrossingEngine::detect( + double currentLat, double currentLng, + unsigned long currentTimeMs, + float currentOdometer, + float currentSpeedKmh, + const crossingPointBufferEntry *prevFix, + double pointALat, double pointALng, + double pointBLat, double pointBLng, + bool& crossingFlag, + int lineLabel, + double& outLat, double& outLng, + unsigned long& outTime, + double& outOdometer) { + double distToLine = INFINITY; + + if (crossingFlag || geoInsideLineThreshold(crossingThresholdMeters, currentLat, currentLng, pointALat, pointALng, pointBLat, pointBLng)) { + distToLine = geoPointLineSegmentDistance(currentLat, currentLng, pointALat, pointALng, pointBLat, pointBLng); + } + + if (crossingFlag) { + if (distToLine > crossingThresholdMeters + 1) { + // Exited the zone — interpolate, reset buffer, report completion. + debug(F("Line ")); + debug(lineLabel); + debugln(F(" crossed, calculating...")); + crossingFlag = false; + + // Include the exiting fix itself: at low GPS rates (1-5 Hz) the line + // is often crossed between the last in-zone fix and this one, and + // without it the buffer holds no straddling pair at all. At high + // rates it sits beyond the (earlier) genuine pair and is ignored. + crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; + crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; + crossingPointBuffer[crossingPointBufferIndex].time = currentTimeMs; + crossingPointBuffer[crossingPointBufferIndex].odometer = currentOdometer; + crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedKmh; + crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; + if (crossingPointBufferIndex == 0) crossingPointBufferFull = true; + + outLat = 0.0; outLng = 0.0; outOdometer = 0.0; outTime = 0; + bool validCrossing = interpolateCrossingPoint(outLat, outLng, outTime, outOdometer, + pointALat, pointALng, pointBLat, pointBLng); + + if (validCrossing) { + debug(F(" crossingLat: ")); debugln(outLat, 6); + debug(F(" crossingLng: ")); debugln(outLng, 6); + debug(F(" crossingOdometer: ")); debugln(outOdometer); + debug(F(" crossingTime: ")); debugln(outTime); + } else { + // Surface the failure — debug serial is usually not connected on + // track, and a silently swallowed crossing looks like a dead lap + // counter to the user. + rejectedCrossingCount++; + } + + crossingPointBufferIndex = 0; + crossingPointBufferFull = false; + memset(crossingPointBuffer, 0, sizeof(crossingPointBuffer)); + // An invalid interpolation (no straddling pair found, or an incoherent + // one) is reported as NONE so callers never consume garbage out-params. + // A legitimate crossing at exactly 00:00:00.000 (outTime == 0) is valid. + return validCrossing ? LINE_DETECT_COMPLETED : LINE_DETECT_NONE; + } + + // Still in zone — buffer this fix. + crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; + crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; + crossingPointBuffer[crossingPointBufferIndex].time = currentTimeMs; + crossingPointBuffer[crossingPointBufferIndex].odometer = currentOdometer; + crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedKmh; + crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; + if (crossingPointBufferIndex == 0) crossingPointBufferFull = true; + + debug(F("Line ")); + debug(lineLabel); + debug(F(" distToLine: ")); + debug(distToLine); + debug(F(" | buffering index[")); + debug(crossingPointBufferIndex); + debug(F("] full[")); + debugln(crossingPointBufferFull ? F("True") : F("False")); + return LINE_DETECT_IN_ZONE; + } + + if (distToLine < crossingThresholdMeters) { + // Entering the zone for the first time this approach. + debug(F("Entering line ")); + debug(lineLabel); + debugln(F(" crossing zone")); + crossingFlag = true; + + // Insert previous GPS fix as pre-crossing point — gives Catmull-Rom its p0. + if (prevFix != NULL) { + crossingPointBuffer[crossingPointBufferIndex] = *prevFix; + crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; + } + + // Capture current point. + crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; + crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; + crossingPointBuffer[crossingPointBufferIndex].time = currentTimeMs; + crossingPointBuffer[crossingPointBufferIndex].odometer = currentOdometer; + crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedKmh; + crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; + return LINE_DETECT_IN_ZONE; + } + + return LINE_DETECT_NONE; +} + +double CrossingEngine::interpolateWeight(double distA, double distB, float speedA, float speedB) { + // Guard against division by zero - if either speed is essentially zero, + // fall back to pure distance-based weighting + const float minSpeed = 0.001f; // ~0.0005 knots, effectively stationary + if (speedA < minSpeed || speedB < minSpeed) { + // Pure distance-based interpolation: weight is proportion of distA to total + double totalDist = distA + distB; + if (totalDist < 1e-9) { + return 0.5; // Both distances zero, use midpoint + } + return distA / totalDist; + } + + double weightedDistA = distA / speedA; + double weightedDistB = distB / speedB; + + // Guard against sum being zero (shouldn't happen with above checks, but defensive) + double weightedSum = weightedDistA + weightedDistB; + if (weightedSum < 1e-9) { + return 0.5; + } + + return weightedDistA / weightedSum; +} + +double CrossingEngine::catmullRom(double p0, double p1, double p2, double p3, double t) { + // Calculate t^2 and t^3 + double t2 = t * t; + double t3 = t2 * t; + + // Calculate the Catmull-Rom coefficients a, b, c, and d + double a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3; + double b = p0 - 2.5 * p1 + 2 * p2 - 0.5 * p3; + double c = -0.5 * p0 + 0.5 * p2; + double d = p1; + + // Calculate and return the interpolated value using the coefficients and powers of t + return a * t3 + b * t2 + c * t + d; +} + +bool CrossingEngine::interpolateCrossingPoint(double& crossingLat, double& crossingLng, unsigned long& crossingTime, double& crossingOdometer, double pointALat, double pointALng, double pointBLat, double pointBLng) { + int numPoints = crossingPointBufferFull ? crossingPointBufferSize : crossingPointBufferIndex; + + // The buffer is circular: once it wraps (e.g. a kart parked on the grid + // inside the zone), physical index order no longer equals chronological + // order, and the seam pair (newest next to oldest) would masquerade as a + // crossing. Walk entries in chronological order instead. + const int oldestIndex = crossingPointBufferFull ? crossingPointBufferIndex : 0; + auto entryAt = [&](int k) -> const crossingPointBufferEntry& { + return crossingPointBuffer[(oldestIndex + k) % crossingPointBufferSize]; + }; + + // Find the first pair of consecutive buffer points on opposite sides of the crossing line. + // In a normal pass there is exactly one such pair - the two GPS fixes that straddle the line. + int crossingIndexA = -1; + int crossingIndexB = -1; + double crossingSumDistances = INFINITY; + + for (int i = 0; i < numPoints - 1; i++) { + double distA = geoPointLineSegmentDistance(entryAt(i).lat, entryAt(i).lng, pointALat, pointALng, pointBLat, pointBLng); + double distB = geoPointLineSegmentDistance(entryAt(i + 1).lat, entryAt(i + 1).lng, pointALat, pointALng, pointBLat, pointBLng); + + int sideA = geoPointOnSideOfLine(entryAt(i).lat, entryAt(i).lng, pointALat, pointALng, pointBLat, pointBLng); + int sideB = geoPointOnSideOfLine(entryAt(i + 1).lat, entryAt(i + 1).lng, pointALat, pointALng, pointBLat, pointBLng); + + debug(F("i: ")); + debug(i); + debug(F(" : distA: ")); + debug(distA); + debug(F(" : sideA: ")); + debug(sideA); + debug(F(" : distB: ")); + debug(distB); + debug(F(" sideB: ")); + debug(sideB); + debug(F(" sum: ")); + debugln(distA + distB, 2); + + // First pair on opposite sides of the line = the crossing pair + if (sideA != sideB) { + crossingIndexA = i; + crossingIndexB = i + 1; + crossingSumDistances = distA + distB; + debug(F("crossing pair found, sum: ")); + debugln(crossingSumDistances, 2); + break; + } + } + debug(F("crossingSumDistances: ")); + debugln(crossingSumDistances); + + if (crossingIndexA == -1 || crossingIndexB == -1) { + debugln(F("~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~")); + return false; + } + + { + const crossingPointBufferEntry& entryA = entryAt(crossingIndexA); + const crossingPointBufferEntry& entryB = entryAt(crossingIndexB); + + // Validate: the crossing pair must hug the line *relative to the GPS + // sample spacing*, not in absolute meters. The old absolute check + // (sum < crossingThresholdMeters) conflated zone size with sample + // density: at 1 Hz / 70 km/h consecutive fixes are ~19 m apart, every + // genuine crossing failed the 7 m bound, and the lap counter silently + // never incremented. For a pair genuinely straddling the line, the sum + // of the two distances can never exceed the pair's own spacing, so a + // spacing-scaled bound stays tight at any sample rate. + double pairSpacing = geoHaversine(entryA.lat, entryA.lng, entryB.lat, entryB.lng); + double allowedSum = CROSSING_PAIR_SPACING_FACTOR * pairSpacing; + if (allowedSum < crossingThresholdMeters) { + allowedSum = crossingThresholdMeters; + } + if (crossingSumDistances > allowedSum) { + debugln(F("~~~ INVALID CROSSING: pair too far from line ~~~")); + return false; + } + + // Time deltas are computed in double, normalized across the UTC midnight + // wrap, and sanity-clamped BEFORE any conversion back to unsigned long — + // an out-of-range double-to-unsigned conversion is undefined behavior. + double deltaTime = (double)entryB.time - (double)entryA.time; + if (deltaTime < 0) { + deltaTime += (double)DOVES_MILLIS_PER_DAY; + } + if (deltaTime > (double)CROSSING_MAX_FIX_GAP_MS) { + // The straddling pair is not temporally coherent (GPS time step during + // re-acquisition, or stale buffer contents) — refuse to interpolate. + debugln(F("~~~ INVALID CROSSING: fix gap too large ~~~")); + return false; + } + + // Compute the interpolation factor (t) from distances and speeds at the crossing pair + double distA = geoPointLineSegmentDistance(entryA.lat, entryA.lng, pointALat, pointALng, pointBLat, pointBLng); + double distB = geoPointLineSegmentDistance(entryB.lat, entryB.lng, pointALat, pointALng, pointBLat, pointBLng); + double t = interpolateWeight(distA, distB, entryA.speedKmh, entryB.speedKmh); + + // Geometric sanity: the interpolated point must land on the crossing + // line *segment* (within the threshold), not on its infinite extension + // — pointOnSideOfLine treats the line as infinite, so a pair straddling + // the extension beyond an endpoint would otherwise slip through now + // that the sum bound scales with fix spacing. + double linearLat = entryA.lat + t * (entryB.lat - entryA.lat); + double linearLng = entryA.lng + t * (entryB.lng - entryA.lng); + if (geoPointLineSegmentDistance(linearLat, linearLng, pointALat, pointALng, pointBLat, pointBLng) > crossingThresholdMeters) { + debugln(F("~~~ INVALID CROSSING: crossing point off the line segment ~~~")); + return false; + } + + debugln(F("~~~ VALID CROSSING ~~~")); + + // Time and odometer are always interpolated linearly (monotonic values that + // should not overshoot), regardless of the interpolation mode for position. + double deltaOdometer = entryB.odometer - entryA.odometer; + crossingOdometer = entryA.odometer + t * deltaOdometer; + double crossingTimeMs = (double)entryA.time + t * deltaTime; + if (crossingTimeMs >= (double)DOVES_MILLIS_PER_DAY) { + crossingTimeMs -= (double)DOVES_MILLIS_PER_DAY; // zone straddled midnight + } + crossingTime = (unsigned long)crossingTimeMs; + + if (forceLinear) { + crossingLat = linearLat; + crossingLng = linearLng; + } else { + // Catmull-Rom spline interpolation for position only. + // Requires 4 control points: p0 (before A), p1 (A), p2 (B), p3 (after B) + bool canUseCatmullRom = (crossingIndexA >= 1) && (crossingIndexB <= numPoints - 2); + + if (!canUseCatmullRom) { + // Not enough points for Catmull-Rom, fall back to linear + debugln(F("Catmull-Rom: insufficient control points, using linear fallback")); + crossingLat = linearLat; + crossingLng = linearLng; + } else { + // We have 4 valid control points for Catmull-Rom + int index0 = crossingIndexA - 1; + int index1 = crossingIndexA; + int index2 = crossingIndexB; + int index3 = crossingIndexB + 1; + + debugln(F("Catmull-Rom: using spline interpolation")); + debug(F(" indices: ")); + debug(index0); + debug(F(", ")); + debug(index1); + debug(F(", ")); + debug(index2); + debug(F(", ")); + debugln(index3); + + // Catmull-Rom for lat/lng only - spline smoothing helps with curved paths + crossingLat = catmullRom(entryAt(index0).lat, entryAt(index1).lat, entryAt(index2).lat, entryAt(index3).lat, t); + crossingLng = catmullRom(entryAt(index0).lng, entryAt(index1).lng, entryAt(index2).lng, entryAt(index3).lng, t); + } + } + return true; + } +} diff --git a/src/CrossingEngine.h b/src/CrossingEngine.h new file mode 100644 index 0000000..b73461e --- /dev/null +++ b/src/CrossingEngine.h @@ -0,0 +1,196 @@ +/** + * CrossingEngine - the reusable line-crossing detection core. + * + * Owns the in-zone GPS ring buffer and the crossing interpolation math that + * previously lived inside DovesLapTimer (_detectLineCrossing + + * interpolateCrossingPoint). Extracted so point-to-point timers + * (SprintTimer) can reuse the exact same battle-tested pipeline — including + * with independent per-line buffers — without duplicating the math. + * + * The engine is deliberately stateless about *which* line it is checking: + * line endpoints and the per-line in-zone flag are passed per call, exactly + * like the old private helper. One engine can therefore be shared across + * several lines (DovesLapTimer shares one across S/F + S2 + S3, with the + * caller enforcing only-one-line-crossing-at-a-time), or dedicated to a + * single line (SprintTimer gives every line its own engine, so start and + * finish zones may overlap without contention). + * + * See DETECTION.md for the algorithm itself. + */ + +#ifndef _DOVES_CROSSING_ENGINE_H +#define _DOVES_CROSSING_ENGINE_H + +#include +#include "GeoMath.h" + +#define CROSSING_LINE_SIDE_A -1 +#define CROSSING_LINE_SIDE_EXACT 0 +#define CROSSING_LINE_SIDE_B 1 + +// GPS time base: milliseconds since UTC midnight, wraps 86,399,999 -> 0 +#define DOVES_MILLIS_PER_DAY 86400000UL + +// Max believable gap between the two consecutive buffered fixes that straddle +// a crossing line. A larger gap means the GPS time base stepped (e.g. u-blox +// re-acquisition) and the pair cannot be interpolated coherently. +#define CROSSING_MAX_FIX_GAP_MS 10000UL + +// The straddling pair's summed distance to the line is validated against +// max(crossingThresholdMeters, factor * pair spacing) so that low-rate GPS +// (widely spaced fixes) doesn't fail validation purely on sample density. +#define CROSSING_PAIR_SPACING_FACTOR 1.25 + +/** + * @brief Elapsed milliseconds between two milliseconds-since-midnight timestamps. + * + * Normalizes across the UTC midnight wrap (86,399,999 -> 0), which happens + * mid-evening across the Americas. Without this, a lap straddling midnight + * underflows unsigned subtraction into a ~4.29-billion-ms "lap time". + * + * @param startMs Earlier timestamp, in ms since midnight. + * @param endMs Later timestamp, in ms since midnight. + * @return Elapsed time in ms, wrap-normalized. + */ +static inline unsigned long timeSinceMidnightDelta(unsigned long startMs, unsigned long endMs) { + if (endMs < startMs) { + endMs += DOVES_MILLIS_PER_DAY; + } + return endMs - startMs; +} + +// Outcome of a single CrossingEngine::detect pass. +enum LineDetectResult { + LINE_DETECT_NONE, // not in / near the zone, or exited with an invalid interpolation + LINE_DETECT_IN_ZONE, // inside the zone (entered this fix or continuing) + LINE_DETECT_COMPLETED, // exited the zone this fix — interpolated outputs valid +}; + +struct crossingPointBufferEntry { + double lat; // latitude + double lng; // longitude + unsigned long time; // current time in milliseconds + float odometer; // time traveled since device start and this entry + float speedKmh; // speed in kmph +}; + +class CrossingEngine { +public: + CrossingEngine(double crossingThresholdMeters = 7, Stream *debugSerial = NULL); + + /** + * @brief Sets the crossing zone threshold, in meters. + */ + void setThreshold(double meters); + /** + * @brief Attaches / detaches a debug output stream. + */ + void setDebugSerial(Stream *debugSerial); + /** + * @brief Selects linear (true, default) or Catmull-Rom (false) position + * interpolation. Time and odometer are always interpolated linearly. + */ + void setForceLinear(bool linear); + bool getForceLinear() const; + /** + * @brief Number of crossing-zone exits whose interpolation was rejected. + * + * A rejection means the GPS data inside the zone never produced a usable + * straddling pair (no side change, pair too far from the line, incoherent + * timestamps, or crossing landing off the line segment). + */ + unsigned int getRejectedCrossingCount() const; + /** + * @brief Clears the buffer, the rejected-crossing counter, and any + * in-flight zone state. Does NOT clear the configured threshold/mode. + */ + void reset(); + + /** + * @brief The crossing-zone state machine (formerly + * DovesLapTimer::_detectLineCrossing). Detects entry, buffers GPS fixes + * inside the zone, and interpolates the exact crossing point on exit. + * + * The caller owns the per-line in-zone flag and the vehicle state + * (current fix + previous-fix snapshot) — the engine owns only the + * buffer and the math. + * + * @param currentLat / currentLng Current GPS position. + * @param currentTimeMs Milliseconds since UTC midnight for this fix. + * @param currentOdometer Total odometer reading at this fix, meters. + * @param currentSpeedKmh Speed at this fix, km/h. + * @param prevFix Previous fix snapshot (Catmull-Rom pre-crossing point), + * or NULL if no previous fix exists yet. + * @param pointALat / pointALng Line endpoint A. + * @param pointBLat / pointBLng Line endpoint B. + * @param crossingFlag Reference to the caller-owned per-line in-zone flag. + * @param lineLabel Debug label: 0 = start(/finish), 1 = sprint finish, + * 2 / 3 = sector. + * @param[out] outLat / outLng / outTime / outOdometer Interpolated + * crossing point — only valid when the return is LINE_DETECT_COMPLETED. + * @return LINE_DETECT_NONE / IN_ZONE / COMPLETED. See enum docs. + */ + LineDetectResult detect( + double currentLat, double currentLng, + unsigned long currentTimeMs, + float currentOdometer, + float currentSpeedKmh, + const crossingPointBufferEntry *prevFix, + double pointALat, double pointALng, + double pointBLat, double pointBLng, + bool& crossingFlag, + int lineLabel, + double& outLat, double& outLng, + unsigned long& outTime, + double& outOdometer); + +private: + template + void debug_print(Args&&... args) { + if(_serial) { _serial->print(std::forward(args)...); } + } + template + void debug_println(Args&&... args) { + if(_serial) { _serial->println(std::forward(args)...); } + } + + /** + * @brief Finds the straddling fix pair in the buffer and interpolates the + * crossing point (chronologically unwinding the ring if it wrapped). + * @return True if a valid crossing was found and the out-params populated. + */ + bool interpolateCrossingPoint(double& crossingLat, double& crossingLng, unsigned long& crossingTime, double& crossingOdometer, double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Computes the interpolation weight based on distances and speeds. + */ + double interpolateWeight(double distA, double distB, float speedA, float speedB); + /** + * @brief Catmull-Rom spline interpolation between two points. + */ + double catmullRom(double p0, double p1, double p2, double p3, double t); + + Stream *_serial; + double crossingThresholdMeters; + bool forceLinear = true; + unsigned int rejectedCrossingCount = 0; + + // Buffer sizing: AVR exposes RAMEND/RAMSTART so we can tell a Mega (8KB) from a Uno (2KB). + // On modern 32-bit cores (nRF52, ESP32, SAMD, RP2040, etc.) those macros are undefined + // and would silently evaluate to 0 - defaulting such targets to the small buffer would + // defeat the whole point of the hotfix. Assume anyone not on classic AVR has plenty of RAM. + #if defined(RAMEND) && defined(RAMSTART) + #if ((RAMEND - RAMSTART) > 3000) + static const int crossingPointBufferSize = 100; + #else + static const int crossingPointBufferSize = 25; + #endif + #else + static const int crossingPointBufferSize = 100; + #endif + + crossingPointBufferEntry crossingPointBuffer[crossingPointBufferSize]; + int crossingPointBufferIndex = 0; + bool crossingPointBufferFull = false; +}; + +#endif diff --git a/src/DovesLapTimer.cpp b/src/DovesLapTimer.cpp index a1fef35..004790a 100644 --- a/src/DovesLapTimer.cpp +++ b/src/DovesLapTimer.cpp @@ -1,1024 +1,665 @@ -/** - * GPS-based lap timing library for go-kart and racing applications. - * This library does NOT interface with your GPS, simply feed it data and check the state. - * Supports start/finish line detection, 3-sector split timing, pace comparison, and distance tracking. - * - * The development of this library has been overseen, and all documentation has been generated using chatGPT4. - */ - -#include "DovesLapTimer.h" -#include "GeoMath.h" - -#define debugln debug_println -#define debug debug_print - -DovesLapTimer::DovesLapTimer(double crossingThresholdMeters, Stream *debugSerial) { - this->crossingThresholdMeters = crossingThresholdMeters; - - if (debugSerial == NULL) { - _serial = nullptr; - } else { - _serial = debugSerial; - } -} - -int DovesLapTimer::loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots) { - // Reject invalid fixes before they can poison the odometer or timing state. - // NaN/Inf and (0,0) fixes are routine parser output during fix loss; a - // single one would otherwise stick in totalDistanceTraveled forever. - if (!geoCoordinatesValid(currentLat, currentLng)) { - debugln(F("Rejected invalid GPS coordinates")); - return -1; - } - // Altitude and speed are auxiliary — sanitize rather than drop the fix. - if (!geoIsFinite(currentAltitudeMeters)) { - currentAltitudeMeters = positionPrevAlt; - } - if (!geoIsFinite(currentSpeedKnots) || currentSpeedKnots < 0) { - currentSpeedKnots = 0; - } - - // Update Odometer - only calculate distance if we have a previous position - if (firstPositionReceived) { - double jumpDistance = this->haversine(positionPrevLat, positionPrevLng, currentLat, currentLng); - if (jumpDistance > GPS_MAX_PLAUSIBLE_JUMP_METERS) { - consecutiveJumpCount++; - if (consecutiveJumpCount < GPS_JUMP_REACCEPT_COUNT) { - // Almost certainly a teleport glitch — drop the fix entirely. - debugln(F("Rejected implausible GPS jump")); - return -1; - } - // Several consecutive far fixes: the new position is real (signal - // re-acquisition / device moved). Re-seed without crediting the gap - // to the odometer. - consecutiveJumpCount = 0; - } else { - consecutiveJumpCount = 0; - // TODO: I think alt is messing up, investigate more... maybe flag? - double distanceTraveledSinceLastUpdate = this->haversine3D( - positionPrevLat, - positionPrevLng, - positionPrevAlt, - currentLat, - currentLng, - currentAltitudeMeters - ); - totalDistanceTraveled += distanceTraveledSinceLastUpdate; - } - } else { - firstPositionReceived = true; - } - positionPrevLat = currentLat; - positionPrevLng = currentLng; - positionPrevAlt = currentAltitudeMeters; - - // update current speed - currentSpeedkmh = currentSpeedKnots * GEOMATH_KNOTS_TO_KMH; - - // run calculations for each crossing-line - // Only one line can be "crossing" at a time due to shared buffer. - // Mutual exclusion: skip start/finish if a sector crossing is active, and vice versa. - - bool nearAnyLine = false; - - // Check start/finish line - requires a configured line (otherwise the - // endpoint members would be meaningless zeros), and skip if a sector - // crossing is already in progress - if (startFinishLineConfigured && (crossing || (!crossingSector2 && !crossingSector3))) { - if (this->checkStartFinish(currentLat, currentLng)) { - nearAnyLine = true; - } - } - - // Check sector lines if configured and not currently crossing start/finish - if (areSectorLinesConfigured() && !crossing) { - // Check sector 2 line - if (sector2LineConfigured && !crossingSector2 && !crossingSector3) { - if (checkSectorLine(currentLat, currentLng, - sector2PointALat, sector2PointALng, - sector2PointBLat, sector2PointBLng, - crossingSector2, 2)) { - nearAnyLine = true; - } - } else if (crossingSector2) { - // Continue processing sector 2 crossing - if (checkSectorLine(currentLat, currentLng, - sector2PointALat, sector2PointALng, - sector2PointBLat, sector2PointBLng, - crossingSector2, 2)) { - nearAnyLine = true; - } - } - - // Check sector 3 line - if (sector3LineConfigured && !crossingSector2 && !crossingSector3) { - if (checkSectorLine(currentLat, currentLng, - sector3PointALat, sector3PointALng, - sector3PointBLat, sector3PointBLng, - crossingSector3, 3)) { - nearAnyLine = true; - } - } else if (crossingSector3) { - // Continue processing sector 3 crossing - if (checkSectorLine(currentLat, currentLng, - sector3PointALat, sector3PointALng, - sector3PointBLat, sector3PointBLng, - crossingSector3, 3)) { - nearAnyLine = true; - } - } - } - - // Save current fix as previous for next iteration's Catmull-Rom pre-crossing point - prevFixLat = currentLat; - prevFixLng = currentLng; - prevFixTime = millisecondsSinceMidnight; - prevFixOdometer = totalDistanceTraveled; - prevFixSpeedKmh = currentSpeedkmh; - hasPrevFix = true; - - return nearAnyLine ? 0 : -1; -} - -// TODO: update function to be a bit more portable to allow for split timing -bool DovesLapTimer::checkStartFinish(double currentLat, double currentLng) { - double cLat = 0.0, cLng = 0.0, cOdo = 0.0; - unsigned long cTime = 0; - - LineDetectResult ev = _detectLineCrossing( - currentLat, currentLng, - startFinishPointALat, startFinishPointALng, - startFinishPointBLat, startFinishPointBLng, - crossing, - 0, - cLat, cLng, cTime, cOdo); - - if (ev == LINE_DETECT_COMPLETED) { - if (raceStarted) { - laps++; - unsigned long lapTime = timeSinceMidnightDelta(currentLapStartTime, cTime); - double lapDistance = cOdo - currentLapOdometerStart; - - debug(F("Lap Finish Time: ")); - debug(lapTime); - debug(F(" : ")); - debugln((double)(lapTime / 1000.0), 3); - - lastLapTime = lapTime; - lastLapDistance = lapDistance; - if (bestLapTime <= 0 || lastLapTime < bestLapTime) { - bestLapTime = lastLapTime; - bestLapDistance = lastLapDistance; - bestLapNumber = laps; - } - } else { - raceStarted = true; - debugln(F("Race Started")); - } - currentLapStartTime = cTime; - currentLapOdometerStart = cOdo; - handleLineCrossing(cTime, 0); - } - - return ev == LINE_DETECT_IN_ZONE; -} - -/** - * Crossing-line detection: hypotenuse threshold method. - * - * Using the width of the crossing line and "crossingThresholdMeters" to form - * a right triangle, the calculated hypotenuse is the effective proximity - * threshold. We measure from the driver to each crossing point; if either - * distance exceeds the hypotenuse we are not in the zone. - * - * Earlier experiments used acute/obtuse-triangle detection (see - * isObtuseTriangle), which worked on OKC but felt brittle. Hypotenuse-based - * threshold has been more reliable across short and long track configurations. - */ -LineDetectResult DovesLapTimer::_detectLineCrossing( - double currentLat, double currentLng, - double pointALat, double pointALng, - double pointBLat, double pointBLng, - bool& crossingFlag, - int lineLabel, - double& outLat, double& outLng, - unsigned long& outTime, - double& outOdometer) { - double distToLine = INFINITY; - - if (crossingFlag || insideLineThreshold(currentLat, currentLng, pointALat, pointALng, pointBLat, pointBLng)) { - distToLine = pointLineSegmentDistance(currentLat, currentLng, pointALat, pointALng, pointBLat, pointBLng); - } - - if (crossingFlag) { - if (distToLine > crossingThresholdMeters + 1) { - // Exited the zone — interpolate, reset buffer, report completion. - debug(F("Line ")); - debug(lineLabel); - debugln(F(" crossed, calculating...")); - crossingFlag = false; - - // Include the exiting fix itself: at low GPS rates (1-5 Hz) the line - // is often crossed between the last in-zone fix and this one, and - // without it the buffer holds no straddling pair at all. At high - // rates it sits beyond the (earlier) genuine pair and is ignored. - crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; - crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; - crossingPointBuffer[crossingPointBufferIndex].time = millisecondsSinceMidnight; - crossingPointBuffer[crossingPointBufferIndex].odometer = totalDistanceTraveled; - crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedkmh; - crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; - if (crossingPointBufferIndex == 0) crossingPointBufferFull = true; - - outLat = 0.0; outLng = 0.0; outOdometer = 0.0; outTime = 0; - bool validCrossing = interpolateCrossingPoint(outLat, outLng, outTime, outOdometer, - pointALat, pointALng, pointBLat, pointBLng); - - if (validCrossing) { - debug(F(" crossingLat: ")); debugln(outLat, 6); - debug(F(" crossingLng: ")); debugln(outLng, 6); - debug(F(" crossingOdometer: ")); debugln(outOdometer); - debug(F(" crossingTime: ")); debugln(outTime); - } else { - // Surface the failure — debug serial is usually not connected on - // track, and a silently swallowed crossing looks like a dead lap - // counter to the user. - rejectedCrossingCount++; - } - - crossingPointBufferIndex = 0; - crossingPointBufferFull = false; - memset(crossingPointBuffer, 0, sizeof(crossingPointBuffer)); - // An invalid interpolation (no straddling pair found, or an incoherent - // one) is reported as NONE so callers never consume garbage out-params. - // A legitimate crossing at exactly 00:00:00.000 (outTime == 0) is valid. - return validCrossing ? LINE_DETECT_COMPLETED : LINE_DETECT_NONE; - } - - // Still in zone — buffer this fix. - crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; - crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; - crossingPointBuffer[crossingPointBufferIndex].time = millisecondsSinceMidnight; - crossingPointBuffer[crossingPointBufferIndex].odometer = totalDistanceTraveled; - crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedkmh; - crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; - if (crossingPointBufferIndex == 0) crossingPointBufferFull = true; - - debug(F("Line ")); - debug(lineLabel); - debug(F(" distToLine: ")); - debug(distToLine); - debug(F(" | buffering index[")); - debug(crossingPointBufferIndex); - debug(F("] full[")); - debugln(crossingPointBufferFull ? F("True") : F("False")); - return LINE_DETECT_IN_ZONE; - } - - if (distToLine < crossingThresholdMeters) { - // Entering the zone for the first time this approach. - debug(F("Entering line ")); - debug(lineLabel); - debugln(F(" crossing zone")); - crossingFlag = true; - - // Insert previous GPS fix as pre-crossing point — gives Catmull-Rom its p0. - if (hasPrevFix) { - crossingPointBuffer[crossingPointBufferIndex].lat = prevFixLat; - crossingPointBuffer[crossingPointBufferIndex].lng = prevFixLng; - crossingPointBuffer[crossingPointBufferIndex].time = prevFixTime; - crossingPointBuffer[crossingPointBufferIndex].odometer = prevFixOdometer; - crossingPointBuffer[crossingPointBufferIndex].speedKmh = prevFixSpeedKmh; - crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; - } - - // Capture current point. - crossingPointBuffer[crossingPointBufferIndex].lat = currentLat; - crossingPointBuffer[crossingPointBufferIndex].lng = currentLng; - crossingPointBuffer[crossingPointBufferIndex].time = millisecondsSinceMidnight; - crossingPointBuffer[crossingPointBufferIndex].odometer = totalDistanceTraveled; - crossingPointBuffer[crossingPointBufferIndex].speedKmh = currentSpeedkmh; - crossingPointBufferIndex = (crossingPointBufferIndex + 1) % crossingPointBufferSize; - return LINE_DETECT_IN_ZONE; - } - - return LINE_DETECT_NONE; -} - -bool DovesLapTimer::insideLineThreshold(double driverLat, double driverLon, double crossingPointALat, double crossingPointALon, double crossingPointBLat, double crossingPointBLon) { - // Calculate the distance from the driver to crossing points A and B - double driverLengthA = haversine(driverLat, driverLon, crossingPointALat, crossingPointALon); - double driverLengthB = haversine(driverLat, driverLon, crossingPointBLat, crossingPointBLon); - - // Calculate the distance between crossing points A and B - double crossingLineLength = haversine(crossingPointALat, crossingPointALon, crossingPointBLat, crossingPointBLon); - - // Calculate the maximum allowed distance from the driver to the line formed by crossing points A and B - double maxLineLength = sqrt(sq(crossingThresholdMeters) + sq(crossingLineLength)); - - // // dbg - // debug(F("crossingLineLength: ")); - // debug(crossingLineLength, 2); - // debug(F(" | maxLineLength: ")); - // debug(maxLineLength, 2); - // debug(F(" | driverLengthA: ")); - // debug(driverLengthA, 2); - // debug(F(" | driverLengthB: ")); - // debug(driverLengthB, 2); - // // dbg - - // Check if the driver is within the threshold distance from the line formed by crossing points A and B - return driverLengthA < maxLineLength && driverLengthB < maxLineLength; -} - -bool DovesLapTimer::isObtuseTriangle(double lat1, double lon1, double lat2, double lon2, double lat3, double lon3) { - // Get side lengths - double a = haversine(lat1, lon1, lat2, lon2); - double b = haversine(lat1, lon1, lat3, lon3); - double c = haversine(lat2, lon2, lat3, lon3); - - // Sort the sides in ascending order - if (a > b) std::swap(a, b); - if (b > c) std::swap(b, c); - if (a > b) std::swap(a, b); - - // listen... this has been a long debugging session - if ( a + b <= c ) { - // debugln(F("triangle: Impossible")); - return false; - } else { - TRITYPE discriminant = a * a + b * b - c * c; - if (discriminant < 0) { - // debugln(F("triangle: Obtuse")); - return true; - } else if (discriminant > 0) { - // debugln(F("triangle: Acute")); - return false; - } else { - // debugln(F("triangle: Right Angled")); - return false; - } - } -} - -int DovesLapTimer::pointOnSideOfLine(double driverLat, double driverLng, double pointALat, double pointALng, double pointBLat, double pointBLng) { - double lineDirectionX = pointBLat - pointALat; - double lineDirectionY = pointBLng - pointALng; - double driverToPointAX = driverLat - pointALat; - double driverToPointAY = driverLng - pointALng; - - double crossProduct = lineDirectionX * driverToPointAY - lineDirectionY * driverToPointAX; - - // todo: defines? - if (crossProduct > 0) { - return CROSSING_LINE_SIDE_A; // Driver is on one side of the line - } else if (crossProduct < 0) { - return CROSSING_LINE_SIDE_B; // Driver is on the other side of the line - } else { - return CROSSING_LINE_SIDE_EXACT; // Driver is exactly on the line - } -} - -double DovesLapTimer::pointLineSegmentDistance(double pointX, double pointY, double startX, double startY, double endX, double endY) { - double dx = endX - startX; - double dy = endY - startY; - double segmentLengthSquared = dx * dx + dy * dy; - - // Use epsilon comparison for floating-point near-zero check - // This handles degenerate line segments (start == end) - if (segmentLengthSquared < 1e-12) { - // The line segment is actually a point (or nearly so) - return haversine(pointX, pointY, startX, startY); - } - - double projectionScalar = ((pointX - startX) * dx + (pointY - startY) * dy) / segmentLengthSquared; - - double haversineStart = haversine(pointX, pointY, startX, startY); - double haversineEnd = haversine(pointX, pointY, endX, endY); - - if (projectionScalar < 0.0) { - // The projection of the point is outside the line segment, closest to the start point - return haversineStart; - } else if (projectionScalar > 1.0) { - // The projection of the point is outside the line segment, closest to the end point - return haversineEnd; - } - - // The projection of the point is within the line segment - double projectedX = startX + projectionScalar * dx; - double projectedY = startY + projectionScalar * dy; - return haversine(pointX, pointY, projectedX, projectedY); -} - -double DovesLapTimer::haversine(double lat1, double lon1, double lat2, double lon2) { - return geoHaversine(lat1, lon1, lat2, lon2); -} - -double DovesLapTimer::haversine3D(double prevLat, double prevLng, double prevAlt, double currentLat, double currentLng, double currentAlt) { - return geoHaversine3D(prevLat, prevLng, prevAlt, currentLat, currentLng, currentAlt); -} - -/////////// private functions - -double DovesLapTimer::interpolateWeight(double distA, double distB, float speedA, float speedB) { - // Guard against division by zero - if either speed is essentially zero, - // fall back to pure distance-based weighting - const float minSpeed = 0.001f; // ~0.0005 knots, effectively stationary - if (speedA < minSpeed || speedB < minSpeed) { - // Pure distance-based interpolation: weight is proportion of distA to total - double totalDist = distA + distB; - if (totalDist < 1e-9) { - return 0.5; // Both distances zero, use midpoint - } - return distA / totalDist; - } - - double weightedDistA = distA / speedA; - double weightedDistB = distB / speedB; - - // Guard against sum being zero (shouldn't happen with above checks, but defensive) - double weightedSum = weightedDistA + weightedDistB; - if (weightedSum < 1e-9) { - return 0.5; - } - - return weightedDistA / weightedSum; -} -double DovesLapTimer::catmullRom(double p0, double p1, double p2, double p3, double t) { - // Calculate t^2 and t^3 - double t2 = t * t; - double t3 = t2 * t; - - // Calculate the Catmull-Rom coefficients a, b, c, and d - double a = -0.5 * p0 + 1.5 * p1 - 1.5 * p2 + 0.5 * p3; - double b = p0 - 2.5 * p1 + 2 * p2 - 0.5 * p3; - double c = -0.5 * p0 + 0.5 * p2; - double d = p1; - - // Calculate and return the interpolated value using the coefficients and powers of t - return a * t3 + b * t2 + c * t + d; -} - -bool DovesLapTimer::interpolateCrossingPoint(double& crossingLat, double& crossingLng, unsigned long& crossingTime, double& crossingOdometer, double pointALat, double pointALng, double pointBLat, double pointBLng) { - int numPoints = crossingPointBufferFull ? crossingPointBufferSize : crossingPointBufferIndex; - - // The buffer is circular: once it wraps (e.g. a kart parked on the grid - // inside the zone), physical index order no longer equals chronological - // order, and the seam pair (newest next to oldest) would masquerade as a - // crossing. Walk entries in chronological order instead. - const int oldestIndex = crossingPointBufferFull ? crossingPointBufferIndex : 0; - auto entryAt = [&](int k) -> const crossingPointBufferEntry& { - return crossingPointBuffer[(oldestIndex + k) % crossingPointBufferSize]; - }; - - // Find the first pair of consecutive buffer points on opposite sides of the crossing line. - // In a normal pass there is exactly one such pair - the two GPS fixes that straddle the line. - int crossingIndexA = -1; - int crossingIndexB = -1; - double crossingSumDistances = INFINITY; - - for (int i = 0; i < numPoints - 1; i++) { - double distA = pointLineSegmentDistance(entryAt(i).lat, entryAt(i).lng, pointALat, pointALng, pointBLat, pointBLng); - double distB = pointLineSegmentDistance(entryAt(i + 1).lat, entryAt(i + 1).lng, pointALat, pointALng, pointBLat, pointBLng); - - int sideA = pointOnSideOfLine(entryAt(i).lat, entryAt(i).lng, pointALat, pointALng, pointBLat, pointBLng); - int sideB = pointOnSideOfLine(entryAt(i + 1).lat, entryAt(i + 1).lng, pointALat, pointALng, pointBLat, pointBLng); - - debug(F("i: ")); - debug(i); - debug(F(" : distA: ")); - debug(distA); - debug(F(" : sideA: ")); - debug(sideA); - debug(F(" : distB: ")); - debug(distB); - debug(F(" sideB: ")); - debug(sideB); - debug(F(" sum: ")); - debugln(distA + distB, 2); - - // First pair on opposite sides of the line = the crossing pair - if (sideA != sideB) { - crossingIndexA = i; - crossingIndexB = i + 1; - crossingSumDistances = distA + distB; - debug(F("crossing pair found, sum: ")); - debugln(crossingSumDistances, 2); - break; - } - } - debug(F("crossingSumDistances: ")); - debugln(crossingSumDistances); - - if (crossingIndexA == -1 || crossingIndexB == -1) { - debugln(F("~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~ INVALID CROSSING ~~~")); - return false; - } - - { - const crossingPointBufferEntry& entryA = entryAt(crossingIndexA); - const crossingPointBufferEntry& entryB = entryAt(crossingIndexB); - - // Validate: the crossing pair must hug the line *relative to the GPS - // sample spacing*, not in absolute meters. The old absolute check - // (sum < crossingThresholdMeters) conflated zone size with sample - // density: at 1 Hz / 70 km/h consecutive fixes are ~19 m apart, every - // genuine crossing failed the 7 m bound, and the lap counter silently - // never incremented. For a pair genuinely straddling the line, the sum - // of the two distances can never exceed the pair's own spacing, so a - // spacing-scaled bound stays tight at any sample rate. - double pairSpacing = haversine(entryA.lat, entryA.lng, entryB.lat, entryB.lng); - double allowedSum = CROSSING_PAIR_SPACING_FACTOR * pairSpacing; - if (allowedSum < crossingThresholdMeters) { - allowedSum = crossingThresholdMeters; - } - if (crossingSumDistances > allowedSum) { - debugln(F("~~~ INVALID CROSSING: pair too far from line ~~~")); - return false; - } - - // Time deltas are computed in double, normalized across the UTC midnight - // wrap, and sanity-clamped BEFORE any conversion back to unsigned long — - // an out-of-range double-to-unsigned conversion is undefined behavior. - double deltaTime = (double)entryB.time - (double)entryA.time; - if (deltaTime < 0) { - deltaTime += (double)DOVES_MILLIS_PER_DAY; - } - if (deltaTime > (double)CROSSING_MAX_FIX_GAP_MS) { - // The straddling pair is not temporally coherent (GPS time step during - // re-acquisition, or stale buffer contents) — refuse to interpolate. - debugln(F("~~~ INVALID CROSSING: fix gap too large ~~~")); - return false; - } - - // Compute the interpolation factor (t) from distances and speeds at the crossing pair - double distA = pointLineSegmentDistance(entryA.lat, entryA.lng, pointALat, pointALng, pointBLat, pointBLng); - double distB = pointLineSegmentDistance(entryB.lat, entryB.lng, pointALat, pointALng, pointBLat, pointBLng); - double t = interpolateWeight(distA, distB, entryA.speedKmh, entryB.speedKmh); - - // Geometric sanity: the interpolated point must land on the crossing - // line *segment* (within the threshold), not on its infinite extension - // — pointOnSideOfLine treats the line as infinite, so a pair straddling - // the extension beyond an endpoint would otherwise slip through now - // that the sum bound scales with fix spacing. - double linearLat = entryA.lat + t * (entryB.lat - entryA.lat); - double linearLng = entryA.lng + t * (entryB.lng - entryA.lng); - if (pointLineSegmentDistance(linearLat, linearLng, pointALat, pointALng, pointBLat, pointBLng) > crossingThresholdMeters) { - debugln(F("~~~ INVALID CROSSING: crossing point off the line segment ~~~")); - return false; - } - - debugln(F("~~~ VALID CROSSING ~~~")); - - // Time and odometer are always interpolated linearly (monotonic values that - // should not overshoot), regardless of the interpolation mode for position. - double deltaOdometer = entryB.odometer - entryA.odometer; - crossingOdometer = entryA.odometer + t * deltaOdometer; - double crossingTimeMs = (double)entryA.time + t * deltaTime; - if (crossingTimeMs >= (double)DOVES_MILLIS_PER_DAY) { - crossingTimeMs -= (double)DOVES_MILLIS_PER_DAY; // zone straddled midnight - } - crossingTime = (unsigned long)crossingTimeMs; - - if (forceLinear) { - crossingLat = linearLat; - crossingLng = linearLng; - } else { - // Catmull-Rom spline interpolation for position only. - // Requires 4 control points: p0 (before A), p1 (A), p2 (B), p3 (after B) - bool canUseCatmullRom = (crossingIndexA >= 1) && (crossingIndexB <= numPoints - 2); - - if (!canUseCatmullRom) { - // Not enough points for Catmull-Rom, fall back to linear - debugln(F("Catmull-Rom: insufficient control points, using linear fallback")); - crossingLat = linearLat; - crossingLng = linearLng; - } else { - // We have 4 valid control points for Catmull-Rom - int index0 = crossingIndexA - 1; - int index1 = crossingIndexA; - int index2 = crossingIndexB; - int index3 = crossingIndexB + 1; - - debugln(F("Catmull-Rom: using spline interpolation")); - debug(F(" indices: ")); - debug(index0); - debug(F(", ")); - debug(index1); - debug(F(", ")); - debug(index2); - debug(F(", ")); - debugln(index3); - - // Catmull-Rom for lat/lng only - spline smoothing helps with curved paths - crossingLat = catmullRom(entryAt(index0).lat, entryAt(index1).lat, entryAt(index2).lat, entryAt(index3).lat, t); - crossingLng = catmullRom(entryAt(index0).lng, entryAt(index1).lng, entryAt(index2).lng, entryAt(index3).lng, t); - } - } - return true; - } -} - -/////////// direction detection - -void DirectionDetector::onLineCrossing(int sectorNumber, unsigned long crossingTime) { - if (sectorNumber == 0) { - // Start/finish crossed. If we've collected both S2 and S3 timestamps in - // the current lap window, resolve direction from their temporal order. - // Single-sector laps (driver missed a poorly-placed line, or GPS rate - // too low to catch the zone) are discarded and re-tried next lap. - if (raceSeen && direction == DIR_UNKNOWN - && lapS2CrossingTime != 0 && lapS3CrossingTime != 0) { - direction = (lapS2CrossingTime < lapS3CrossingTime) ? DIR_FORWARD : DIR_REVERSE; - } - lapS2CrossingTime = 0; - lapS3CrossingTime = 0; - raceSeen = true; - return; - } - - if (direction != DIR_UNKNOWN) { - return; - } - - if (!raceSeen) { - return; - } - - // Latest crossing wins inside a lap so a phantom glitch early in the lap - // gets overwritten by the real crossing later on. - if (sectorNumber == 2) { - lapS2CrossingTime = crossingTime; - } else if (sectorNumber == 3) { - lapS3CrossingTime = crossingTime; - } -} - -/////////// sector timing helper methods - -void DovesLapTimer::handleLineCrossing(unsigned long crossingTime, int sectorNumber) { - if (!areSectorLinesConfigured()) { - // If sector lines not configured, just handle start/finish as before - return; - } - - // Feed direction detector with raw (physical) sector number and crossing - // time so it can compare S2 vs S3 timestamps to infer direction. - _directionDetector.onLineCrossing(sectorNumber, crossingTime); - - // Remap sector number for reverse direction (swap 2<->3) - int effectiveSector = sectorNumber; - if (_directionDetector.isReverse() && sectorNumber >= 2) { - effectiveSector = (sectorNumber == 2) ? 3 : 2; - debug(F("Direction reverse: physical S")); - debug(sectorNumber); - debug(F(" -> logical S")); - debugln(effectiveSector); - } - - if (effectiveSector == 0) { - // Crossing start/finish line - if (raceStarted && currentSector == 3) { - // Completing sector 3 and finishing lap - currentLapSector3Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); - - debug(F("Sector 3 Time: ")); - debugln(currentLapSector3Time); - - // Update best sectors - updateBestSectors(); - } - - // Start sector 1 - currentSector = 1; - currentSectorStartTime = crossingTime; - currentLapSector1Time = 0; - currentLapSector2Time = 0; - currentLapSector3Time = 0; - - debug(F("Starting Sector 1")); - debugln(); - - } else if (effectiveSector == 2) { - // Crossing sector 2 line (logical) - if (currentSector == 1) { - // Completing sector 1, starting sector 2 - currentLapSector1Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); - currentSector = 2; - currentSectorStartTime = crossingTime; - - debug(F("Sector 1 Time: ")); - debug(currentLapSector1Time); - debug(F(" : ")); - debugln((double)(currentLapSector1Time/1000.0), 3); - } else { - // Out of order crossing - invalidate lap - debug(F("WARNING: Sector 2 crossed out of order (current sector: ")); - debug(currentSector); - debugln(F(")")); - currentSector = 0; // Invalidate - } - - } else if (effectiveSector == 3) { - // Crossing sector 3 line (logical) - if (currentSector == 2) { - // Completing sector 2, starting sector 3 - currentLapSector2Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); - currentSector = 3; - currentSectorStartTime = crossingTime; - - debug(F("Sector 2 Time: ")); - debug(currentLapSector2Time); - debug(F(" : ")); - debugln((double)(currentLapSector2Time/1000.0), 3); - } else { - // Out of order crossing - invalidate lap - debug(F("WARNING: Sector 3 crossed out of order (current sector: ")); - debug(currentSector); - debugln(F(")")); - currentSector = 0; // Invalidate - } - } -} - -void DovesLapTimer::updateBestSectors() { - // Only update if all sectors were completed - if (currentLapSector1Time == 0 || currentLapSector2Time == 0 || currentLapSector3Time == 0) { - return; - } - - // Update sector 1 - if (bestSector1Time == 0 || currentLapSector1Time < bestSector1Time) { - bestSector1Time = currentLapSector1Time; - bestSector1LapNumber = laps; - debug(F("New best Sector 1: ")); - debugln(bestSector1Time); - } - - // Update sector 2 - if (bestSector2Time == 0 || currentLapSector2Time < bestSector2Time) { - bestSector2Time = currentLapSector2Time; - bestSector2LapNumber = laps; - debug(F("New best Sector 2: ")); - debugln(bestSector2Time); - } - - // Update sector 3 - if (bestSector3Time == 0 || currentLapSector3Time < bestSector3Time) { - bestSector3Time = currentLapSector3Time; - bestSector3LapNumber = laps; - debug(F("New best Sector 3: ")); - debugln(bestSector3Time); - } -} - -bool DovesLapTimer::checkSectorLine(double currentLat, double currentLng, - double pointALat, double pointALng, - double pointBLat, double pointBLng, - bool& crossingFlag, int sectorNumber) { - double cLat = 0.0, cLng = 0.0, cOdo = 0.0; - unsigned long cTime = 0; - - LineDetectResult ev = _detectLineCrossing( - currentLat, currentLng, - pointALat, pointALng, pointBLat, pointBLng, - crossingFlag, - sectorNumber, - cLat, cLng, cTime, cOdo); - - if (ev == LINE_DETECT_COMPLETED && raceStarted) { - handleLineCrossing(cTime, sectorNumber); - } - - return ev == LINE_DETECT_IN_ZONE; -} - -/////////// getters and setters - -void DovesLapTimer::reset() { - debugln(F("Resetting laptimer...")); - // reset main race parameters - raceStarted = false; - currentLapStartTime = 0; - lastLapTime = 0; - bestLapTime = 0; - currentLapOdometerStart = 0.0; - lastLapDistance = 0.0; - bestLapDistance = 0.0; - bestLapNumber = 0; - laps = 0; - - // reset sector timing state - currentSector = 0; - currentSectorStartTime = 0; - crossingSector2 = false; - crossingSector3 = false; - currentLapSector1Time = 0; - currentLapSector2Time = 0; - currentLapSector3Time = 0; - bestSector1Time = 0; - bestSector2Time = 0; - bestSector3Time = 0; - bestSector1LapNumber = 0; - bestSector2LapNumber = 0; - bestSector3LapNumber = 0; - - // reset direction detection - _directionDetector.reset(); - - // reset time tracking - millisecondsSinceMidnight = 0; - - // reset odometer and position tracking - totalDistanceTraveled = 0; - positionPrevLat = 0; - positionPrevLng = 0; - positionPrevAlt = 0; - firstPositionReceived = false; - consecutiveJumpCount = 0; - rejectedCrossingCount = 0; - prevFixLat = 0; - prevFixLng = 0; - prevFixTime = 0; - prevFixOdometer = 0; - prevFixSpeedKmh = 0; - hasPrevFix = false; - - // Reset the crossingPointBuffer index and full status - crossing = false; - crossingPointBufferIndex = 0; - crossingPointBufferFull = false; - memset(crossingPointBuffer, 0, sizeof(crossingPointBuffer)); -} -// A crossing line is usable only if both endpoints are finite and distinct. -// A degenerate line (e.g. the 0.00 placeholders shipped in example sketches) -// can never produce a side-change and would only churn the crossing buffer. -static bool lineIsValid(double aLat, double aLng, double bLat, double bLng) { - if (!geoIsFinite(aLat) || !geoIsFinite(aLng) || !geoIsFinite(bLat) || !geoIsFinite(bLng)) { - return false; - } - return aLat != bLat || aLng != bLng; -} - -void DovesLapTimer::setStartFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng) { - startFinishPointALat = pointALat; - startFinishPointALng = pointALng; - startFinishPointBLat = pointBLat; - startFinishPointBLng = pointBLng; - startFinishLineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); - if (!startFinishLineConfigured) { - debugln(F("WARNING: invalid start/finish line (degenerate or non-finite) - detection disabled")); - } -} -void DovesLapTimer::setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { - sector2PointALat = pointALat; - sector2PointALng = pointALng; - sector2PointBLat = pointBLat; - sector2PointBLng = pointBLng; - sector2LineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); - if (!sector2LineConfigured) { - debugln(F("WARNING: invalid sector 2 line (degenerate or non-finite) - sector disabled")); - } -} -void DovesLapTimer::setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { - sector3PointALat = pointALat; - sector3PointALng = pointALng; - sector3PointBLat = pointBLat; - sector3PointBLng = pointBLng; - sector3LineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); - if (!sector3LineConfigured) { - debugln(F("WARNING: invalid sector 3 line (degenerate or non-finite) - sector disabled")); - } -} -void DovesLapTimer::updateCurrentTime(unsigned long currentTimeMilliseconds) { - millisecondsSinceMidnight = currentTimeMilliseconds; -} -void DovesLapTimer::forceLinearInterpolation() { - forceLinear = true; -} -void DovesLapTimer::forceCatmullRomInterpolation() { - forceLinear = false; -} -bool DovesLapTimer::getRaceStarted() const { - return raceStarted; -} -bool DovesLapTimer::getCrossing() const { - return crossing; -} -unsigned long DovesLapTimer::getCurrentLapStartTime() const { - return currentLapStartTime; -} -unsigned long DovesLapTimer::getCurrentLapTime() const { - // raceStarted implies currentLapStartTime has been set — even a lap that - // legitimately started at exactly 00:00:00.000 (start time 0) is valid. - return raceStarted ? timeSinceMidnightDelta(currentLapStartTime, millisecondsSinceMidnight) : 0; -} -unsigned long DovesLapTimer::getLastLapTime() const { - return lastLapTime; -} -unsigned long DovesLapTimer::getBestLapTime() const { - return bestLapTime; -} -float DovesLapTimer::getCurrentLapOdometerStart() const { - return currentLapOdometerStart; -} -float DovesLapTimer::getCurrentLapDistance() const { - return currentLapOdometerStart == 0 || raceStarted == false ? 0 : totalDistanceTraveled - currentLapOdometerStart; -} -float DovesLapTimer::getLastLapDistance() const { - return lastLapDistance; -} -float DovesLapTimer::getBestLapDistance() const { - return bestLapDistance; -} -float DovesLapTimer::getTotalDistanceTraveled() const { - return totalDistanceTraveled; -} -int DovesLapTimer::getBestLapNumber() const { - return bestLapNumber; -} -int DovesLapTimer::getLaps() const { - return laps; -} -float DovesLapTimer::getPaceDifference() const { - float currentLapDistance = currentLapOdometerStart == 0 || raceStarted == false ? 0 : totalDistanceTraveled - currentLapOdometerStart; - unsigned long currentLapTime = timeSinceMidnightDelta(currentLapStartTime, millisecondsSinceMidnight); - - // Avoid division by zero - if (currentLapDistance == 0 || bestLapDistance == 0) { - return 0.0; - } - - // Calculate the pace for the current lap and the best lap - float currentLapPace = currentLapTime / currentLapDistance; - float bestLapPace = bestLapTime / bestLapDistance; - - // Calculate the pace difference - float paceDiff = currentLapPace - bestLapPace; - - return paceDiff; -} -float DovesLapTimer::getCurrentSpeedKmh() const { - return currentSpeedkmh; -} -float DovesLapTimer::getCurrentSpeedMph() const { - return currentSpeedkmh * GEOMATH_KMH_TO_MPH; -} - -/////////// sector timing getters - -unsigned long DovesLapTimer::getBestSector1Time() const { - return bestSector1Time; -} -unsigned long DovesLapTimer::getBestSector2Time() const { - return bestSector2Time; -} -unsigned long DovesLapTimer::getBestSector3Time() const { - return bestSector3Time; -} -unsigned long DovesLapTimer::getCurrentLapSector1Time() const { - return currentLapSector1Time; -} -unsigned long DovesLapTimer::getCurrentLapSector2Time() const { - return currentLapSector2Time; -} -unsigned long DovesLapTimer::getCurrentLapSector3Time() const { - return currentLapSector3Time; -} -unsigned long DovesLapTimer::getOptimalLapTime() const { - // Only return optimal lap if all sectors have been recorded - if (bestSector1Time == 0 || bestSector2Time == 0 || bestSector3Time == 0) { - return 0; - } - return bestSector1Time + bestSector2Time + bestSector3Time; -} -int DovesLapTimer::getBestSector1LapNumber() const { - return bestSector1LapNumber; -} -int DovesLapTimer::getBestSector2LapNumber() const { - return bestSector2LapNumber; -} -int DovesLapTimer::getBestSector3LapNumber() const { - return bestSector3LapNumber; -} -int DovesLapTimer::getCurrentSector() const { - return currentSector; -} -bool DovesLapTimer::areSectorLinesConfigured() const { - return sector2LineConfigured && sector3LineConfigured; -} -bool DovesLapTimer::isStartFinishLineConfigured() const { - return startFinishLineConfigured; -} -unsigned int DovesLapTimer::getRejectedCrossingCount() const { - return rejectedCrossingCount; -} - -/////////// direction detection getters - -int DovesLapTimer::getDirection() const { - return _directionDetector.direction; -} -bool DovesLapTimer::isDirectionResolved() const { - return _directionDetector.direction != DIR_UNKNOWN; +/** + * GPS-based lap timing library for go-kart and racing applications. + * This library does NOT interface with your GPS, simply feed it data and check the state. + * Supports start/finish line detection, 3-sector split timing, pace comparison, and distance tracking. + * + * The development of this library has been overseen, and all documentation has been generated using chatGPT4. + */ + +#include "DovesLapTimer.h" +#include "GeoMath.h" + +#define debugln debug_println +#define debug debug_print + +DovesLapTimer::DovesLapTimer(double crossingThresholdMeters, Stream *debugSerial) { + this->crossingThresholdMeters = crossingThresholdMeters; + + if (debugSerial == NULL) { + _serial = nullptr; + } else { + _serial = debugSerial; + } + + _crossingEngine.setThreshold(crossingThresholdMeters); + _crossingEngine.setDebugSerial(_serial); +} + +int DovesLapTimer::loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots) { + // Reject invalid fixes before they can poison the odometer or timing state. + // NaN/Inf and (0,0) fixes are routine parser output during fix loss; a + // single one would otherwise stick in totalDistanceTraveled forever. + if (!geoCoordinatesValid(currentLat, currentLng)) { + debugln(F("Rejected invalid GPS coordinates")); + return -1; + } + // Altitude and speed are auxiliary — sanitize rather than drop the fix. + if (!geoIsFinite(currentAltitudeMeters)) { + currentAltitudeMeters = positionPrevAlt; + } + if (!geoIsFinite(currentSpeedKnots) || currentSpeedKnots < 0) { + currentSpeedKnots = 0; + } + + // Update Odometer - only calculate distance if we have a previous position + if (firstPositionReceived) { + double jumpDistance = this->haversine(positionPrevLat, positionPrevLng, currentLat, currentLng); + if (jumpDistance > GPS_MAX_PLAUSIBLE_JUMP_METERS) { + consecutiveJumpCount++; + if (consecutiveJumpCount < GPS_JUMP_REACCEPT_COUNT) { + // Almost certainly a teleport glitch — drop the fix entirely. + debugln(F("Rejected implausible GPS jump")); + return -1; + } + // Several consecutive far fixes: the new position is real (signal + // re-acquisition / device moved). Re-seed without crediting the gap + // to the odometer. + consecutiveJumpCount = 0; + } else { + consecutiveJumpCount = 0; + // TODO: I think alt is messing up, investigate more... maybe flag? + double distanceTraveledSinceLastUpdate = this->haversine3D( + positionPrevLat, + positionPrevLng, + positionPrevAlt, + currentLat, + currentLng, + currentAltitudeMeters + ); + totalDistanceTraveled += distanceTraveledSinceLastUpdate; + } + } else { + firstPositionReceived = true; + } + positionPrevLat = currentLat; + positionPrevLng = currentLng; + positionPrevAlt = currentAltitudeMeters; + + // update current speed + currentSpeedkmh = currentSpeedKnots * GEOMATH_KNOTS_TO_KMH; + + // run calculations for each crossing-line + // Only one line can be "crossing" at a time due to shared buffer. + // Mutual exclusion: skip start/finish if a sector crossing is active, and vice versa. + + bool nearAnyLine = false; + + // Check start/finish line - requires a configured line (otherwise the + // endpoint members would be meaningless zeros), and skip if a sector + // crossing is already in progress + if (startFinishLineConfigured && (crossing || (!crossingSector2 && !crossingSector3))) { + if (this->checkStartFinish(currentLat, currentLng)) { + nearAnyLine = true; + } + } + + // Check sector lines if configured and not currently crossing start/finish + if (areSectorLinesConfigured() && !crossing) { + // Check sector 2 line + if (sector2LineConfigured && !crossingSector2 && !crossingSector3) { + if (checkSectorLine(currentLat, currentLng, + sector2PointALat, sector2PointALng, + sector2PointBLat, sector2PointBLng, + crossingSector2, 2)) { + nearAnyLine = true; + } + } else if (crossingSector2) { + // Continue processing sector 2 crossing + if (checkSectorLine(currentLat, currentLng, + sector2PointALat, sector2PointALng, + sector2PointBLat, sector2PointBLng, + crossingSector2, 2)) { + nearAnyLine = true; + } + } + + // Check sector 3 line + if (sector3LineConfigured && !crossingSector2 && !crossingSector3) { + if (checkSectorLine(currentLat, currentLng, + sector3PointALat, sector3PointALng, + sector3PointBLat, sector3PointBLng, + crossingSector3, 3)) { + nearAnyLine = true; + } + } else if (crossingSector3) { + // Continue processing sector 3 crossing + if (checkSectorLine(currentLat, currentLng, + sector3PointALat, sector3PointALng, + sector3PointBLat, sector3PointBLng, + crossingSector3, 3)) { + nearAnyLine = true; + } + } + } + + // Save current fix as previous for next iteration's Catmull-Rom pre-crossing point + prevFix.lat = currentLat; + prevFix.lng = currentLng; + prevFix.time = millisecondsSinceMidnight; + prevFix.odometer = totalDistanceTraveled; + prevFix.speedKmh = currentSpeedkmh; + hasPrevFix = true; + + return nearAnyLine ? 0 : -1; +} + +bool DovesLapTimer::checkStartFinish(double currentLat, double currentLng) { + double cLat = 0.0, cLng = 0.0, cOdo = 0.0; + unsigned long cTime = 0; + + LineDetectResult ev = _detectLineCrossing( + currentLat, currentLng, + startFinishPointALat, startFinishPointALng, + startFinishPointBLat, startFinishPointBLng, + crossing, + 0, + cLat, cLng, cTime, cOdo); + + if (ev == LINE_DETECT_COMPLETED) { + if (raceStarted) { + laps++; + unsigned long lapTime = timeSinceMidnightDelta(currentLapStartTime, cTime); + double lapDistance = cOdo - currentLapOdometerStart; + + debug(F("Lap Finish Time: ")); + debug(lapTime); + debug(F(" : ")); + debugln((double)(lapTime / 1000.0), 3); + + lastLapTime = lapTime; + lastLapDistance = lapDistance; + if (bestLapTime <= 0 || lastLapTime < bestLapTime) { + bestLapTime = lastLapTime; + bestLapDistance = lastLapDistance; + bestLapNumber = laps; + } + } else { + raceStarted = true; + debugln(F("Race Started")); + } + currentLapStartTime = cTime; + currentLapOdometerStart = cOdo; + handleLineCrossing(cTime, 0); + } + + return ev == LINE_DETECT_IN_ZONE; +} + +/** + * Thin wrapper over CrossingEngine::detect — the detection state machine and + * interpolation now live in CrossingEngine.cpp (extracted, not rewritten). + * This wrapper supplies the timer's vehicle state so the two callers + * (checkStartFinish / checkSectorLine) keep their historical call shape. + */ +LineDetectResult DovesLapTimer::_detectLineCrossing( + double currentLat, double currentLng, + double pointALat, double pointALng, + double pointBLat, double pointBLng, + bool& crossingFlag, + int lineLabel, + double& outLat, double& outLng, + unsigned long& outTime, + double& outOdometer) { + return _crossingEngine.detect( + currentLat, currentLng, + millisecondsSinceMidnight, + totalDistanceTraveled, + currentSpeedkmh, + hasPrevFix ? &prevFix : NULL, + pointALat, pointALng, + pointBLat, pointBLng, + crossingFlag, + lineLabel, + outLat, outLng, outTime, outOdometer); +} + +bool DovesLapTimer::insideLineThreshold(double driverLat, double driverLon, double crossingPointALat, double crossingPointALon, double crossingPointBLat, double crossingPointBLon) { + return geoInsideLineThreshold(crossingThresholdMeters, driverLat, driverLon, crossingPointALat, crossingPointALon, crossingPointBLat, crossingPointBLon); +} + +bool DovesLapTimer::isObtuseTriangle(double lat1, double lon1, double lat2, double lon2, double lat3, double lon3) { + // Get side lengths + double a = haversine(lat1, lon1, lat2, lon2); + double b = haversine(lat1, lon1, lat3, lon3); + double c = haversine(lat2, lon2, lat3, lon3); + + // Sort the sides in ascending order + if (a > b) std::swap(a, b); + if (b > c) std::swap(b, c); + if (a > b) std::swap(a, b); + + // listen... this has been a long debugging session + if ( a + b <= c ) { + // debugln(F("triangle: Impossible")); + return false; + } else { + TRITYPE discriminant = a * a + b * b - c * c; + if (discriminant < 0) { + // debugln(F("triangle: Obtuse")); + return true; + } else if (discriminant > 0) { + // debugln(F("triangle: Acute")); + return false; + } else { + // debugln(F("triangle: Right Angled")); + return false; + } + } +} + +int DovesLapTimer::pointOnSideOfLine(double driverLat, double driverLng, double pointALat, double pointALng, double pointBLat, double pointBLng) { + return geoPointOnSideOfLine(driverLat, driverLng, pointALat, pointALng, pointBLat, pointBLng); +} + +double DovesLapTimer::pointLineSegmentDistance(double pointX, double pointY, double startX, double startY, double endX, double endY) { + return geoPointLineSegmentDistance(pointX, pointY, startX, startY, endX, endY); +} + +double DovesLapTimer::haversine(double lat1, double lon1, double lat2, double lon2) { + return geoHaversine(lat1, lon1, lat2, lon2); +} + +double DovesLapTimer::haversine3D(double prevLat, double prevLng, double prevAlt, double currentLat, double currentLng, double currentAlt) { + return geoHaversine3D(prevLat, prevLng, prevAlt, currentLat, currentLng, currentAlt); +} + +/////////// direction detection + +void DirectionDetector::onLineCrossing(int sectorNumber, unsigned long crossingTime) { + if (sectorNumber == 0) { + // Start/finish crossed. If we've collected both S2 and S3 timestamps in + // the current lap window, resolve direction from their temporal order. + // Single-sector laps (driver missed a poorly-placed line, or GPS rate + // too low to catch the zone) are discarded and re-tried next lap. + if (raceSeen && direction == DIR_UNKNOWN + && lapS2CrossingTime != 0 && lapS3CrossingTime != 0) { + direction = (lapS2CrossingTime < lapS3CrossingTime) ? DIR_FORWARD : DIR_REVERSE; + } + lapS2CrossingTime = 0; + lapS3CrossingTime = 0; + raceSeen = true; + return; + } + + if (direction != DIR_UNKNOWN) { + return; + } + + if (!raceSeen) { + return; + } + + // Latest crossing wins inside a lap so a phantom glitch early in the lap + // gets overwritten by the real crossing later on. + if (sectorNumber == 2) { + lapS2CrossingTime = crossingTime; + } else if (sectorNumber == 3) { + lapS3CrossingTime = crossingTime; + } +} + +/////////// sector timing helper methods + +void DovesLapTimer::handleLineCrossing(unsigned long crossingTime, int sectorNumber) { + if (!areSectorLinesConfigured()) { + // If sector lines not configured, just handle start/finish as before + return; + } + + // Feed direction detector with raw (physical) sector number and crossing + // time so it can compare S2 vs S3 timestamps to infer direction. + _directionDetector.onLineCrossing(sectorNumber, crossingTime); + + // Remap sector number for reverse direction (swap 2<->3) + int effectiveSector = sectorNumber; + if (_directionDetector.isReverse() && sectorNumber >= 2) { + effectiveSector = (sectorNumber == 2) ? 3 : 2; + debug(F("Direction reverse: physical S")); + debug(sectorNumber); + debug(F(" -> logical S")); + debugln(effectiveSector); + } + + if (effectiveSector == 0) { + // Crossing start/finish line + if (raceStarted && currentSector == 3) { + // Completing sector 3 and finishing lap + currentLapSector3Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); + + debug(F("Sector 3 Time: ")); + debugln(currentLapSector3Time); + + // Update best sectors + updateBestSectors(); + } + + // Start sector 1 + currentSector = 1; + currentSectorStartTime = crossingTime; + currentLapSector1Time = 0; + currentLapSector2Time = 0; + currentLapSector3Time = 0; + + debug(F("Starting Sector 1")); + debugln(); + + } else if (effectiveSector == 2) { + // Crossing sector 2 line (logical) + if (currentSector == 1) { + // Completing sector 1, starting sector 2 + currentLapSector1Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); + currentSector = 2; + currentSectorStartTime = crossingTime; + + debug(F("Sector 1 Time: ")); + debug(currentLapSector1Time); + debug(F(" : ")); + debugln((double)(currentLapSector1Time/1000.0), 3); + } else { + // Out of order crossing - invalidate lap + debug(F("WARNING: Sector 2 crossed out of order (current sector: ")); + debug(currentSector); + debugln(F(")")); + currentSector = 0; // Invalidate + } + + } else if (effectiveSector == 3) { + // Crossing sector 3 line (logical) + if (currentSector == 2) { + // Completing sector 2, starting sector 3 + currentLapSector2Time = timeSinceMidnightDelta(currentSectorStartTime, crossingTime); + currentSector = 3; + currentSectorStartTime = crossingTime; + + debug(F("Sector 2 Time: ")); + debug(currentLapSector2Time); + debug(F(" : ")); + debugln((double)(currentLapSector2Time/1000.0), 3); + } else { + // Out of order crossing - invalidate lap + debug(F("WARNING: Sector 3 crossed out of order (current sector: ")); + debug(currentSector); + debugln(F(")")); + currentSector = 0; // Invalidate + } + } +} + +void DovesLapTimer::updateBestSectors() { + // Only update if all sectors were completed + if (currentLapSector1Time == 0 || currentLapSector2Time == 0 || currentLapSector3Time == 0) { + return; + } + + // Update sector 1 + if (bestSector1Time == 0 || currentLapSector1Time < bestSector1Time) { + bestSector1Time = currentLapSector1Time; + bestSector1LapNumber = laps; + debug(F("New best Sector 1: ")); + debugln(bestSector1Time); + } + + // Update sector 2 + if (bestSector2Time == 0 || currentLapSector2Time < bestSector2Time) { + bestSector2Time = currentLapSector2Time; + bestSector2LapNumber = laps; + debug(F("New best Sector 2: ")); + debugln(bestSector2Time); + } + + // Update sector 3 + if (bestSector3Time == 0 || currentLapSector3Time < bestSector3Time) { + bestSector3Time = currentLapSector3Time; + bestSector3LapNumber = laps; + debug(F("New best Sector 3: ")); + debugln(bestSector3Time); + } +} + +bool DovesLapTimer::checkSectorLine(double currentLat, double currentLng, + double pointALat, double pointALng, + double pointBLat, double pointBLng, + bool& crossingFlag, int sectorNumber) { + double cLat = 0.0, cLng = 0.0, cOdo = 0.0; + unsigned long cTime = 0; + + LineDetectResult ev = _detectLineCrossing( + currentLat, currentLng, + pointALat, pointALng, pointBLat, pointBLng, + crossingFlag, + sectorNumber, + cLat, cLng, cTime, cOdo); + + if (ev == LINE_DETECT_COMPLETED && raceStarted) { + handleLineCrossing(cTime, sectorNumber); + } + + return ev == LINE_DETECT_IN_ZONE; +} + +/////////// getters and setters + +void DovesLapTimer::reset() { + debugln(F("Resetting laptimer...")); + // reset main race parameters + raceStarted = false; + currentLapStartTime = 0; + lastLapTime = 0; + bestLapTime = 0; + currentLapOdometerStart = 0.0; + lastLapDistance = 0.0; + bestLapDistance = 0.0; + bestLapNumber = 0; + laps = 0; + + // reset sector timing state + currentSector = 0; + currentSectorStartTime = 0; + crossingSector2 = false; + crossingSector3 = false; + currentLapSector1Time = 0; + currentLapSector2Time = 0; + currentLapSector3Time = 0; + bestSector1Time = 0; + bestSector2Time = 0; + bestSector3Time = 0; + bestSector1LapNumber = 0; + bestSector2LapNumber = 0; + bestSector3LapNumber = 0; + + // reset direction detection + _directionDetector.reset(); + + // reset time tracking + millisecondsSinceMidnight = 0; + + // reset odometer and position tracking + totalDistanceTraveled = 0; + positionPrevLat = 0; + positionPrevLng = 0; + positionPrevAlt = 0; + firstPositionReceived = false; + consecutiveJumpCount = 0; + prevFix.lat = 0; + prevFix.lng = 0; + prevFix.time = 0; + prevFix.odometer = 0; + prevFix.speedKmh = 0; + hasPrevFix = false; + + // Reset the crossing engine (buffer + rejected-crossing counter) + crossing = false; + _crossingEngine.reset(); +} +// A crossing line is usable only if both endpoints are finite and distinct. +// A degenerate line (e.g. the 0.00 placeholders shipped in example sketches) +// can never produce a side-change and would only churn the crossing buffer. +static bool lineIsValid(double aLat, double aLng, double bLat, double bLng) { + if (!geoIsFinite(aLat) || !geoIsFinite(aLng) || !geoIsFinite(bLat) || !geoIsFinite(bLng)) { + return false; + } + return aLat != bLat || aLng != bLng; +} + +void DovesLapTimer::setStartFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng) { + startFinishPointALat = pointALat; + startFinishPointALng = pointALng; + startFinishPointBLat = pointBLat; + startFinishPointBLng = pointBLng; + startFinishLineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!startFinishLineConfigured) { + debugln(F("WARNING: invalid start/finish line (degenerate or non-finite) - detection disabled")); + } +} +void DovesLapTimer::setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { + sector2PointALat = pointALat; + sector2PointALng = pointALng; + sector2PointBLat = pointBLat; + sector2PointBLng = pointBLng; + sector2LineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!sector2LineConfigured) { + debugln(F("WARNING: invalid sector 2 line (degenerate or non-finite) - sector disabled")); + } +} +void DovesLapTimer::setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { + sector3PointALat = pointALat; + sector3PointALng = pointALng; + sector3PointBLat = pointBLat; + sector3PointBLng = pointBLng; + sector3LineConfigured = lineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!sector3LineConfigured) { + debugln(F("WARNING: invalid sector 3 line (degenerate or non-finite) - sector disabled")); + } +} +void DovesLapTimer::updateCurrentTime(unsigned long currentTimeMilliseconds) { + millisecondsSinceMidnight = currentTimeMilliseconds; +} +void DovesLapTimer::forceLinearInterpolation() { + _crossingEngine.setForceLinear(true); +} +void DovesLapTimer::forceCatmullRomInterpolation() { + _crossingEngine.setForceLinear(false); +} +bool DovesLapTimer::getRaceStarted() const { + return raceStarted; +} +bool DovesLapTimer::getCrossing() const { + return crossing; +} +unsigned long DovesLapTimer::getCurrentLapStartTime() const { + return currentLapStartTime; +} +unsigned long DovesLapTimer::getCurrentLapTime() const { + // raceStarted implies currentLapStartTime has been set — even a lap that + // legitimately started at exactly 00:00:00.000 (start time 0) is valid. + return raceStarted ? timeSinceMidnightDelta(currentLapStartTime, millisecondsSinceMidnight) : 0; +} +unsigned long DovesLapTimer::getLastLapTime() const { + return lastLapTime; +} +unsigned long DovesLapTimer::getBestLapTime() const { + return bestLapTime; +} +float DovesLapTimer::getCurrentLapOdometerStart() const { + return currentLapOdometerStart; +} +float DovesLapTimer::getCurrentLapDistance() const { + return currentLapOdometerStart == 0 || raceStarted == false ? 0 : totalDistanceTraveled - currentLapOdometerStart; +} +float DovesLapTimer::getLastLapDistance() const { + return lastLapDistance; +} +float DovesLapTimer::getBestLapDistance() const { + return bestLapDistance; +} +float DovesLapTimer::getTotalDistanceTraveled() const { + return totalDistanceTraveled; +} +int DovesLapTimer::getBestLapNumber() const { + return bestLapNumber; +} +int DovesLapTimer::getLaps() const { + return laps; +} +float DovesLapTimer::getPaceDifference() const { + float currentLapDistance = currentLapOdometerStart == 0 || raceStarted == false ? 0 : totalDistanceTraveled - currentLapOdometerStart; + unsigned long currentLapTime = timeSinceMidnightDelta(currentLapStartTime, millisecondsSinceMidnight); + + // Avoid division by zero + if (currentLapDistance == 0 || bestLapDistance == 0) { + return 0.0; + } + + // Calculate the pace for the current lap and the best lap + float currentLapPace = currentLapTime / currentLapDistance; + float bestLapPace = bestLapTime / bestLapDistance; + + // Calculate the pace difference + float paceDiff = currentLapPace - bestLapPace; + + return paceDiff; +} +float DovesLapTimer::getCurrentSpeedKmh() const { + return currentSpeedkmh; +} +float DovesLapTimer::getCurrentSpeedMph() const { + return currentSpeedkmh * GEOMATH_KMH_TO_MPH; +} + +/////////// sector timing getters + +unsigned long DovesLapTimer::getBestSector1Time() const { + return bestSector1Time; +} +unsigned long DovesLapTimer::getBestSector2Time() const { + return bestSector2Time; +} +unsigned long DovesLapTimer::getBestSector3Time() const { + return bestSector3Time; +} +unsigned long DovesLapTimer::getCurrentLapSector1Time() const { + return currentLapSector1Time; +} +unsigned long DovesLapTimer::getCurrentLapSector2Time() const { + return currentLapSector2Time; +} +unsigned long DovesLapTimer::getCurrentLapSector3Time() const { + return currentLapSector3Time; +} +unsigned long DovesLapTimer::getOptimalLapTime() const { + // Only return optimal lap if all sectors have been recorded + if (bestSector1Time == 0 || bestSector2Time == 0 || bestSector3Time == 0) { + return 0; + } + return bestSector1Time + bestSector2Time + bestSector3Time; +} +int DovesLapTimer::getBestSector1LapNumber() const { + return bestSector1LapNumber; +} +int DovesLapTimer::getBestSector2LapNumber() const { + return bestSector2LapNumber; +} +int DovesLapTimer::getBestSector3LapNumber() const { + return bestSector3LapNumber; +} +int DovesLapTimer::getCurrentSector() const { + return currentSector; +} +bool DovesLapTimer::areSectorLinesConfigured() const { + return sector2LineConfigured && sector3LineConfigured; +} +bool DovesLapTimer::isStartFinishLineConfigured() const { + return startFinishLineConfigured; +} +unsigned int DovesLapTimer::getRejectedCrossingCount() const { + return _crossingEngine.getRejectedCrossingCount(); +} + +/////////// direction detection getters + +int DovesLapTimer::getDirection() const { + return _directionDetector.direction; +} +bool DovesLapTimer::isDirectionResolved() const { + return _directionDetector.direction != DIR_UNKNOWN; } \ No newline at end of file diff --git a/src/DovesLapTimer.h b/src/DovesLapTimer.h index bb19af2..484b49f 100644 --- a/src/DovesLapTimer.h +++ b/src/DovesLapTimer.h @@ -11,6 +11,7 @@ #include "ArxTypeTraits.h" #include "GeoMath.h" +#include "CrossingEngine.h" using TRITYPE = double; // On classic AVR (Mega, Uno) `double` is a 32-bit float (~7 significant @@ -25,9 +26,7 @@ using TRITYPE = double; #warning "DovesLapTimer: 'double' is only 32 bits on this target (classic AVR). Lap counting works but GPS math runs degraded - distances and interpolated crossing times will be noticeably less accurate. Use a 64-bit-double MCU (e.g. XIAO nRF52840) for full precision." #endif -#define CROSSING_LINE_SIDE_A -1 -#define CROSSING_LINE_SIDE_EXACT 0 -#define CROSSING_LINE_SIDE_B 1 +// CROSSING_LINE_SIDE_* now live in CrossingEngine.h (included above). // Course detection constants #define COURSE_DETECT_SPEED_THRESHOLD_MPH 20 @@ -67,8 +66,9 @@ using TRITYPE = double; // Maximum courses supported #define MAX_COURSES 8 -// GPS time base: milliseconds since UTC midnight, wraps 86,399,999 -> 0 -#define DOVES_MILLIS_PER_DAY 86400000UL +// DOVES_MILLIS_PER_DAY, timeSinceMidnightDelta(), CROSSING_MAX_FIX_GAP_MS, +// CROSSING_PAIR_SPACING_FACTOR, LineDetectResult, and +// crossingPointBufferEntry now live in CrossingEngine.h (included above). // GPS input validation: a single-fix jump beyond this is treated as a glitch // and dropped; after GPS_JUMP_REACCEPT_COUNT consecutive far fixes the new @@ -77,50 +77,6 @@ using TRITYPE = double; #define GPS_MAX_PLAUSIBLE_JUMP_METERS 500.0 #define GPS_JUMP_REACCEPT_COUNT 3 -// Max believable gap between the two consecutive buffered fixes that straddle -// a crossing line. A larger gap means the GPS time base stepped (e.g. u-blox -// re-acquisition) and the pair cannot be interpolated coherently. -#define CROSSING_MAX_FIX_GAP_MS 10000UL - -// The straddling pair's summed distance to the line is validated against -// max(crossingThresholdMeters, factor * pair spacing) so that low-rate GPS -// (widely spaced fixes) doesn't fail validation purely on sample density. -#define CROSSING_PAIR_SPACING_FACTOR 1.25 - -/** - * @brief Elapsed milliseconds between two milliseconds-since-midnight timestamps. - * - * Normalizes across the UTC midnight wrap (86,399,999 -> 0), which happens - * mid-evening across the Americas. Without this, a lap straddling midnight - * underflows unsigned subtraction into a ~4.29-billion-ms "lap time". - * - * @param startMs Earlier timestamp, in ms since midnight. - * @param endMs Later timestamp, in ms since midnight. - * @return Elapsed time in ms, wrap-normalized. - */ -static inline unsigned long timeSinceMidnightDelta(unsigned long startMs, unsigned long endMs) { - if (endMs < startMs) { - endMs += DOVES_MILLIS_PER_DAY; - } - return endMs - startMs; -} - -// Outcome of a single _detectLineCrossing pass. -enum LineDetectResult { - LINE_DETECT_NONE, // not in / near the zone, or exited with an invalid interpolation - LINE_DETECT_IN_ZONE, // inside the zone (entered this fix or continuing) - LINE_DETECT_COMPLETED, // exited the zone this fix — interpolated outputs valid -}; - - -struct crossingPointBufferEntry { - double lat; // latitude - double lng; // longitude - unsigned long time; // current time in milliseconds - float odometer; // time traveled since device start and this entry - float speedKmh; // speed in kmph -}; - struct DirectionDetector { int direction; // DIR_UNKNOWN, DIR_FORWARD, DIR_REVERSE bool raceSeen; @@ -575,9 +531,9 @@ class DovesLapTimer { */ bool checkSectorLine(double currentLat, double currentLng, double pointALat, double pointALng, double pointBLat, double pointBLng, bool& crossingFlag, int sectorNumber); /** - * @brief Shared crossing-zone state machine used by checkStartFinish and - * checkSectorLine. Detects entry, buffers GPS fixes inside the zone, and - * interpolates the exact crossing point on exit. + * @brief Thin wrapper over CrossingEngine::detect — supplies this timer's + * vehicle state (time, odometer, speed, previous-fix snapshot) so + * checkStartFinish and checkSectorLine keep their historical call shape. * * @param currentLat / currentLng Current GPS position. * @param pointALat / pointALng Line endpoint A. @@ -608,56 +564,20 @@ class DovesLapTimer { * @brief Updates best sector times if current lap sector times are better. */ void updateBestSectors(); - /** - * @brief Catmull-Rom spline interpolation between two points - * - * @param p0 Value at point 0 - * @param p1 Value at point 1 - * @param p2 Value at point 2 - * @param p3 Value at point 3 - * @param t Interpolation parameter [0, 1] - * @return Interpolated value - */ - double catmullRom(double p0, double p1, double p2, double p3, double t); - /** - * @brief Computes the interpolation weight based on distances and speeds. - * - * @param distA Distance from point A to the line. - * @param distB Distance from point B to the line. - * @param speedA Speed (in km/h) at point A. - * @param speedB Speed (in km/h) at point B. - * @return Interpolation weight factor for point A. - */ - double interpolateWeight(double distA, double distB, float speedA, float speedB); - /** - * @brief Calculates the crossing point's latitude, longitude, and time based on the buffer points and the line defined by two points. - * - * This function walks the buffered GPS points in chronological order (unwinding the - * circular buffer if it wrapped) and finds the first pair of consecutive points on - * opposite sides of the line defined by (pointALat, pointALng) and (pointBLat, pointBLng). - * It then interpolates the crossing point's latitude, longitude, and time using that pair. - * - * @param crossingLat Reference to the variable that will store the crossing point's latitude. - * @param crossingLng Reference to the variable that will store the crossing point's longitude. - * @param crossingTime Reference to the variable that will store the crossing point's time. - * @param crossingOdometer Reference to the variable that will store the crossing point's odometer. - * @param pointALat Latitude of the first point of the line in decimal degrees. - * @param pointALng Longitude of the first point of the line in decimal degrees. - * @param pointBLat Latitude of the second point of the line in decimal degrees. - * @param pointBLng Longitude of the second point of the line in decimal degrees. - * @return True if a valid crossing was found and the out-params are populated. - */ - bool interpolateCrossingPoint(double& crossingLat, double& crossingLng, unsigned long& crossingTime, double& crossingOdometer, double pointALat, double pointALng, double pointBLat, double pointBLng); Stream *_serial; DirectionDetector _directionDetector; + // The shared crossing pipeline (buffer + interpolation). ONE engine for + // all three lines — the historical shared-buffer design, kept so the + // by-value CourseManager timer array doesn't triple in size. loop() + // enforces the resulting only-one-line-crossing-at-a-time exclusion. + CrossingEngine _crossingEngine; unsigned long millisecondsSinceMidnight = 0; // Timing variables double crossingThresholdMeters; bool raceStarted = false; bool crossing = false; - bool forceLinear = true; unsigned long currentLapStartTime = 0; unsigned long lastLapTime = 0; unsigned long bestLapTime = 0; @@ -696,11 +616,7 @@ class DovesLapTimer { bool firstPositionReceived = false; // Explicit flag for first GPS fix detection // Previous GPS fix snapshot (used as Catmull-Rom pre-crossing control point) - double prevFixLat = 0; - double prevFixLng = 0; - unsigned long prevFixTime = 0; - float prevFixOdometer = 0; - float prevFixSpeedKmh = 0; + crossingPointBufferEntry prevFix = {0, 0, 0, 0, 0}; bool hasPrevFix = false; double startFinishPointALat = 0.0; @@ -727,30 +643,6 @@ class DovesLapTimer { // Consecutive fixes rejected for jumping > GPS_MAX_PLAUSIBLE_JUMP_METERS int consecutiveJumpCount = 0; - - // Zone exits whose crossing interpolation was rejected (see getter docs) - unsigned int rejectedCrossingCount = 0; - - // Earth's radius in meters - static constexpr double radiusEarth = 6371.0 * 1000; - - // Buffer sizing: AVR exposes RAMEND/RAMSTART so we can tell a Mega (8KB) from a Uno (2KB). - // On modern 32-bit cores (nRF52, ESP32, SAMD, RP2040, etc.) those macros are undefined - // and would silently evaluate to 0 - defaulting such targets to the small buffer would - // defeat the whole point of the hotfix. Assume anyone not on classic AVR has plenty of RAM. - #if defined(RAMEND) && defined(RAMSTART) - #if ((RAMEND - RAMSTART) > 3000) - static const int crossingPointBufferSize = 100; - #else - static const int crossingPointBufferSize = 25; - #endif - #else - static const int crossingPointBufferSize = 100; - #endif - - crossingPointBufferEntry crossingPointBuffer[crossingPointBufferSize]; - int crossingPointBufferIndex = 0; - bool crossingPointBufferFull = false; }; #endif \ No newline at end of file diff --git a/src/GeoMath.h b/src/GeoMath.h index 89589d5..294684b 100644 --- a/src/GeoMath.h +++ b/src/GeoMath.h @@ -88,4 +88,78 @@ static inline double geoHaversine3D(double prevLat, double prevLng, double prevA return sqrt(dist * dist + altDiff * altDiff); } +/** + * @brief Determines which side of an (infinite) line a point is on. + * + * Cross-product sign test in degree space. Extracted from + * DovesLapTimer::pointOnSideOfLine so CrossingEngine and SprintTimer can + * share it without a timer instance; the member method delegates here. + * + * @return 1 / -1 for the two sides, 0 if exactly on the line + * (matches CROSSING_LINE_SIDE_B / _A / _EXACT). + */ +static inline int geoPointOnSideOfLine(double driverLat, double driverLng, double pointALat, double pointALng, double pointBLat, double pointBLng) { + double lineDirectionX = pointBLat - pointALat; + double lineDirectionY = pointBLng - pointALng; + double driverToPointAX = driverLat - pointALat; + double driverToPointAY = driverLng - pointALng; + + double crossProduct = lineDirectionX * driverToPointAY - lineDirectionY * driverToPointAX; + + if (crossProduct > 0) { + return -1; // CROSSING_LINE_SIDE_A + } else if (crossProduct < 0) { + return 1; // CROSSING_LINE_SIDE_B + } + return 0; // CROSSING_LINE_SIDE_EXACT +} + +/** + * @brief Shortest distance from a point to a line *segment*, in meters. + * + * Inputs are decimal-degree coordinates; the projection onto the segment + * happens in degree space, but every return path measures the final + * distance via haversine, so the result is in meters. Extracted from + * DovesLapTimer::pointLineSegmentDistance (which delegates here). + */ +static inline double geoPointLineSegmentDistance(double pointX, double pointY, double startX, double startY, double endX, double endY) { + double dx = endX - startX; + double dy = endY - startY; + double segmentLengthSquared = dx * dx + dy * dy; + + // Epsilon comparison handles degenerate segments (start == end). + if (segmentLengthSquared < 1e-12) { + return geoHaversine(pointX, pointY, startX, startY); + } + + double projectionScalar = ((pointX - startX) * dx + (pointY - startY) * dy) / segmentLengthSquared; + + if (projectionScalar < 0.0) { + return geoHaversine(pointX, pointY, startX, startY); + } else if (projectionScalar > 1.0) { + return geoHaversine(pointX, pointY, endX, endY); + } + + double projectedX = startX + projectionScalar * dx; + double projectedY = startY + projectionScalar * dy; + return geoHaversine(pointX, pointY, projectedX, projectedY); +} + +/** + * @brief Hypotenuse-based crossing-zone membership test. + * + * Forms a right triangle from the line's width and thresholdMeters; the + * hypotenuse is the effective proximity bound measured from the driver to + * EACH line endpoint. See DETECTION.md for why this beats a plain + * distance-to-line check. Extracted from DovesLapTimer::insideLineThreshold + * (which delegates here with its configured threshold). + */ +static inline bool geoInsideLineThreshold(double thresholdMeters, double driverLat, double driverLon, double crossingPointALat, double crossingPointALon, double crossingPointBLat, double crossingPointBLon) { + double driverLengthA = geoHaversine(driverLat, driverLon, crossingPointALat, crossingPointALon); + double driverLengthB = geoHaversine(driverLat, driverLon, crossingPointBLat, crossingPointBLon); + double crossingLineLength = geoHaversine(crossingPointALat, crossingPointALon, crossingPointBLat, crossingPointBLon); + double maxLineLength = sqrt(thresholdMeters * thresholdMeters + crossingLineLength * crossingLineLength); + return driverLengthA < maxLineLength && driverLengthB < maxLineLength; +} + #endif diff --git a/test/Makefile b/test/Makefile index d1c1ee5..c9a13ce 100644 --- a/test/Makefile +++ b/test/Makefile @@ -23,6 +23,8 @@ LDFLAGS = # Library sources we link into every test (small enough that always-link # costs nothing and we don't have to per-test bookkeep dependencies). LIB_SRCS = ../src/DovesLapTimer.cpp \ + ../src/CrossingEngine.cpp \ + ../src/SprintTimer.cpp \ ../src/WaypointLapTimer.cpp \ ../src/CourseDetector.cpp \ ../src/CourseManager.cpp From 8d57dc8a34d00a20c65e337c1c4fd71482ffe977 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:23:40 +0000 Subject: [PATCH 2/7] feat: SprintTimer point-to-point run timing + CourseManager::selectCourse SprintTimer: sprint-mode (autocross/hillclimb) timing - a run starts at a START line and ends at a separate FINISH line, up to two optional splits (0/1/2 all legal). Two states, purely line-driven: start crossing begins a run and cancels+restarts one already in progress (botched-course re-launch); finish crossings with no active run are ignored; DNF is nothing special. Every line has its own CrossingEngine so all lines stay hot on every fix and start/finish zones may overlap. Duck-typed to DovesLapTimer's getter surface (laps == runs) plus run-native aliases. CourseManager::selectCourse(int) selects a layout directly, bypassing CourseDetector - required for sprint (point-to-point driving can never satisfy drive-a-lap-back-to-your-waypoint detection) and useful for app-chosen circuit courses. 15 SprintTimer tests over a synthetic 500m open course (exact-by- construction times) + 4 selectCourse tests. Coverage 85.2% (gate 80). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- src/CourseManager.cpp | 15 + src/CourseManager.h | 15 + src/SprintTimer.cpp | 521 +++++++++++++++++++++++++++++++++++ src/SprintTimer.h | 268 ++++++++++++++++++ test/test_course_manager.cpp | 79 ++++++ test/test_sprint_timer.cpp | 408 +++++++++++++++++++++++++++ 6 files changed, 1306 insertions(+) create mode 100644 src/SprintTimer.cpp create mode 100644 src/SprintTimer.h create mode 100644 test/test_sprint_timer.cpp diff --git a/src/CourseManager.cpp b/src/CourseManager.cpp index 0b3159c..3e298c3 100644 --- a/src/CourseManager.cpp +++ b/src/CourseManager.cpp @@ -205,6 +205,21 @@ void CourseManager::_activateLapAnything() { } } +bool CourseManager::selectCourse(int index) { + if (index < 0 || index >= _courseCount) { + return false; + } + _activeCourseIndex = index; + _detectionComplete = true; + _lapAnythingActive = false; + for (int i = 0; i < _courseCount; i++) { + _courseTimers[i].active = (i == index); + } + debug(F("Course selected directly: ")); + debugln(_courseTimers[index].name); + return true; +} + void CourseManager::pruneInactiveCourses() { if (!_detectionComplete || _activeCourseIndex < 0) return; diff --git a/src/CourseManager.h b/src/CourseManager.h index c33249f..ab694b0 100644 --- a/src/CourseManager.h +++ b/src/CourseManager.h @@ -49,6 +49,21 @@ class CourseManager { int loop(double lat, double lng, float altMeters, float speedKnots); void reset(); void pruneInactiveCourses(); + /** + * @brief Selects a course directly, bypassing CourseDetector entirely. + * + * For callers that already know which layout is in play (an app-side + * default course choice, or sprint mode's newest-course-by-date rule — + * point-to-point driving can never satisfy the detector's + * drive-a-lap-back-to-your-waypoint premise). Marks detection complete, + * deactivates every other course timer (same effect as detection + + * pruneInactiveCourses()), and clears any Lap Anything activation. + * reset() returns the manager to normal detection. + * + * @param index Course index [0, getCourseCount()). + * @return True if the index was valid and the course was selected. + */ + bool selectCourse(int index); // Detection state bool isDetectionComplete() const; diff --git a/src/SprintTimer.cpp b/src/SprintTimer.cpp new file mode 100644 index 0000000..5d6c91a --- /dev/null +++ b/src/SprintTimer.cpp @@ -0,0 +1,521 @@ +/** + * SprintTimer - point-to-point run timing. See SprintTimer.h for the model. + */ + +#include "SprintTimer.h" + +#define debugln debug_println +#define debug debug_print + +// Line labels for CrossingEngine debug output. +#define SPRINT_LINE_START 0 +#define SPRINT_LINE_FINISH 1 + +SprintTimer::SprintTimer(double crossingThresholdMeters, Stream *debugSerial) + : _startEngine(crossingThresholdMeters, debugSerial), + _finishEngine(crossingThresholdMeters, debugSerial), + _sector2Engine(crossingThresholdMeters, debugSerial), + _sector3Engine(crossingThresholdMeters, debugSerial) { + _crossingThresholdMeters = crossingThresholdMeters; + _serial = (debugSerial == NULL) ? nullptr : debugSerial; +} + +int SprintTimer::loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots) { + // Fix intake mirrors DovesLapTimer::loop() — reject invalid fixes, + // sanitize auxiliary values, drop single-fix teleports, re-accept a + // sustained relocation without crediting the gap to the odometer. + if (!geoCoordinatesValid(currentLat, currentLng)) { + debugln(F("Rejected invalid GPS coordinates")); + return -1; + } + if (!geoIsFinite(currentAltitudeMeters)) { + currentAltitudeMeters = _positionPrevAlt; + } + if (!geoIsFinite(currentSpeedKnots) || currentSpeedKnots < 0) { + currentSpeedKnots = 0; + } + + if (_firstPositionReceived) { + double jumpDistance = geoHaversine(_positionPrevLat, _positionPrevLng, currentLat, currentLng); + if (jumpDistance > GPS_MAX_PLAUSIBLE_JUMP_METERS) { + _consecutiveJumpCount++; + if (_consecutiveJumpCount < GPS_JUMP_REACCEPT_COUNT) { + debugln(F("Rejected implausible GPS jump")); + return -1; + } + _consecutiveJumpCount = 0; + } else { + _consecutiveJumpCount = 0; + _totalDistanceTraveled += geoHaversine3D( + _positionPrevLat, _positionPrevLng, _positionPrevAlt, + currentLat, currentLng, currentAltitudeMeters); + } + } else { + _firstPositionReceived = true; + } + _positionPrevLat = currentLat; + _positionPrevLng = currentLng; + _positionPrevAlt = currentAltitudeMeters; + _currentSpeedKmh = currentSpeedKnots * GEOMATH_KNOTS_TO_KMH; + + // Every configured line is checked on EVERY fix — independent engines, + // no mutual exclusion (see header). Detect first, then handle results in + // a fixed order: finish before start, so that when both lines complete + // on the same fix (overlapping zones) the active run is completed by the + // finish before the start crossing opens the next one. + const crossingPointBufferEntry *prev = _hasPrevFix ? &_prevFix : NULL; + bool nearAnyLine = false; + + double finLat = 0, finLng = 0, finOdo = 0; + unsigned long finTime = 0; + LineDetectResult finishEv = LINE_DETECT_NONE; + if (_finishLineConfigured) { + finishEv = _finishEngine.detect( + currentLat, currentLng, _millisecondsSinceMidnight, _totalDistanceTraveled, _currentSpeedKmh, prev, + _finishALat, _finishALng, _finishBLat, _finishBLng, + _crossingFinish, SPRINT_LINE_FINISH, finLat, finLng, finTime, finOdo); + if (finishEv != LINE_DETECT_NONE) nearAnyLine = true; + } + + double stLat = 0, stLng = 0, stOdo = 0; + unsigned long stTime = 0; + LineDetectResult startEv = LINE_DETECT_NONE; + if (_startLineConfigured) { + startEv = _startEngine.detect( + currentLat, currentLng, _millisecondsSinceMidnight, _totalDistanceTraveled, _currentSpeedKmh, prev, + _startALat, _startALng, _startBLat, _startBLng, + _crossingStart, SPRINT_LINE_START, stLat, stLng, stTime, stOdo); + if (startEv != LINE_DETECT_NONE) nearAnyLine = true; + } + + double s2Lat = 0, s2Lng = 0, s2Odo = 0; + unsigned long s2Time = 0; + LineDetectResult s2Ev = LINE_DETECT_NONE; + if (_sector2LineConfigured) { + s2Ev = _sector2Engine.detect( + currentLat, currentLng, _millisecondsSinceMidnight, _totalDistanceTraveled, _currentSpeedKmh, prev, + _sector2ALat, _sector2ALng, _sector2BLat, _sector2BLng, + _crossingSector2, 2, s2Lat, s2Lng, s2Time, s2Odo); + if (s2Ev != LINE_DETECT_NONE) nearAnyLine = true; + } + + double s3Lat = 0, s3Lng = 0, s3Odo = 0; + unsigned long s3Time = 0; + LineDetectResult s3Ev = LINE_DETECT_NONE; + if (_sector3LineConfigured) { + s3Ev = _sector3Engine.detect( + currentLat, currentLng, _millisecondsSinceMidnight, _totalDistanceTraveled, _currentSpeedKmh, prev, + _sector3ALat, _sector3ALng, _sector3BLat, _sector3BLng, + _crossingSector3, 3, s3Lat, s3Lng, s3Time, s3Odo); + if (s3Ev != LINE_DETECT_NONE) nearAnyLine = true; + } + + if (finishEv == LINE_DETECT_COMPLETED) { + _handleFinishCrossing(finTime, finOdo); + } + if (startEv == LINE_DETECT_COMPLETED) { + _handleStartCrossing(stTime, stOdo); + } + if (s2Ev == LINE_DETECT_COMPLETED) { + _handleSplitCrossing(2, s2Time); + } + if (s3Ev == LINE_DETECT_COMPLETED) { + _handleSplitCrossing(3, s3Time); + } + + // Save current fix as previous for the engines' Catmull-Rom pre-crossing point + _prevFix.lat = currentLat; + _prevFix.lng = currentLng; + _prevFix.time = _millisecondsSinceMidnight; + _prevFix.odometer = _totalDistanceTraveled; + _prevFix.speedKmh = _currentSpeedKmh; + _hasPrevFix = true; + + return nearAnyLine ? 0 : -1; +} + +void SprintTimer::updateCurrentTime(unsigned long currentTimeMilliseconds) { + _millisecondsSinceMidnight = currentTimeMilliseconds; +} + +/////////// crossing handlers + +void SprintTimer::_handleStartCrossing(unsigned long crossingTime, double crossingOdometer) { + if (_runActive) { + // Botched-course re-launch: cancel the run in progress, start fresh. + _cancelledRuns++; + debugln(F("Start line re-crossed - run cancelled, starting new run")); + } else { + debugln(F("Run started")); + } + _raceStarted = true; + _runActive = true; + _currentRunStartTime = crossingTime; + _currentRunOdometerStart = crossingOdometer; + _currentSegment = 1; + _segmentStartTime = crossingTime; + _segmentOrderValid = true; + for (int i = 0; i <= SPRINT_MAX_SPLITS; i++) { + _currentRunSegTime[i] = 0; + } +} + +void SprintTimer::_handleFinishCrossing(unsigned long crossingTime, double crossingOdometer) { + if (!_runActive) { + // Return loop / staging traffic — a finish crossing with no active run + // means nothing in sprint. + debugln(F("Finish crossing ignored (no active run)")); + return; + } + + // Close the final segment (only when splits are in play). + if (areSectorLinesConfigured()) { + if (_currentSegment == _expectedSegments()) { + _currentRunSegTime[_currentSegment - 1] = timeSinceMidnightDelta(_segmentStartTime, crossingTime); + } else { + // A split was missed (GPS dropout or misplaced line) — the run time + // stands, but its segment data can't feed best-segment tracking. + _segmentOrderValid = false; + } + } + + unsigned long runTime = timeSinceMidnightDelta(_currentRunStartTime, crossingTime); + float runDistance = (float)(crossingOdometer - _currentRunOdometerStart); + + _runs++; + _lastRunTime = runTime; + _lastRunDistance = runDistance; + if (_bestRunTime <= 0 || runTime < _bestRunTime) { + _bestRunTime = runTime; + _bestRunDistance = runDistance; + _bestRunNumber = _runs; + } + + debug(F("Run Finish Time: ")); + debug(runTime); + debug(F(" : ")); + debugln((double)(runTime / 1000.0), 3); + + if (areSectorLinesConfigured() && _segmentOrderValid) { + _updateBestSegments(); + } + + _runActive = false; + _currentSegment = 0; +} + +void SprintTimer::_handleSplitCrossing(int sectorLabel, unsigned long crossingTime) { + if (!_runActive) { + // Splits mean nothing outside a run (return loop traffic). + return; + } + + int closesSegment = _segmentClosedBySector(sectorLabel); + if (_currentSegment == closesSegment) { + _currentRunSegTime[closesSegment - 1] = timeSinceMidnightDelta(_segmentStartTime, crossingTime); + _currentSegment = closesSegment + 1; + _segmentStartTime = crossingTime; + + debug(F("Segment ")); + debug(closesSegment); + debug(F(" Time: ")); + debugln(_currentRunSegTime[closesSegment - 1]); + } else { + debug(F("WARNING: Sector ")); + debug(sectorLabel); + debug(F(" crossed out of order (current segment: ")); + debug(_currentSegment); + debugln(F(") - run sector data invalidated")); + _segmentOrderValid = false; + } +} + +int SprintTimer::_expectedSegments() const { + return 1 + (_sector2LineConfigured ? 1 : 0) + (_sector3LineConfigured ? 1 : 0); +} + +int SprintTimer::_segmentClosedBySector(int sectorLabel) const { + if (sectorLabel == 2) { + return 1; // sector 2, when configured, is always the first split + } + return _sector2LineConfigured ? 2 : 1; // sector 3 follows sector 2 if present +} + +void SprintTimer::_updateBestSegments() { + // Only runs with every expected segment recorded, in order, may feed the + // best-segment table — otherwise a short/invalid segment from a missed + // split would fabricate an unbeatable "best". + int expected = _expectedSegments(); + for (int i = 0; i < expected; i++) { + if (_currentRunSegTime[i] == 0) { + return; + } + } + for (int i = 0; i < expected; i++) { + if (_bestSegTime[i] == 0 || _currentRunSegTime[i] < _bestSegTime[i]) { + _bestSegTime[i] = _currentRunSegTime[i]; + _bestSegRunNumber[i] = _runs; + } + } +} + +/////////// configuration + +// A crossing line is usable only if both endpoints are finite and distinct +// (same rule as DovesLapTimer's setters). +static bool sprintLineIsValid(double aLat, double aLng, double bLat, double bLng) { + if (!geoIsFinite(aLat) || !geoIsFinite(aLng) || !geoIsFinite(bLat) || !geoIsFinite(bLng)) { + return false; + } + return aLat != bLat || aLng != bLng; +} + +void SprintTimer::setStartLine(double pointALat, double pointALng, double pointBLat, double pointBLng) { + _startALat = pointALat; _startALng = pointALng; + _startBLat = pointBLat; _startBLng = pointBLng; + _startLineConfigured = sprintLineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!_startLineConfigured) { + debugln(F("WARNING: invalid start line (degenerate or non-finite) - detection disabled")); + } +} + +void SprintTimer::setFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng) { + _finishALat = pointALat; _finishALng = pointALng; + _finishBLat = pointBLat; _finishBLng = pointBLng; + _finishLineConfigured = sprintLineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!_finishLineConfigured) { + debugln(F("WARNING: invalid finish line (degenerate or non-finite) - detection disabled")); + } +} + +void SprintTimer::setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { + _sector2ALat = pointALat; _sector2ALng = pointALng; + _sector2BLat = pointBLat; _sector2BLng = pointBLng; + _sector2LineConfigured = sprintLineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!_sector2LineConfigured) { + debugln(F("WARNING: invalid sector 2 line (degenerate or non-finite) - sector disabled")); + } +} + +void SprintTimer::setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng) { + _sector3ALat = pointALat; _sector3ALng = pointALng; + _sector3BLat = pointBLat; _sector3BLng = pointBLng; + _sector3LineConfigured = sprintLineIsValid(pointALat, pointALng, pointBLat, pointBLng); + if (!_sector3LineConfigured) { + debugln(F("WARNING: invalid sector 3 line (degenerate or non-finite) - sector disabled")); + } +} + +bool SprintTimer::isStartLineConfigured() const { + return _startLineConfigured; +} + +bool SprintTimer::isFinishLineConfigured() const { + return _finishLineConfigured; +} + +bool SprintTimer::areSectorLinesConfigured() const { + return _sector2LineConfigured || _sector3LineConfigured; +} + +void SprintTimer::forceLinearInterpolation() { + _startEngine.setForceLinear(true); + _finishEngine.setForceLinear(true); + _sector2Engine.setForceLinear(true); + _sector3Engine.setForceLinear(true); +} + +void SprintTimer::forceCatmullRomInterpolation() { + _startEngine.setForceLinear(false); + _finishEngine.setForceLinear(false); + _sector2Engine.setForceLinear(false); + _sector3Engine.setForceLinear(false); +} + +void SprintTimer::reset() { + debugln(F("Resetting sprint timer...")); + _raceStarted = false; + _runActive = false; + _runs = 0; + _cancelledRuns = 0; + _currentRunStartTime = 0; + _currentRunOdometerStart = 0; + _lastRunTime = 0; + _lastRunDistance = 0; + _bestRunTime = 0; + _bestRunDistance = 0; + _bestRunNumber = 0; + + _currentSegment = 0; + _segmentStartTime = 0; + _segmentOrderValid = true; + for (int i = 0; i <= SPRINT_MAX_SPLITS; i++) { + _currentRunSegTime[i] = 0; + _bestSegTime[i] = 0; + _bestSegRunNumber[i] = 0; + } + + _millisecondsSinceMidnight = 0; + _totalDistanceTraveled = 0; + _positionPrevLat = 0; + _positionPrevLng = 0; + _positionPrevAlt = 0; + _firstPositionReceived = false; + _consecutiveJumpCount = 0; + _currentSpeedKmh = 0; + _prevFix.lat = 0; + _prevFix.lng = 0; + _prevFix.time = 0; + _prevFix.odometer = 0; + _prevFix.speedKmh = 0; + _hasPrevFix = false; + + _crossingStart = false; + _crossingFinish = false; + _crossingSector2 = false; + _crossingSector3 = false; + _startEngine.reset(); + _finishEngine.reset(); + _sector2Engine.reset(); + _sector3Engine.reset(); +} + +/////////// getters + +bool SprintTimer::isRunActive() const { + return _runActive; +} + +int SprintTimer::getRuns() const { + return _runs; +} + +int SprintTimer::getCancelledRunCount() const { + return _cancelledRuns; +} + +unsigned long SprintTimer::getCurrentRunTime() const { + return _runActive ? timeSinceMidnightDelta(_currentRunStartTime, _millisecondsSinceMidnight) : 0; +} + +unsigned long SprintTimer::getLastRunTime() const { + return _lastRunTime; +} + +unsigned long SprintTimer::getBestRunTime() const { + return _bestRunTime; +} + +int SprintTimer::getBestRunNumber() const { + return _bestRunNumber; +} + +float SprintTimer::getCurrentRunDistance() const { + return _runActive ? (float)(_totalDistanceTraveled - _currentRunOdometerStart) : 0; +} + +float SprintTimer::getLastRunDistance() const { + return _lastRunDistance; +} + +float SprintTimer::getBestRunDistance() const { + return _bestRunDistance; +} + +bool SprintTimer::getRaceStarted() const { + return _raceStarted; +} + +bool SprintTimer::getCrossing() const { + return _crossingStart || _crossingFinish || _crossingSector2 || _crossingSector3; +} + +float SprintTimer::getTotalDistanceTraveled() const { + return _totalDistanceTraveled; +} + +float SprintTimer::getPaceDifference() const { + if (!_runActive) { + return 0.0; + } + float currentRunDistance = (float)(_totalDistanceTraveled - _currentRunOdometerStart); + unsigned long currentRunTime = timeSinceMidnightDelta(_currentRunStartTime, _millisecondsSinceMidnight); + + if (currentRunDistance == 0 || _bestRunDistance == 0) { + return 0.0; + } + + float currentRunPace = currentRunTime / currentRunDistance; + float bestRunPace = _bestRunTime / _bestRunDistance; + return currentRunPace - bestRunPace; +} + +float SprintTimer::getCurrentSpeedKmh() const { + return _currentSpeedKmh; +} + +float SprintTimer::getCurrentSpeedMph() const { + return _currentSpeedKmh * GEOMATH_KMH_TO_MPH; +} + +int SprintTimer::getCurrentSector() const { + return _runActive ? _currentSegment : 0; +} + +unsigned long SprintTimer::getCurrentLapSector1Time() const { + return _currentRunSegTime[0]; +} + +unsigned long SprintTimer::getCurrentLapSector2Time() const { + return _currentRunSegTime[1]; +} + +unsigned long SprintTimer::getCurrentLapSector3Time() const { + return _currentRunSegTime[2]; +} + +unsigned long SprintTimer::getBestSector1Time() const { + return _bestSegTime[0]; +} + +unsigned long SprintTimer::getBestSector2Time() const { + return _bestSegTime[1]; +} + +unsigned long SprintTimer::getBestSector3Time() const { + return _bestSegTime[2]; +} + +int SprintTimer::getBestSector1LapNumber() const { + return _bestSegRunNumber[0]; +} + +int SprintTimer::getBestSector2LapNumber() const { + return _bestSegRunNumber[1]; +} + +int SprintTimer::getBestSector3LapNumber() const { + return _bestSegRunNumber[2]; +} + +unsigned long SprintTimer::getOptimalLapTime() const { + if (!areSectorLinesConfigured()) { + return 0; + } + int expected = _expectedSegments(); + unsigned long sum = 0; + for (int i = 0; i < expected; i++) { + if (_bestSegTime[i] == 0) { + return 0; + } + sum += _bestSegTime[i]; + } + return sum; +} + +unsigned int SprintTimer::getRejectedCrossingCount() const { + return _startEngine.getRejectedCrossingCount() + + _finishEngine.getRejectedCrossingCount() + + _sector2Engine.getRejectedCrossingCount() + + _sector3Engine.getRejectedCrossingCount(); +} diff --git a/src/SprintTimer.h b/src/SprintTimer.h new file mode 100644 index 0000000..3983c35 --- /dev/null +++ b/src/SprintTimer.h @@ -0,0 +1,268 @@ +/** + * SprintTimer - point-to-point run timing ("sprint mode": autocross, + * hillclimb, rally-stage style events). + * + * Unlike DovesLapTimer there are no laps: a run begins at a START line and + * ends at a SEPARATE FINISH line, with up to two optional split (sector) + * lines between them. Multiple runs happen per session — the driver loops + * back around to the start and goes again. + * + * Run semantics (deliberately simple, two states): + * - WAITING: a start-line crossing begins a run. + * - RUNNING: a finish-line crossing completes the run; + * a START-line crossing CANCELS the run in progress and starts + * a brand new one (botched-course re-launch — the sprint + * equivalent of circuit's every-S/F-crossing-closes-and-opens). + * - Finish crossings while WAITING are ignored (driving back past the + * finish on the return loop must not do anything). + * - A DNF is just normal operation: an abandoned run never completes and + * records nothing. + * + * ALL lines are monitored on every fix — there is no mutual exclusion. + * Every line owns its own CrossingEngine (buffer + interpolation), so the + * start and finish zones may sit arbitrarily close together (common at + * autocross paddocks) without contending for a shared buffer. The cost is + * RAM: ~2.9 KB per configured line's buffer on 32-bit targets (~11.6 KB for + * a fully-configured instance). SprintTimer is meant to be instantiated + * once, not pooled like CourseManager's 8-slot timer array. Not suitable + * for AVR Uno; tight on Mega. + * + * Sector/split model: segments are numbered 1..N in crossing order, where + * N = 1 + (number of configured splits). Sector 2 (if configured) always + * closes segment 1; sector 3 closes the next segment; the finish closes the + * last. A split crossed out of order, or missed entirely, invalidates the + * run's SECTOR data (best-segment tracking skips that run) but never the + * run time itself. Zero, one, or two splits are all legal — unlike the + * circuit timer there is no both-or-nothing sector gate. + * + * Direction note: crossing detection is direction-agnostic. A backward + * start crossing on the return loop opens a bogus run, which the + * cancel-and-restart rule self-heals at the real launch. A backward FINISH + * crossing while RUNNING is the only truly bogus completion; it requires + * re-entering the finish zone mid-run, which a one-way sprint course + * doesn't do. + * + * Duck-typed to DovesLapTimer's public getter surface (laps == runs) so + * displays/loggers can consume either timer interchangeably; run-native + * aliases (getRuns, getBestRunTime, ...) are provided for new code. + */ + +#ifndef _DOVES_SPRINT_TIMER_H +#define _DOVES_SPRINT_TIMER_H + +#include +#include "GeoMath.h" +#include "CrossingEngine.h" +#include "DovesLapTimer.h" // DIR_UNKNOWN, GPS_* validation constants + +// The maximum number of split (sector) lines between start and finish. +#define SPRINT_MAX_SPLITS 2 + +class SprintTimer { +public: + SprintTimer(double crossingThresholdMeters = 7, Stream *debugSerial = NULL); + + /** + * @brief Feed a GPS fix. Call updateCurrentTime() first, then this, on + * every fix — identical contract to DovesLapTimer::loop(). + * + * @param currentLat Latitude in decimal degrees. + * @param currentLng Longitude in decimal degrees. + * @param currentAltitudeMeters Altitude in meters. + * @param currentSpeedKnots Speed in knots. + * @return 0 if near any configured line this fix, -1 otherwise (or the + * fix was rejected as invalid). + */ + int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots); + /** + * @brief Updates the current GPS time since midnight (milliseconds). + */ + void updateCurrentTime(unsigned long currentTimeMilliseconds); + /** + * @brief Reset all run/timing state to zero. Configured lines are kept. + */ + void reset(); + + ///////////////////////////////////////////////////////////////////////////// + // Configuration + + /** + * @brief Sets the START line (a run begins when it is crossed). + */ + void setStartLine(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Sets the FINISH line (a run completes when it is crossed while + * a run is active). + */ + void setFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Sets the optional sector 2 split line (closes segment 1). + */ + void setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Sets the optional sector 3 split line (closes the segment after + * sector 2's, or segment 1 if sector 2 is not configured). + */ + void setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng); + + bool isStartLineConfigured() const; + bool isFinishLineConfigured() const; + /** + * @brief True when at least one split line is configured (note: NOT the + * circuit timer's both-or-nothing rule — a single split is legal). + */ + bool areSectorLinesConfigured() const; + + void forceLinearInterpolation(); + void forceCatmullRomInterpolation(); + + ///////////////////////////////////////////////////////////////////////////// + // Run-native surface + + /** @brief True while a run is in progress (between start and finish). */ + bool isRunActive() const; + /** @brief Completed run count this session. */ + int getRuns() const; + /** @brief Runs cancelled by re-crossing the start line mid-run. */ + int getCancelledRunCount() const; + /** @brief Elapsed ms of the run in progress, 0 while WAITING. */ + unsigned long getCurrentRunTime() const; + unsigned long getLastRunTime() const; + unsigned long getBestRunTime() const; + /** @brief 1-based run number that set the best time (0 = none yet). */ + int getBestRunNumber() const; + float getCurrentRunDistance() const; + float getLastRunDistance() const; + float getBestRunDistance() const; + + ///////////////////////////////////////////////////////////////////////////// + // Duck-typed DovesLapTimer surface (laps == runs) + + /** @brief True once any run has ever started this session. */ + bool getRaceStarted() const; + /** @brief True while inside any configured line's crossing zone. */ + bool getCrossing() const; + int getLaps() const { return getRuns(); } + unsigned long getCurrentLapTime() const { return getCurrentRunTime(); } + unsigned long getLastLapTime() const { return getLastRunTime(); } + unsigned long getBestLapTime() const { return getBestRunTime(); } + int getBestLapNumber() const { return getBestRunNumber(); } + float getCurrentLapDistance() const { return getCurrentRunDistance(); } + float getLastLapDistance() const { return getLastRunDistance(); } + float getBestLapDistance() const { return getBestRunDistance(); } + float getTotalDistanceTraveled() const; + /** + * @brief Pace delta vs the best run in ms per meter (see + * DovesLapTimer::getPaceDifference). 0 while WAITING. + */ + float getPaceDifference() const; + float getCurrentSpeedKmh() const; + float getCurrentSpeedMph() const; + + /** + * @brief Current segment (1..N) while a run is active, 0 while WAITING. + */ + int getCurrentSector() const; + unsigned long getCurrentLapSector1Time() const; + unsigned long getCurrentLapSector2Time() const; + unsigned long getCurrentLapSector3Time() const; + unsigned long getBestSector1Time() const; + unsigned long getBestSector2Time() const; + unsigned long getBestSector3Time() const; + int getBestSector1LapNumber() const; + int getBestSector2LapNumber() const; + int getBestSector3LapNumber() const; + /** + * @brief Sum of best segment times (theoretical best run). 0 unless at + * least one split is configured and every segment has a best recorded. + */ + unsigned long getOptimalLapTime() const; + + /** @brief Total zone exits whose interpolation was rejected, all lines. */ + unsigned int getRejectedCrossingCount() const; + + // Direction (not applicable point-to-point) + int getDirection() const { return DIR_UNKNOWN; } + bool isDirectionResolved() const { return false; } + +private: + template + void debug_print(Args&&... args) { + if(_serial) { _serial->print(std::forward(args)...); } + } + template + void debug_println(Args&&... args) { + if(_serial) { _serial->println(std::forward(args)...); } + } + + /** @brief Segments in a complete run: 1 + configured splits. */ + int _expectedSegments() const; + /** @brief Which segment a given split line (2 or 3) closes. */ + int _segmentClosedBySector(int sectorLabel) const; + void _handleStartCrossing(unsigned long crossingTime, double crossingOdometer); + void _handleFinishCrossing(unsigned long crossingTime, double crossingOdometer); + void _handleSplitCrossing(int sectorLabel, unsigned long crossingTime); + void _updateBestSegments(); + + Stream *_serial; + double _crossingThresholdMeters; + + // One engine per line: independent buffers, so zones may overlap and all + // lines stay hot on every fix. This is the deliberate opposite of + // DovesLapTimer's shared-buffer design — see the header comment. + CrossingEngine _startEngine; + CrossingEngine _finishEngine; + CrossingEngine _sector2Engine; + CrossingEngine _sector3Engine; + + // Line endpoints + configuration + in-zone flags + double _startALat = 0, _startALng = 0, _startBLat = 0, _startBLng = 0; + double _finishALat = 0, _finishALng = 0, _finishBLat = 0, _finishBLng = 0; + double _sector2ALat = 0, _sector2ALng = 0, _sector2BLat = 0, _sector2BLng = 0; + double _sector3ALat = 0, _sector3ALng = 0, _sector3BLat = 0, _sector3BLng = 0; + bool _startLineConfigured = false; + bool _finishLineConfigured = false; + bool _sector2LineConfigured = false; + bool _sector3LineConfigured = false; + bool _crossingStart = false; + bool _crossingFinish = false; + bool _crossingSector2 = false; + bool _crossingSector3 = false; + + // Vehicle state (same intake pipeline as DovesLapTimer::loop) + unsigned long _millisecondsSinceMidnight = 0; + float _totalDistanceTraveled = 0; + double _positionPrevLat = 0; + double _positionPrevLng = 0; + float _positionPrevAlt = 0; + bool _firstPositionReceived = false; + int _consecutiveJumpCount = 0; + float _currentSpeedKmh = 0; + crossingPointBufferEntry _prevFix = {0, 0, 0, 0, 0}; + bool _hasPrevFix = false; + + // Run state + bool _raceStarted = false; // any run ever started this session + bool _runActive = false; // RUNNING vs WAITING + int _runs = 0; + int _cancelledRuns = 0; + unsigned long _currentRunStartTime = 0; + double _currentRunOdometerStart = 0; + unsigned long _lastRunTime = 0; + float _lastRunDistance = 0; + unsigned long _bestRunTime = 0; + float _bestRunDistance = 0; + int _bestRunNumber = 0; + + // Segment (split) state for the run in progress + int _currentSegment = 0; // 0 = WAITING, 1..N while RUNNING + unsigned long _segmentStartTime = 0; + bool _segmentOrderValid = true; + unsigned long _currentRunSegTime[SPRINT_MAX_SPLITS + 1] = {0, 0, 0}; + + // Best segment times across runs (valid, in-order runs only) + unsigned long _bestSegTime[SPRINT_MAX_SPLITS + 1] = {0, 0, 0}; + int _bestSegRunNumber[SPRINT_MAX_SPLITS + 1] = {0, 0, 0}; +}; + +#endif diff --git a/test/test_course_manager.cpp b/test/test_course_manager.cpp index 66925a5..8c5abda 100644 --- a/test/test_course_manager.cpp +++ b/test/test_course_manager.cpp @@ -254,6 +254,80 @@ void test_prune_deactivates_non_detected_courses() { EXPECT_TRUE(mgr.getActiveTimer() != NULL); } +// ============================================================================= +// selectCourse — direct selection, no detection (sprint mode / app default) +// ============================================================================= + +void test_select_course_bypasses_detection() { + TrackConfig cfg = {}; + cfg.longName = "TwoLayouts"; + cfg.shortName = "2L"; + cfg.courseCount = 2; + cfg.courses[0] = matchingCourse("Layout A"); + cfg.courses[1] = matchingCourse("Layout B"); + + CourseManager mgr(cfg); + EXPECT_FALSE(mgr.isDetectionComplete()); + + EXPECT_TRUE(mgr.selectCourse(1)); + EXPECT_TRUE(mgr.isDetectionComplete()); + EXPECT_FALSE(mgr.isLapAnythingActive()); + EXPECT_EQ(mgr.getActiveCourseIndex(), 1); + EXPECT_TRUE(mgr.getActiveTimer() != NULL); + EXPECT_TRUE(strcmp(mgr.getActiveCourseName(), "Layout B") == 0); + // Only the selected course keeps being fed. + EXPECT_FALSE(mgr.isCourseTimerActive(0)); + EXPECT_TRUE(mgr.isCourseTimerActive(1)); +} + +void test_select_course_rejects_bad_index() { + TrackConfig cfg = {}; + cfg.longName = "One"; + cfg.shortName = "1"; + cfg.courseCount = 1; + cfg.courses[0] = matchingCourse("Only"); + + CourseManager mgr(cfg); + EXPECT_FALSE(mgr.selectCourse(-1)); + EXPECT_FALSE(mgr.selectCourse(1)); + EXPECT_FALSE(mgr.isDetectionComplete()); + EXPECT_EQ(mgr.getActiveCourseIndex(), -1); +} + +void test_selected_course_times_laps_without_detector() { + // The whole point: laps count from the very first crossing, with no + // drive-a-lap-back-to-the-waypoint detection pass ever happening. + TrackConfig cfg = {}; + cfg.longName = "Synthetic"; + cfg.shortName = "SYN"; + cfg.courseCount = 1; + cfg.courses[0] = matchingCourse("Square 400m"); + + CourseManager mgr(cfg); + EXPECT_TRUE(mgr.selectCourse(0)); + driveLaps(mgr, 3 * STEPS_PER_LAP + 30); + + DovesLapTimer *timer = mgr.getActiveTimer(); + EXPECT_TRUE(timer != NULL); + EXPECT_TRUE(timer->getLaps() >= 2); + EXPECT_NEAR(timer->getLastLapTime(), STEPS_PER_LAP * MS_PER_STEP, 100.0); + EXPECT_EQ(mgr.getDetectionRejectionCount(), 0); +} + +void test_reset_clears_selected_course() { + TrackConfig cfg = {}; + cfg.longName = "One"; + cfg.shortName = "1"; + cfg.courseCount = 1; + cfg.courses[0] = matchingCourse("Only"); + + CourseManager mgr(cfg); + EXPECT_TRUE(mgr.selectCourse(0)); + mgr.reset(); + EXPECT_FALSE(mgr.isDetectionComplete()); + EXPECT_TRUE(mgr.getActiveTimer() == NULL); +} + // ============================================================================= // reset // ============================================================================= @@ -294,6 +368,11 @@ int main() { RUN_TEST(prune_deactivates_non_detected_courses); + RUN_TEST(select_course_bypasses_detection); + RUN_TEST(select_course_rejects_bad_index); + RUN_TEST(selected_course_times_laps_without_detector); + RUN_TEST(reset_clears_selected_course); + RUN_TEST(reset_restores_detection_state); TEST_SUMMARY(); diff --git a/test/test_sprint_timer.cpp b/test/test_sprint_timer.cpp new file mode 100644 index 0000000..43a7af9 --- /dev/null +++ b/test/test_sprint_timer.cpp @@ -0,0 +1,408 @@ +/** + * Layer-2 integration tests for SprintTimer (point-to-point run timing). + * + * Drives the timer over a deterministic synthetic OPEN course — a 500 m + * straight with a start line at x=50 m, splits at x=150 m (S2) and + * x=300 m (S3), and a finish line at x=450 m — then loops back on a + * parallel return leg 40 m north (outside every crossing zone). + * + * Step: 5 m per 200 ms fix (25 m/s ≈ 48.6 kn), matching the circuit + * synthetic test. Expected values are exact by construction: + * - run time: 400 m = 16000 ms + * - segment 1: 100 m = 4000 ms (start → S2) + * - segment 2: 150 m = 6000 ms (S2 → S3) + * - segment 3: 150 m = 6000 ms (S3 → finish) + * + * Covers the decided sprint semantics: + * - runs complete with correct, deterministic times across multiple runs + * - start crossing while RUNNING cancels + restarts (botched-course rule) + * - finish crossings while WAITING are ignored + * - a backward start crossing opens a run (self-heal documented behavior) + * - 0 / 1 / 2 splits are all legal; single split has a real optimal + * - a missed split invalidates the run's sector data but not the run + */ + +#include "test_runner.h" +#include "../src/SprintTimer.h" + +// ============================================================================= +// Synthetic open course +// ============================================================================= + +static constexpr double BASE_LAT = 28.40000; +static constexpr double BASE_LNG = -81.40000; +// Haversine-consistent meters→degrees at BASE_LAT (R = 6371 km). +static constexpr double M_PER_DEG_LAT = 111194.9266; +static constexpr double M_PER_DEG_LNG = 111194.9266 * 0.879649; // cos(28.4°) + +static double latAt(double yMeters) { return BASE_LAT + yMeters / M_PER_DEG_LAT; } +static double lngAt(double xMeters) { return BASE_LNG + xMeters / M_PER_DEG_LNG; } + +static constexpr double START_X = 50.0; +static constexpr double S2_X = 150.0; +static constexpr double S3_X = 300.0; +static constexpr double FINISH_X = 450.0; +static constexpr double COURSE_END_X = 500.0; +static constexpr double RETURN_Y = 40.0; // return leg offset, outside all zones +static constexpr double LINE_HALF_WIDTH = 10.0; // lines span y in [-10, +10] + +static constexpr double STEP_METERS = 5.0; +static constexpr unsigned long MS_PER_STEP = 200; +static constexpr float SIM_SPEED_KNOTS = 48.0f; +static constexpr float SIM_ALT_METERS = 50.0f; + +static void setLine(SprintTimer &t, void (SprintTimer::*setter)(double, double, double, double), double xMeters) { + (t.*setter)(latAt(-LINE_HALF_WIDTH), lngAt(xMeters), latAt(LINE_HALF_WIDTH), lngAt(xMeters)); +} + +static void configureFullCourse(SprintTimer &t) { + setLine(t, &SprintTimer::setStartLine, START_X); + setLine(t, &SprintTimer::setFinishLine, FINISH_X); + setLine(t, &SprintTimer::setSector2Line, S2_X); + setLine(t, &SprintTimer::setSector3Line, S3_X); +} + +// Simple fix-feeding driver: walks the timer along a piecewise path of +// (x, y) waypoints in STEP_METERS increments, advancing sim time per step. +struct PathDriver { + SprintTimer &timer; + unsigned long simTimeMs = 0; + double x = 0, y = 0; + + explicit PathDriver(SprintTimer &t) : timer(t) {} + + void feed() { + timer.updateCurrentTime(simTimeMs); + timer.loop(latAt(y), lngAt(x), SIM_ALT_METERS, SIM_SPEED_KNOTS); + simTimeMs += MS_PER_STEP; + } + + // Move in a straight line to (targetX, targetY) in STEP_METERS steps, + // feeding a fix at every step (including the final position). + void driveTo(double targetX, double targetY) { + double dx = targetX - x; + double dy = targetY - y; + double dist = sqrt(dx * dx + dy * dy); + int steps = (int)(dist / STEP_METERS + 0.5); + if (steps < 1) steps = 1; + for (int i = 1; i <= steps; i++) { + x += dx / steps; + y += dy / steps; + feed(); + } + } + + // One full out-and-back circuit: out along y=0 crossing every line, then + // return on the y=RETURN_Y leg and rejoin the start of the course. + void lapCourse() { + driveTo(COURSE_END_X, 0); + driveTo(COURSE_END_X, RETURN_Y); + driveTo(0, RETURN_Y); + driveTo(0, 0); + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +void test_initial_state() { + SprintTimer t(7.0); + configureFullCourse(t); + EXPECT_TRUE(t.isStartLineConfigured()); + EXPECT_TRUE(t.isFinishLineConfigured()); + EXPECT_TRUE(t.areSectorLinesConfigured()); + EXPECT_FALSE(t.isRunActive()); + EXPECT_FALSE(t.getRaceStarted()); + EXPECT_EQ(t.getRuns(), 0); + EXPECT_EQ(t.getCurrentRunTime(), 0UL); + EXPECT_EQ(t.getLaps(), 0); // duck-typed alias +} + +void test_single_run_time_exact() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.driveTo(COURSE_END_X, 0); + + EXPECT_EQ(t.getRuns(), 1); + EXPECT_TRUE(t.getRaceStarted()); + EXPECT_FALSE(t.isRunActive()); // finished, back to WAITING + EXPECT_NEAR(t.getLastRunTime(), 16000UL, 100.0); + EXPECT_NEAR(t.getLastRunDistance(), 400.0f, 10.0); + EXPECT_EQ(t.getBestRunNumber(), 1); +} + +void test_run_active_between_start_and_finish() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.driveTo(100, 0); // past start (50), before S2 + EXPECT_TRUE(t.isRunActive()); + EXPECT_TRUE(t.getCurrentRunTime() > 0); + EXPECT_EQ(t.getCurrentSector(), 1); + + d.driveTo(200, 0); // past S2 + EXPECT_EQ(t.getCurrentSector(), 2); + + d.driveTo(350, 0); // past S3 + EXPECT_EQ(t.getCurrentSector(), 3); + + d.driveTo(COURSE_END_X, 0); // past finish + EXPECT_FALSE(t.isRunActive()); + EXPECT_EQ(t.getCurrentSector(), 0); + EXPECT_EQ(t.getCurrentRunTime(), 0UL); // the *waiting* signal +} + +void test_three_runs_deterministic() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + unsigned long runTimes[3]; + for (int i = 0; i < 3; i++) { + d.lapCourse(); + runTimes[i] = t.getLastRunTime(); + } + + EXPECT_EQ(t.getRuns(), 3); + EXPECT_EQ(t.getCancelledRunCount(), 0); + EXPECT_NEAR(runTimes[0], 16000UL, 100.0); + EXPECT_NEAR(runTimes[0], runTimes[1], 5.0); + EXPECT_NEAR(runTimes[1], runTimes[2], 5.0); +} + +void test_segment_times_exact() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.driveTo(COURSE_END_X, 0); + + EXPECT_NEAR(t.getCurrentLapSector1Time(), 4000UL, 100.0); + EXPECT_NEAR(t.getCurrentLapSector2Time(), 6000UL, 100.0); + EXPECT_NEAR(t.getCurrentLapSector3Time(), 6000UL, 100.0); + EXPECT_NEAR(t.getBestSector1Time(), 4000UL, 100.0); + EXPECT_NEAR(t.getBestSector2Time(), 6000UL, 100.0); + EXPECT_NEAR(t.getBestSector3Time(), 6000UL, 100.0); + EXPECT_EQ(t.getBestSector1LapNumber(), 1); + + // Segments sum to the run time, and optimal == best on identical runs. + unsigned long segSum = t.getBestSector1Time() + t.getBestSector2Time() + t.getBestSector3Time(); + EXPECT_NEAR(segSum, t.getBestRunTime(), 5.0); + EXPECT_NEAR(t.getOptimalLapTime(), t.getBestRunTime(), 5.0); +} + +void test_start_recross_cancels_and_restarts() { + // Botched course: driver gets to x=250 (past S2), gives up, loops back + // around on the return leg, re-crosses the start, and runs the full + // course. The aborted attempt must be cancelled — one completed run, + // one cancellation, and the completed run's time is a clean 16000 ms + // from the SECOND start crossing. + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.driveTo(250, 0); // start crossed, S2 crossed, mid-course + EXPECT_TRUE(t.isRunActive()); + d.driveTo(250, RETURN_Y); // bail out north + d.driveTo(0, RETURN_Y); // return leg (no zones) + d.driveTo(0, 0); // rejoin course start + EXPECT_TRUE(t.isRunActive()); // aborted run still nominally active + d.driveTo(COURSE_END_X, 0); // full clean run + + EXPECT_EQ(t.getRuns(), 1); + EXPECT_EQ(t.getCancelledRunCount(), 1); + EXPECT_NEAR(t.getLastRunTime(), 16000UL, 100.0); + EXPECT_NEAR(t.getBestRunTime(), 16000UL, 100.0); +} + +void test_finish_crossing_ignored_while_waiting() { + // Drive east-to-west across the finish line with no run active: nothing + // may start, complete, or count. + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + d.x = COURSE_END_X; + d.y = 0; + + d.driveTo(400, 0); // backward across the finish (450) + + EXPECT_EQ(t.getRuns(), 0); + EXPECT_FALSE(t.isRunActive()); + EXPECT_FALSE(t.getRaceStarted()); + EXPECT_EQ(t.getLastRunTime(), 0UL); +} + +void test_backward_start_crossing_opens_run() { + // Documented self-heal behavior: crossing detection is direction + // agnostic, so a backward start crossing opens a (bogus) run — which + // the next forward start crossing cancels. The timed result of the real + // launch is unaffected. + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + d.x = COURSE_END_X; + d.y = 0; + + d.driveTo(0, 0); // backward down the whole course, crossing start last + EXPECT_TRUE(t.isRunActive()); // bogus run opened... + EXPECT_EQ(t.getRuns(), 0); + + d.driveTo(COURSE_END_X, 0); // ...real launch cancels it, clean run + EXPECT_EQ(t.getRuns(), 1); + EXPECT_EQ(t.getCancelledRunCount(), 1); + EXPECT_NEAR(t.getLastRunTime(), 16000UL, 100.0); +} + +void test_single_split_is_legal() { + // Only S2 configured: two segments (start→S2 = 4000, S2→finish = 12000), + // and a real optimal — unlike the circuit timer's both-or-nothing gate. + SprintTimer t(7.0); + setLine(t, &SprintTimer::setStartLine, START_X); + setLine(t, &SprintTimer::setFinishLine, FINISH_X); + setLine(t, &SprintTimer::setSector2Line, S2_X); + PathDriver d(t); + + EXPECT_TRUE(t.areSectorLinesConfigured()); + d.driveTo(COURSE_END_X, 0); + + EXPECT_EQ(t.getRuns(), 1); + EXPECT_NEAR(t.getCurrentLapSector1Time(), 4000UL, 100.0); + EXPECT_NEAR(t.getCurrentLapSector2Time(), 12000UL, 100.0); + EXPECT_EQ(t.getCurrentLapSector3Time(), 0UL); + EXPECT_NEAR(t.getOptimalLapTime(), t.getBestRunTime(), 5.0); +} + +void test_no_splits_still_times_runs() { + SprintTimer t(7.0); + setLine(t, &SprintTimer::setStartLine, START_X); + setLine(t, &SprintTimer::setFinishLine, FINISH_X); + PathDriver d(t); + + EXPECT_FALSE(t.areSectorLinesConfigured()); + d.driveTo(COURSE_END_X, 0); + + EXPECT_EQ(t.getRuns(), 1); + EXPECT_NEAR(t.getLastRunTime(), 16000UL, 100.0); + EXPECT_EQ(t.getOptimalLapTime(), 0UL); + EXPECT_EQ(t.getBestSector1Time(), 0UL); +} + +void test_missed_split_invalidates_sector_data_not_run() { + // Detour around S2 (northward bulge from x=125 to x=175), then finish + // normally. The run completes and counts; segment data must not feed + // the best-segment table. + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.driveTo(125, 0); + d.driveTo(125, RETURN_Y); // detour north, S2 zone never entered + d.driveTo(175, RETURN_Y); + d.driveTo(175, 0); + d.driveTo(COURSE_END_X, 0); // S3 + finish crossed normally + + EXPECT_EQ(t.getRuns(), 1); + EXPECT_TRUE(t.getLastRunTime() > 16000UL); // detour cost real time + EXPECT_EQ(t.getBestSector1Time(), 0UL); // no best segments recorded + EXPECT_EQ(t.getBestSector2Time(), 0UL); + EXPECT_EQ(t.getOptimalLapTime(), 0UL); +} + +void test_best_run_tracking_across_unequal_runs() { + // Run 1 clean (16000). Run 2 with a mid-course detour (slower). Best + // must stay run 1. + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.lapCourse(); // run 1: 16000 + d.driveTo(200, 0); + d.driveTo(200, RETURN_Y); // detour (S3 not yet crossed) + d.driveTo(240, RETURN_Y); + d.driveTo(240, 0); + d.driveTo(COURSE_END_X, 0); // run 2 completes, slower + + EXPECT_EQ(t.getRuns(), 2); + EXPECT_TRUE(t.getLastRunTime() > t.getBestRunTime()); + EXPECT_EQ(t.getBestRunNumber(), 1); + EXPECT_NEAR(t.getBestRunTime(), 16000UL, 100.0); +} + +void test_reset_clears_state_keeps_lines() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + + d.lapCourse(); + EXPECT_EQ(t.getRuns(), 1); + + t.reset(); + EXPECT_EQ(t.getRuns(), 0); + EXPECT_FALSE(t.getRaceStarted()); + EXPECT_FALSE(t.isRunActive()); + EXPECT_EQ(t.getBestRunTime(), 0UL); + EXPECT_EQ(t.getCancelledRunCount(), 0); + EXPECT_TRUE(t.isStartLineConfigured()); // lines survive reset + EXPECT_TRUE(t.isFinishLineConfigured()); + + // And the course still times correctly after reset. + PathDriver d2(t); + d2.driveTo(COURSE_END_X, 0); + EXPECT_EQ(t.getRuns(), 1); + EXPECT_NEAR(t.getLastRunTime(), 16000UL, 100.0); +} + +void test_degenerate_lines_rejected() { + SprintTimer t(7.0); + t.setStartLine(0.0, 0.0, 0.0, 0.0); + t.setFinishLine(latAt(0), lngAt(100), latAt(0), lngAt(100)); // A == B + EXPECT_FALSE(t.isStartLineConfigured()); + EXPECT_FALSE(t.isFinishLineConfigured()); + + // Feeding fixes with nothing configured must be safe and do nothing. + PathDriver d(t); + d.driveTo(COURSE_END_X, 0); + EXPECT_EQ(t.getRuns(), 0); + EXPECT_FALSE(t.getRaceStarted()); +} + +void test_duck_typed_surface_matches_run_surface() { + SprintTimer t(7.0); + configureFullCourse(t); + PathDriver d(t); + d.lapCourse(); + d.lapCourse(); + + EXPECT_EQ(t.getLaps(), t.getRuns()); + EXPECT_EQ(t.getLastLapTime(), t.getLastRunTime()); + EXPECT_EQ(t.getBestLapTime(), t.getBestRunTime()); + EXPECT_EQ(t.getBestLapNumber(), t.getBestRunNumber()); + EXPECT_EQ(t.getDirection(), DIR_UNKNOWN); + EXPECT_FALSE(t.isDirectionResolved()); +} + +int main() { + printf("=== SprintTimer point-to-point tests ===\n"); + + RUN_TEST(initial_state); + RUN_TEST(single_run_time_exact); + RUN_TEST(run_active_between_start_and_finish); + RUN_TEST(three_runs_deterministic); + RUN_TEST(segment_times_exact); + RUN_TEST(start_recross_cancels_and_restarts); + RUN_TEST(finish_crossing_ignored_while_waiting); + RUN_TEST(backward_start_crossing_opens_run); + RUN_TEST(single_split_is_legal); + RUN_TEST(no_splits_still_times_runs); + RUN_TEST(missed_split_invalidates_sector_data_not_run); + RUN_TEST(best_run_tracking_across_unequal_runs); + RUN_TEST(reset_clears_state_keeps_lines); + RUN_TEST(degenerate_lines_rejected); + RUN_TEST(duck_typed_surface_matches_run_surface); + + TEST_SUMMARY(); +} From c2925760f40fd083136d73da2b3a4979cea1fd7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:23:40 +0000 Subject: [PATCH 3/7] docs: sprint_timing_example, README/CLAUDE/keywords/CHANGELOG for SprintTimer Example compiles on the ESP32 + XIAO CI cells only (per-line buffers do not fit small AVRs). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- .github/workflows/compile-examples.yml | 2 + CHANGELOG.md | 33 ++++ CLAUDE.md | 83 ++++++++- README.md | 69 +++++++ .../sprint_timing_example.ino | 169 ++++++++++++++++++ keywords.txt | 24 +++ library.properties | 4 +- 7 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 examples/sprint_timing_example/sprint_timing_example.ino diff --git a/.github/workflows/compile-examples.yml b/.github/workflows/compile-examples.yml index 260d2d6..cbd8244 100644 --- a/.github/workflows/compile-examples.yml +++ b/.github/workflows/compile-examples.yml @@ -44,6 +44,7 @@ jobs: sketch-paths: | - examples/basic_oled_example - examples/sector_timing_example + - examples/sprint_timing_example - examples/real_track_data_debug # Seeed XIAO nRF52840 (recommended hardware) — all examples @@ -57,6 +58,7 @@ jobs: sketch-paths: | - examples/basic_oled_example - examples/sector_timing_example + - examples/sprint_timing_example - examples/real_track_data_debug steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a2129..8076f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`SprintTimer` — point-to-point run timing ("sprint mode")** for + autocross / hillclimb / rally-stage events: a run starts at a START line + and ends at a SEPARATE FINISH line, with up to two optional split lines + between them (zero, one, or two are all legal — no both-or-nothing sector + gate). Both lines are monitored on every fix with independent crossing + buffers, so start and finish zones may sit arbitrarily close together. + Run semantics: a start crossing begins a run; re-crossing the start + MID-RUN cancels and restarts it (botched-course re-launch); finish + crossings with no active run are ignored; a DNF simply never completes. + Duck-typed to `DovesLapTimer`'s getter surface (laps == runs) plus + run-native aliases (`getRuns()`, `getBestRunTime()`, + `getCancelledRunCount()`, `isRunActive()`, ...). New example: + `sprint_timing_example` (32-bit targets; per-line buffers don't fit + small AVRs). Host tests: `test/test_sprint_timer.cpp`. +- **`CourseManager::selectCourse(int)`** — selects a course directly, + bypassing `CourseDetector` entirely, for callers that already know the + layout (an app-side default course, or sprint mode's + newest-course-by-date rule — point-to-point driving can never satisfy + the detector's drive-a-lap-back-to-your-waypoint premise). Marks + detection complete and deactivates the other course timers. + +### Changed +- **Crossing detection extracted into `CrossingEngine`** (resolves the + long-standing `checkStartFinish` split-timing portability TODO): the + in-zone buffer, zone state machine, and crossing interpolation moved + verbatim from `DovesLapTimer` internals into a reusable class; + `DovesLapTimer` now delegates to one shared engine (behavior and memory + layout unchanged — the Layer-3 NMEA replay goldens pin this). Shared + line geometry (`geoPointOnSideOfLine`, `geoPointLineSegmentDistance`, + `geoInsideLineThreshold`) moved to `GeoMath.h`; the existing + `DovesLapTimer` public methods delegate and are unchanged. + ## [4.2.0] – 2026-07-17 A hardening release built on the v4.1 test harness. No breaking API changes; diff --git a/CLAUDE.md b/CLAUDE.md index 625493b..b368f47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,12 @@ course detection, direction detection, and waypoint-based fallback timing. DovesLapTimer/ ├── src/ │ ├── DovesLapTimer.h # Header - class definition, structs, constants, DirectionDetector -│ ├── DovesLapTimer.cpp # Implementation - crossing detection, sector timing, direction -│ ├── GeoMath.h # Shared haversine/haversine3D (static inline, no .cpp needed) +│ ├── DovesLapTimer.cpp # Implementation - lap/sector accounting, direction (crossing math delegated to CrossingEngine) +│ ├── CrossingEngine.h # Reusable crossing core: buffer entry struct, LineDetectResult, timeSinceMidnightDelta, CROSSING_* constants +│ ├── CrossingEngine.cpp # Zone state machine + crossing interpolation (extracted verbatim from DovesLapTimer) +│ ├── SprintTimer.h # Point-to-point run timer ("sprint mode": autocross/hillclimb) +│ ├── SprintTimer.cpp # SprintTimer implementation (per-line engines, run accounting) +│ ├── GeoMath.h # Shared haversine/haversine3D + line geometry (side-of-line, segment distance, zone test) │ ├── WaypointLapTimer.h # Single-point proximity-based lap timer ("Lap Anything") │ ├── WaypointLapTimer.cpp # WaypointLapTimer implementation │ ├── CourseDetector.h # Course detection state machine @@ -71,6 +75,8 @@ DovesLapTimer/ │ │ └── images.h # Bitmap data for UI │ ├── sector_timing_example/ # Demonstrates 3-sector timing │ │ └── sector_timing_example.ino +│ ├── sprint_timing_example/ # SprintTimer point-to-point demo (synthetic course, Serial only) +│ │ └── sprint_timing_example.ino │ └── real_track_data_debug/ # Replays real NMEA data (no GPS needed) │ ├── real_track_data_debug.ino │ ├── gps_race_data_2laps.h @@ -95,10 +101,14 @@ DovesLapTimer/ ``` CourseManager (orchestrator) ├── DovesLapTimer[MAX_COURSES] # One per course layout, line-crossing detection -│ └── DirectionDetector # Inline struct, detects forward/reverse +│ ├── DirectionDetector # Inline struct, detects forward/reverse +│ └── CrossingEngine (×1) # Shared crossing buffer + interpolation (3 lines, mutual exclusion) ├── CourseDetector # State machine: speed → waypoint → distance match ├── WaypointLapTimer # Fallback "Lap Anything" proximity-based timer -└── GeoMath.h # Shared static haversine functions +└── GeoMath.h # Shared static haversine + line geometry functions + +SprintTimer (standalone, point-to-point — NOT managed by CourseManager) +└── CrossingEngine (×4) # Independent per-line buffers: start / finish / S2 / S3 ``` ### Core Class: `DovesLapTimer` @@ -177,6 +187,49 @@ catches it inside a crossing zone; lap-level deltas do not. re-trigger ranking, burning through `COURSE_DETECT_MAX_REJECTIONS` in a few frames and jumping straight to Lap Anything +### CrossingEngine (extracted from DovesLapTimer) + +- The in-zone GPS ring buffer + zone state machine (`detect()`) + crossing + interpolation, extracted **verbatim** from `DovesLapTimer`'s old private + `_detectLineCrossing` / `interpolateCrossingPoint` — the Layer-3 NMEA + replay goldens pin the numeric behavior across the move (resolves the + long-standing `checkStartFinish` split-timing portability TODO). +- Line coords + the per-line in-zone flag are passed per call, so an engine + can be shared across lines (DovesLapTimer: one engine, 3 lines, caller + enforces one-crossing-at-a-time) or dedicated per line (SprintTimer). +- Owns `crossingPointBufferEntry`, `LineDetectResult`, + `timeSinceMidnightDelta()`, `DOVES_MILLIS_PER_DAY`, `CROSSING_*` — all + still visible through `DovesLapTimer.h`'s include for back-compat. +- Line geometry (`geoPointOnSideOfLine`, `geoPointLineSegmentDistance`, + `geoInsideLineThreshold`) moved to `GeoMath.h`; DovesLapTimer's public + methods delegate unchanged. + +### SprintTimer (point-to-point / "sprint mode") + +- For autocross / hillclimb / rally-stage events: run = START line → + SEPARATE FINISH line, no laps. Up to `SPRINT_MAX_SPLITS` (2) optional + split lines; zero/one/two all legal (`areSectorLinesConfigured()` = any, + NOT the circuit both-or-nothing rule). Segments numbered in crossing + order; missed/out-of-order split invalidates the run's segment data + (best-segment table skips it) but never the run time. +- **Two states, purely line-driven**: WAITING → start crossing begins a + run; RUNNING → finish completes it, and a START crossing CANCELS + + restarts (botched-course re-launch; `getCancelledRunCount()`); finish + while WAITING is ignored. DNF = run just never completes. No + DirectionDetector — backward start crossings self-heal via the restart + rule. +- **All lines hot on every fix, independent per-line CrossingEngines** — + no mutual exclusion, start/finish zones may overlap (autocross paddocks). + Cost: ~11.6 KB/instance on 32-bit. Single instance intended; not for + small AVRs. +- Same fix-intake pipeline as DovesLapTimer (validation, teleport + rejection, odometer). Duck-typed to the full DovesLapTimer getter + surface (laps == runs) + run-native aliases (`getRuns`, + `getBestRunTime`, `isRunActive`, ...). `getCurrentLapTime()` returns 0 + while WAITING — the downstream "waiting" display signal. +- Consumed by the DovesDataLogger firmware's sprint mode (see that repo's + `docs/plans/0002-sprint-mode.md` for the cross-repo design). + ### WaypointLapTimer ("Lap Anything") (v4.0) - Fallback when no course is detected (after rejections / no-match passes / distance failsafe) - Drops waypoint at speed, tracks closest approach inside a 30m proximity zone @@ -280,6 +333,20 @@ Same timing/state getters as DovesLapTimer. Sector getters return 0. Additional: | `getWaypointLat()` | Waypoint latitude | | `getWaypointLng()` | Waypoint longitude | +### SprintTimer API (duck-typed to DovesLapTimer, laps == runs) +Same loop contract (`updateCurrentTime()` + `loop()`) and the full DovesLapTimer getter surface. Additional: +| Method | Returns | +|--------|---------| +| `setStartLine(aLat,aLng,bLat,bLng)` | Define the start line | +| `setFinishLine(aLat,aLng,bLat,bLng)` | Define the separate finish line | +| `setSector2Line` / `setSector3Line` | Optional splits (0/1/2 legal) | +| `isStartLineConfigured()` / `isFinishLineConfigured()` | Line validity | +| `isRunActive()` | True between start and finish crossing | +| `getRuns()` / `getCancelledRunCount()` | Completed / cancelled runs | +| `getCurrentRunTime()` | Elapsed ms, 0 while WAITING | +| `getLastRunTime()` / `getBestRunTime()` / `getBestRunNumber()` | Run results | +| `getCurrentRunDistance()` / `getLastRunDistance()` / `getBestRunDistance()` | Distances | + ### CourseDetector API | Method | Returns | |--------|---------| @@ -298,6 +365,7 @@ Same timing/state getters as DovesLapTimer. Sector getters return 0. Additional: | `updateCurrentTime(ms)` | Feed time to all timers | | `loop(lat, lng, alt, speedKnots)` | Feed GPS to all timers + detector | | `reset()` | Reset everything | +| `selectCourse(index)` | Skip detection: activate course `index` directly (deactivates the rest); false on bad index | | `pruneInactiveCourses()` | Stop feeding non-detected timers (CPU only, frees no RAM) | | `isCourseTimerActive(index)` | True while that course's timer is still fed | | `isDetectionComplete()` | True if course detected or Lap Anything active | @@ -369,7 +437,7 @@ struct TrackConfig { this contract enforced. 2. **Altitude messing up distance**: `loop()` has a TODO: "I think alt is messing up, investigate more... maybe flag?" 3. ~~**Early abort bug**~~: Abandoned — commented-out `crossingStartedLineSide` tracking plus the `CROSSING_LINE_SIDE_NONE` define have been removed; the underlying "abort early" optimization was never implemented and the current hypotenuse-threshold flow is reliable in practice. -4. **`checkStartFinish` portability**: TODO at the top of `checkStartFinish` to make more portable for split timing +4. ~~**`checkStartFinish` portability**~~: Resolved — the crossing pipeline was extracted into `CrossingEngine` (shared by DovesLapTimer and SprintTimer); `checkStartFinish` is now a thin accounting layer over `_detectLineCrossing`'s wrapper 5. ~~**License mismatch**~~: Resolved - GPL v3, library.properties updated 6. ~~**Header comment outdated**~~: Fixed - updated to mention 3-sector timing 7. ~~**No keywords.txt**~~: Added (refreshed 2026-04-17 for full v4.0 API coverage) @@ -477,7 +545,10 @@ README.md. host via `make run`. Covers `GeoMath`, `DirectionDetector`, `CourseDetector` state machine, `CourseManager` orchestration (`test_course_manager.cpp`), `WaypointLapTimer` - (`test_waypoint_lap_timer.cpp`), a synthetic-track integration + (`test_waypoint_lap_timer.cpp`), `SprintTimer` point-to-point runs over a + synthetic open course (`test_sprint_timer.cpp` — run times, cancel/restart, + ignored finishes, 0/1/2 splits, missed-split invalidation), + a synthetic-track integration pass over the full `DovesLapTimer` pipeline, plus regression suites for midnight rollover (`test_midnight_rollover.cpp`), adversarial GPS input (`test_input_validation.cpp`), crossing-buffer wraparound diff --git a/README.md b/README.md index 3e1b931..ba42889 100644 --- a/README.md +++ b/README.md @@ -431,6 +431,67 @@ The `WaypointLapTimer` is the fallback that kicks in when no pre-configured cour // Sector getters exist but return 0 (sectors not supported in proximity mode) ``` +### 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):** + +1. 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). +2. 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. +3. 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. + +```c + SprintTimer sprintTimer(7.0); // crossing threshold, like DovesLapTimer + sprintTimer.setStartLine(aLat, aLng, bLat, bLng); + sprintTimer.setFinishLine(aLat, aLng, bLat, bLng); + sprintTimer.setSector2Line(aLat, aLng, bLat, bLng); // optional + sprintTimer.setSector3Line(aLat, aLng, bLat, bLng); // optional + + // Same loop contract as DovesLapTimer: + sprintTimer.updateCurrentTime(millisecondsSinceMidnight); + sprintTimer.loop(lat, lng, altitudeMeters, speedKnots); + + // Run-native surface: + bool isRunActive() const; // between start and finish + int getRuns() const; // completed runs this session + int getCancelledRunCount() const; // re-crossed start mid-run + unsigned long getCurrentRunTime() const; // 0 while waiting + unsigned long getLastRunTime() const; + unsigned long getBestRunTime() const; + int getBestRunNumber() const; + + // Plus the full DovesLapTimer duck-typed getter surface (laps == runs), + // so displays/loggers can consume either timer interchangeably. +``` + +`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`: @@ -479,6 +540,12 @@ When sector lines are configured, the library automatically detects whether you' - 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](examples/sprint_timing_example/sprint_timing_example.ino) + - 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](examples/real_track_data_debug/real_track_data_debug.ino) - **REQUIRES A LOT OF RAM TO STORE SAMPLE DATA** - **Serial Only** No GPS Required @@ -493,6 +560,8 @@ A `CourseManager` instance is ~29 KB on a 64-bit-double MCU, dominated by a fixe 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](https://www.gnu.org/licenses/gpl-3.0.en.html). diff --git a/examples/sprint_timing_example/sprint_timing_example.ino b/examples/sprint_timing_example/sprint_timing_example.ino new file mode 100644 index 0000000..96f0e4e --- /dev/null +++ b/examples/sprint_timing_example/sprint_timing_example.ino @@ -0,0 +1,169 @@ +/** + * Sprint (Point-to-Point) Timing Example for DovesLapTimer's SprintTimer + * + * Demonstrates sprint-mode run timing — autocross / hillclimb style: a + * START line and a SEPARATE FINISH line, no laps — using a SYNTHETIC GPS + * course, no GPS hardware required. The timer is driven through three + * simulated runs down a 500 m straight (loop back on a parallel return + * leg between runs) so you can watch run times, split segments, best-run + * and optimal tracking light up on Serial. + * + * Course layout (x = meters along the course): + * + * 0m ── START(50m) ── S2(150m) ── S3(300m) ── FINISH(450m) ── 500m + * |------------- timed run: 400m --------------| + * return leg 40m north of the course, outside every crossing zone + * + * Sprint semantics demonstrated: + * - a start-line crossing begins a run; a finish-line crossing ends it + * - crossing the start again MID-RUN cancels and restarts (botched course) + * - finish crossings with no active run are ignored (the return loop) + * - splits are optional: zero, one, or two lines are all legal + * + * To adapt for a real course: + * 1. Replace the line coordinates below with your own + * (Google Maps right-click -> copy lat,lng). + * 2. Delete the synthetic-drive block in loop(). + * 3. Feed real GPS: lat, lng, altitude (m), speed (knots) into + * sprintTimer.loop(), and ms-since-midnight into + * sprintTimer.updateCurrentTime(). + * + * RAM note: each configured line owns an independent crossing buffer + * (~2.9 KB each on 32-bit targets). SprintTimer targets modern MCUs + * (nRF52840, ESP32, ...) — it does not fit small AVRs. + */ + +#include +#include + +// Adafruit nRF52 BSP (e.g. Seeed XIAO nRF52840) routes Serial through USB CDC +// via TinyUSB. The BSP defines USE_TINYUSB when that stack is selected; the +// header below pulls in the TinyUSB implementation so Serial links. +// No-op on ESP32 / mbed cores. +#ifdef USE_TINYUSB + #include +#endif + +// ========================================================================= +// Synthetic course configuration +// ========================================================================= + +const double BASE_LAT = 28.40000; +const double BASE_LNG = -81.40000; +// meters -> degrees at BASE_LAT (haversine-consistent, R = 6371 km) +const double M_PER_DEG_LAT = 111194.9266; +const double M_PER_DEG_LNG = 111194.9266 * 0.879649; // cos(28.4 deg) + +double latAt(double yMeters) { return BASE_LAT + yMeters / M_PER_DEG_LAT; } +double lngAt(double xMeters) { return BASE_LNG + xMeters / M_PER_DEG_LNG; } + +const double START_X = 50.0; +const double S2_X = 150.0; +const double S3_X = 300.0; +const double FINISH_X = 450.0; +const double COURSE_END_X = 500.0; +const double RETURN_Y = 40.0; // return-leg offset, outside all zones +const double LINE_HALF_WIDTH = 10.0; // lines span y = -10m .. +10m + +const double STEP_METERS = 5.0; +const unsigned long SIM_MS_PER_STEP = 200; // sim-time GPS @ 5 Hz +const float SIM_SPEED_KNOTS = 48.0f; // ~90 km/h +const float SIM_ALT_METERS = 50.0f; +const unsigned int RUNS_TO_DO = 3; + +SprintTimer sprintTimer(7.0); + +// Synthetic drive state +double simX = 0, simY = 0; +unsigned long simTimeMs = 0; +unsigned int runsSeen = 0; +int simPhase = 0; // 0 = out (east along course), 1..3 = return loop legs +bool done = false; + +void feedFix() { + sprintTimer.updateCurrentTime(simTimeMs); + sprintTimer.loop(latAt(simY), lngAt(simX), SIM_ALT_METERS, SIM_SPEED_KNOTS); + simTimeMs += SIM_MS_PER_STEP; +} + +void setup() { + Serial.begin(115200); + while (!Serial && millis() < 4000) { } + + Serial.println(F("=== SprintTimer point-to-point example ===")); + + // Start line: a run begins when it is crossed (even mid-run: that + // cancels the current run and starts a fresh one). + sprintTimer.setStartLine(latAt(-LINE_HALF_WIDTH), lngAt(START_X), + latAt(LINE_HALF_WIDTH), lngAt(START_X)); + // Finish line: completes the run (ignored when no run is active). + sprintTimer.setFinishLine(latAt(-LINE_HALF_WIDTH), lngAt(FINISH_X), + latAt(LINE_HALF_WIDTH), lngAt(FINISH_X)); + // Optional splits — configure zero, one, or both. + sprintTimer.setSector2Line(latAt(-LINE_HALF_WIDTH), lngAt(S2_X), + latAt(LINE_HALF_WIDTH), lngAt(S2_X)); + sprintTimer.setSector3Line(latAt(-LINE_HALF_WIDTH), lngAt(S3_X), + latAt(LINE_HALF_WIDTH), lngAt(S3_X)); + sprintTimer.forceLinearInterpolation(); + + Serial.println(F("Course: start@50m S2@150m S3@300m finish@450m (400m run)")); + Serial.println(); +} + +void printRun() { + Serial.print(F("Run ")); + Serial.print(sprintTimer.getRuns()); + Serial.print(F(" complete: ")); + Serial.print(sprintTimer.getLastRunTime()); + Serial.print(F(" ms (")); + Serial.print(sprintTimer.getLastRunDistance()); + Serial.println(F(" m)")); + + Serial.print(F(" segments: ")); + Serial.print(sprintTimer.getCurrentLapSector1Time()); + Serial.print(F(" / ")); + Serial.print(sprintTimer.getCurrentLapSector2Time()); + Serial.print(F(" / ")); + Serial.println(sprintTimer.getCurrentLapSector3Time()); + + Serial.print(F(" best run: ")); + Serial.print(sprintTimer.getBestRunTime()); + Serial.print(F(" ms (run ")); + Serial.print(sprintTimer.getBestRunNumber()); + Serial.print(F(") optimal: ")); + Serial.print(sprintTimer.getOptimalLapTime()); + Serial.println(F(" ms")); + Serial.println(); +} + +void loop() { + if (done) return; + + // ---- Synthetic drive: out along the course, then loop back north ---- + if (simPhase == 0) { // out: east along y=0, through every line + simX += STEP_METERS; + if (simX >= COURSE_END_X) simPhase = 1; + } else if (simPhase == 1) { // north to the return leg + simY += STEP_METERS; + if (simY >= RETURN_Y) simPhase = 2; + } else if (simPhase == 2) { // west back to the course start + simX -= STEP_METERS; + if (simX <= 0) simPhase = 3; + } else { // south, rejoin the course + simY -= STEP_METERS; + if (simY <= 0) simPhase = 0; + } + feedFix(); + // ---- End synthetic drive ---- + + if ((unsigned int)sprintTimer.getRuns() > runsSeen) { + runsSeen = sprintTimer.getRuns(); + printRun(); + if (runsSeen >= RUNS_TO_DO) { + Serial.println(F("=== Done. ===")); + done = true; + } + } + + delay(2); // keep the sim snappy but readable on a scope +} diff --git a/keywords.txt b/keywords.txt index cf1e84f..0e46b0b 100644 --- a/keywords.txt +++ b/keywords.txt @@ -161,3 +161,27 @@ GEOMATH_KNOTS_TO_KMH LITERAL1 GEOMATH_KMH_TO_MPH LITERAL1 GEOMATH_MPH_TO_KMH LITERAL1 GEOMATH_METERS_TO_FEET LITERAL1 + +####################################### +# SprintTimer (point-to-point / sprint mode) +####################################### +SprintTimer KEYWORD1 +CrossingEngine KEYWORD1 + +setStartLine KEYWORD2 +setFinishLine KEYWORD2 +isStartLineConfigured KEYWORD2 +isFinishLineConfigured KEYWORD2 +isRunActive KEYWORD2 +getRuns KEYWORD2 +getCancelledRunCount KEYWORD2 +getCurrentRunTime KEYWORD2 +getLastRunTime KEYWORD2 +getBestRunTime KEYWORD2 +getBestRunNumber KEYWORD2 +getCurrentRunDistance KEYWORD2 +getLastRunDistance KEYWORD2 +getBestRunDistance KEYWORD2 +selectCourse KEYWORD2 + +SPRINT_MAX_SPLITS LITERAL1 diff --git a/library.properties b/library.properties index 7c0055e..18b17d7 100644 --- a/library.properties +++ b/library.properties @@ -3,10 +3,10 @@ version=4.2.0 author=Michael Champagne (crimsondove) maintainer=Michael Champagne sentence=GPS-based lap timing library with course detection and multi-course support -paragraph=Handles whole laps, split lap timing, automatic course detection, direction detection, and waypoint-based fallback timing. Supports multiple course layouts per track. +paragraph=Handles whole laps, split lap timing, automatic course detection, direction detection, waypoint-based fallback timing, and point-to-point sprint runs (autocross/hillclimb). Supports multiple course layouts per track. category=Other url=https://github.com/TheAngryRaven/DovesLapTimer architectures=* -includes=DovesLapTimer.h,WaypointLapTimer.h,CourseDetector.h,CourseManager.h +includes=DovesLapTimer.h,WaypointLapTimer.h,CourseDetector.h,CourseManager.h,SprintTimer.h depends=ArxTypeTraits license=GPL-3.0 From 5cc65d55f7e31b7aa4382dfc33787cb589faaeee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 19:48:42 +0000 Subject: [PATCH 4/7] fix: include ArxTypeTraits polyfill in CrossingEngine.h for classic AVR std::forward doesn't exist on classic AVR without the ArxTypeTraits polyfill (no C++ standard library) - the extracted debug templates used it but the new header didn't pull the polyfill in, breaking the Uno/Mega CI cells for any TU that parses CrossingEngine.h before DovesLapTimer.h. Same first-include rule DovesLapTimer.h already follows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- src/CrossingEngine.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CrossingEngine.h b/src/CrossingEngine.h index b73461e..d24de1f 100644 --- a/src/CrossingEngine.h +++ b/src/CrossingEngine.h @@ -22,6 +22,10 @@ #define _DOVES_CROSSING_ENGINE_H #include +// Polyfills std::forward (used by the debug templates below) on cores with +// no C++ standard library — classic AVR. Must come before any std:: use so +// this header is safe to include in any order relative to DovesLapTimer.h. +#include "ArxTypeTraits.h" #include "GeoMath.h" #define CROSSING_LINE_SIDE_A -1 From 73facb9a9d2e60c92ab967459ab2f8f9d1e05b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:31:15 +0000 Subject: [PATCH 5/7] test: real-data differential replay - SprintTimer vs circuit sector 1 on OKC fixture Per review idea: reuse the recorded OKC session as pseudo-sprint data. Start = the OKC 'Normal' S/F line, finish = its real sector-2 line (from the DovesDataLogger SDCARD OKC.json - S/F coords match the fixture to the last digit). Every recorded lap then contains exactly one point-to-point run whose duration must equal that lap's circuit-timer sector-1 time on the same fixes: two independent accounting layers over the same crossing engine agree to the millisecond (28378 / 27903 / 28304 ms, pinned +/-50 like the other layer-3 goldens). Also verifies zero cancellations on circuit data and the fixture-end abandoned run (DNF-as-normal-op) on real GPS. Synthetic suite unchanged - it still owns the semantics circuit data can't produce (staging stops, cancel/restart, ignored finishes). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- CHANGELOG.md | 6 +- CLAUDE.md | 10 ++- test/test_nmea_sprint.cpp | 175 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 test/test_nmea_sprint.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 8076f50..27597f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 run-native aliases (`getRuns()`, `getBestRunTime()`, `getCancelledRunCount()`, `isRunActive()`, ...). New example: `sprint_timing_example` (32-bit targets; per-line buffers don't fit - small AVRs). Host tests: `test/test_sprint_timer.cpp`. + small AVRs). Host tests: `test/test_sprint_timer.cpp` (synthetic + semantics) plus `test/test_nmea_sprint.cpp`, a real-data differential + replay — the OKC fixture driven through both the circuit timer and a + SprintTimer (start = S/F, finish = the real OKC sector-2 line), with + every run required to equal that lap's circuit sector-1 time. - **`CourseManager::selectCourse(int)`** — selects a course directly, bypassing `CourseDetector` entirely, for callers that already know the layout (an app-side default course, or sprint mode's diff --git a/CLAUDE.md b/CLAUDE.md index b368f47..9ea072b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -563,7 +563,15 @@ README.md. golden values pinned in each fixture header to ±50ms, plus a ±200ms check against MyLaps magnetic-loop times where recorded. Catches interpolation regressions on real-world noisy GPS data, not just the clean synthetic track. - 21 replay tests across 4 fixtures. + 21 replay tests across 4 fixtures. Additionally `test_nmea_sprint.cpp` runs a + **differential replay**: the 2laps OKC fixture through BOTH the circuit timer + (full S/F+S2+S3, OKC "Normal" lines from the DovesDataLogger SDCARD JSON) and + a SprintTimer configured start=S/F, finish=S2 — every recorded lap becomes one + point-to-point run whose time must equal that lap's circuit sector-1 time to + ±10ms (two independent accounting layers over the same crossings must agree), + plus pinned run goldens. Real-data sprint coverage until a true autocross + fixture exists; note it can't exercise staging stops / cancel-restart (those + stay synthetic). ## Cross-Reference: Related Repos diff --git a/test/test_nmea_sprint.cpp b/test/test_nmea_sprint.cpp new file mode 100644 index 0000000..7322bf4 --- /dev/null +++ b/test/test_nmea_sprint.cpp @@ -0,0 +1,175 @@ +/** + * Layer-3 differential replay: SprintTimer vs DovesLapTimer on REAL data. + * + * Fixture: examples/real_track_data_debug/gps_race_data_2laps.h + * (Orlando Kart Center, "Normal" layout, two laps, real noisy GPS) + * + * Idea (from the sprint-mode plan): treat the circuit lap as a pseudo-sprint + * by using the OKC start/finish line as the sprint START and the OKC + * sector 2 line as the sprint FINISH. Every recorded lap then contains + * exactly one point-to-point run whose duration must equal that lap's + * SECTOR 1 time as computed by the circuit timer on the very same fixes. + * + * That makes this a self-validating differential test: two independent + * state machines (lap accounting vs run accounting) over the same + * crossing engine and the same real-world data must agree — no synthetic + * geometry, no hand-picked goldens. It does NOT exercise sprint-specific + * semantics the circuit data can't produce (staging stops, cancel/restart, + * ignored finishes) — those live in test_sprint_timer.cpp's synthetic + * suite. Better point-to-point fixtures (a real autocross log) can join + * later without displacing either. + * + * Line coordinates are the OKC "Normal" course from the DovesDataLogger + * repo's SDCARD/TRACKS/OKC.json — its S/F line matches this fixture's + * documented S/F to the last digit. + */ + +#include "test_runner.h" +#include "replay_runner.h" +#include "../src/SprintTimer.h" +#include "../examples/real_track_data_debug/gps_race_data_2laps.h" + +static const int num_gps_logs = sizeof(gps_logs) / sizeof(gps_logs[0]); + +// OKC "Normal" course (SDCARD/TRACKS/OKC.json, DovesDataLogger repo) +static constexpr double SF_A_LAT = 28.4127081705638, SF_A_LNG = -81.3797326641803; +static constexpr double SF_B_LAT = 28.4127303867932, SF_B_LNG = -81.3795704875378; +static constexpr double S2_A_LAT = 28.4119049886871, S2_A_LNG = -81.3790708193926; +static constexpr double S2_B_LAT = 28.4118316342961, S2_B_LNG = -81.3791856652217; +static constexpr double S3_A_LAT = 28.4115010664104, S3_A_LNG = -81.3799856475317; +static constexpr double S3_B_LAT = 28.4115084390461, S3_B_LNG = -81.3798064021136; + +struct DifferentialResult { + std::vector circuitSector1; // per lap: S/F -> S2, circuit timer + std::vector sprintRuns; // per run: S/F -> S2, sprint timer + int circuitLaps = 0; + int sprintRuns_n = 0; + int cancelled = 0; + bool sprintRunActiveAtEnd = false; +}; + +// Feed the identical fix stream to both timers, capturing the circuit +// timer's sector-1 completions (snapshot at the 1 -> 2 sector transition, +// while the value is still in the current-lap accumulator) and the sprint +// timer's completed runs. +static DifferentialResult runDifferential() { + DovesLapTimer circuit(7.0); + circuit.setStartFinishLine(SF_A_LAT, SF_A_LNG, SF_B_LAT, SF_B_LNG); + circuit.setSector2Line(S2_A_LAT, S2_A_LNG, S2_B_LAT, S2_B_LNG); + circuit.setSector3Line(S3_A_LAT, S3_A_LNG, S3_B_LAT, S3_B_LNG); + circuit.forceLinearInterpolation(); + + SprintTimer sprint(7.0); + sprint.setStartLine(SF_A_LAT, SF_A_LNG, SF_B_LAT, SF_B_LNG); + sprint.setFinishLine(S2_A_LAT, S2_A_LNG, S2_B_LAT, S2_B_LNG); + sprint.forceLinearInterpolation(); + + DifferentialResult r; + NmeaState state; + int lastSector = 0; + int lastRuns = 0; + + for (int i = 0; i < num_gps_logs; i++) { + if (!parseNmeaLine(gps_logs[i], state)) continue; + if (!state.fix) continue; + + circuit.updateCurrentTime(state.time_ms); + circuit.loop(state.lat, state.lng, state.alt_m, state.speed_knots); + sprint.updateCurrentTime(state.time_ms); + sprint.loop(state.lat, state.lng, state.alt_m, state.speed_knots); + + int sector = circuit.getCurrentSector(); + if (sector == 2 && lastSector == 1) { + r.circuitSector1.push_back(circuit.getCurrentLapSector1Time()); + } + lastSector = sector; + + if (sprint.getRuns() > lastRuns) { + r.sprintRuns.push_back(sprint.getLastRunTime()); + lastRuns = sprint.getRuns(); + } + } + + r.circuitLaps = circuit.getLaps(); + r.sprintRuns_n = sprint.getRuns(); + r.cancelled = sprint.getCancelledRunCount(); + r.sprintRunActiveAtEnd = sprint.isRunActive(); + return r; +} + +void test_three_runs_from_three_laps() { + DifferentialResult r = runDifferential(); + // The "2laps" fixture actually contains THREE full laps (the name refers + // to the two golden-pinned ones); each lap contains exactly one complete + // S/F -> S2 sequence. The lap-3-closing S/F crossing opens a FOURTH + // sprint run that the fixture ends before finishing — exactly the + // expected "abandoned run records nothing" behavior on real data. + EXPECT_EQ(r.circuitLaps, 3); + EXPECT_EQ(r.sprintRuns_n, 3); + EXPECT_EQ((int)r.sprintRuns.size(), 3); + EXPECT_EQ((int)r.circuitSector1.size(), 3); +} + +void test_run_times_match_pinned_goldens() { + // Golden pins in the layer-3 style (measured on this fixture, ±50 ms): + // sector-1 durations of OKC "Normal" laps 1-3 as point-to-point runs. + DifferentialResult r = runDifferential(); + EXPECT_TRUE(r.sprintRuns.size() >= 3); + EXPECT_NEAR(r.sprintRuns[0], 28378UL, 50.0); + EXPECT_NEAR(r.sprintRuns[1], 27903UL, 50.0); + EXPECT_NEAR(r.sprintRuns[2], 28304UL, 50.0); +} + +void test_run_times_match_circuit_sector1_times() { + // The core differential assertion: same fixes, same engine, two + // independent accounting layers -> identical crossing interpolations -> + // run time == that lap's sector-1 time, to the millisecond (10 ms slack + // for any rounding asymmetry). + DifferentialResult r = runDifferential(); + size_t n = r.sprintRuns.size() < r.circuitSector1.size() + ? r.sprintRuns.size() : r.circuitSector1.size(); + EXPECT_TRUE(n >= 3); + for (size_t i = 0; i < n; i++) { + EXPECT_NEAR(r.sprintRuns[i], r.circuitSector1[i], 10.0); + } +} + +void test_run_times_are_plausible() { + // Coarse sanity independent of the circuit timer: OKC "Normal" is a + // ~69s lap and sector 1 is its opening chunk — every run must be a + // sane fraction of the lap, not 0 and not a whole lap. + DifferentialResult r = runDifferential(); + for (size_t i = 0; i < r.sprintRuns.size(); i++) { + EXPECT_TRUE(r.sprintRuns[i] > 2000UL); + EXPECT_TRUE(r.sprintRuns[i] < 60000UL); + } +} + +void test_no_cancellations_on_circuit_data() { + // At OKC the start line is never re-crossed between a run's start (S/F) + // and its finish (S2), so the cancel/restart path must never trigger. + DifferentialResult r = runDifferential(); + EXPECT_EQ(r.cancelled, 0); +} + +void test_final_run_left_open_at_fixture_end() { + // The lap-3-closing S/F crossing starts sprint run 4; the data ends + // before the S2 line is reached again. DNF-as-normal-op: nothing + // recorded, run simply still active. + DifferentialResult r = runDifferential(); + EXPECT_TRUE(r.sprintRunActiveAtEnd); + EXPECT_EQ(r.sprintRuns_n, 3); +} + +int main() { + printf("=== NMEA differential replay: SprintTimer vs circuit sector 1 (OKC 2 laps) ===\n"); + + RUN_TEST(three_runs_from_three_laps); + RUN_TEST(run_times_match_pinned_goldens); + RUN_TEST(run_times_match_circuit_sector1_times); + RUN_TEST(run_times_are_plausible); + RUN_TEST(no_cancellations_on_circuit_data); + RUN_TEST(final_run_left_open_at_fixture_end); + + TEST_SUMMARY(); +} From 03fac0d1d4f8de2c7a142b75966c7648b4921ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 21:08:07 +0000 Subject: [PATCH 6/7] feat: DOVES_DISABLE_DEBUG compile-time debug kill switch The debug templates' runtime if(_serial) gate keeps every debug string and print call-site in flash even on firmwares that never attach a debug Stream (~150 call sites across the library - several KB on a fully-featured build; the DovesDataLogger beta image just burst its 320 KB OTA cap at 100.9% partly on this dead weight). Defining DOVES_DISABLE_DEBUG swaps the templates for empty inlines so the compiler drops strings and call-sites entirely. Default behavior unchanged; unit-tests CI now runs the host suite both ways. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HnTP6BdA9xjLR5hSWE9frb --- .github/workflows/unit-tests.yml | 10 + CHANGELOG.md | 8 + CLAUDE.md | 9 + src/CourseManager.h | 276 ++++--- src/CrossingEngine.h | 12 + src/DovesLapTimer.h | 1300 +++++++++++++++--------------- src/SprintTimer.h | 12 + src/WaypointLapTimer.h | 286 +++---- 8 files changed, 997 insertions(+), 916 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 875abcc..649e403 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -19,3 +19,13 @@ jobs: - name: Build and run tests working-directory: test run: make run + + # Same suite with the debug kill switch — proves -DDOVES_DISABLE_DEBUG + # compiles clean and changes no timing behavior (tests attach no + # debug Stream, so results must be identical). + - name: Run host test suite (DOVES_DISABLE_DEBUG) + working-directory: test + run: | + make clean + make run CXXFLAGS="-std=c++14 -Wall -Wextra -O0 -g -Imock -I../src -include mock/Arduino.h -DDOVES_DISABLE_DEBUG" + diff --git a/CHANGELOG.md b/CHANGELOG.md index 27597f0..8f9be36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`DOVES_DISABLE_DEBUG` compile-time debug kill switch.** The library's + debug output is gated by a runtime `if (_serial)` check, so production + firmwares that never attach a debug Stream still carried every debug + string and print call-site in flash (~150 call sites / several KB on a + build using all modules). Defining `DOVES_DISABLE_DEBUG` (e.g. + `-DDOVES_DISABLE_DEBUG`) swaps the debug templates for empty inlines so + the compiler drops it all. Default behavior is unchanged; CI runs the + host suite both ways. - **`SprintTimer` — point-to-point run timing ("sprint mode")** for autocross / hillclimb / rally-stage events: a run starts at a START line and ends at a SEPARATE FINISH line, with up to two optional split lines diff --git a/CLAUDE.md b/CLAUDE.md index 9ea072b..b88d30b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,6 +279,15 @@ catches it inside a crossing zone; lap-level deltas do not. - Used by WaypointLapTimer and CourseDetector (no DovesLapTimer instance needed) - DovesLapTimer retains its own `haversine()`/`haversine3D()` methods for backward compat +### DOVES_DISABLE_DEBUG (flash-size kill switch) + +All six classes' `debug_print`/`debug_println` templates compile to empty +inlines when `DOVES_DISABLE_DEBUG` is defined, dropping every debug string +and call-site from flash (production firmwares never attach a debug +Stream; the runtime `if (_serial)` check kept it all resident — several +KB). Off by default; the DovesDataLogger firmware workflows pass it. CI +runs the host suite with and without the flag. + ## Public API Quick Reference ### DovesLapTimer Setup Methods diff --git a/src/CourseManager.h b/src/CourseManager.h index ab694b0..90d1955 100644 --- a/src/CourseManager.h +++ b/src/CourseManager.h @@ -1,132 +1,144 @@ -/** - * CourseManager - orchestrates multiple DovesLapTimer instances + CourseDetector + WaypointLapTimer. - * - * Feeds ALL course timers the same GPS data simultaneously. - * Once the CourseDetector identifies candidates, validates via raceStarted sanity check. - * Falls back to WaypointLapTimer ("Lap Anything") if detection fails. - * - * Implements the same updateCurrentTime() / loop() interface as DovesLapTimer, - * so the caller can feed it via duck typing. - */ - -#ifndef _COURSE_MANAGER_H -#define _COURSE_MANAGER_H - -#include -#include "DovesLapTimer.h" -#include "WaypointLapTimer.h" -#include "CourseDetector.h" - -struct CourseConfig { - const char* name; - float lengthFt; - double startALat, startALng, startBLat, startBLng; - double sector2ALat, sector2ALng, sector2BLat, sector2BLng; - double sector3ALat, sector3ALng, sector3BLat, sector3BLng; - bool hasSector2; - bool hasSector3; -}; - -struct TrackConfig { - const char* longName; - const char* shortName; - CourseConfig courses[MAX_COURSES]; - int courseCount; -}; - -struct CourseTimerEntry { - DovesLapTimer timer; - const char* name; - float lengthFt; - bool active; -}; - -class CourseManager { -public: - CourseManager(TrackConfig& config, double crossingThreshold = 7.0, Stream *debugSerial = NULL); - - void updateCurrentTime(unsigned long ms); - int loop(double lat, double lng, float altMeters, float speedKnots); - void reset(); - void pruneInactiveCourses(); - /** - * @brief Selects a course directly, bypassing CourseDetector entirely. - * - * For callers that already know which layout is in play (an app-side - * default course choice, or sprint mode's newest-course-by-date rule — - * point-to-point driving can never satisfy the detector's - * drive-a-lap-back-to-your-waypoint premise). Marks detection complete, - * deactivates every other course timer (same effect as detection + - * pruneInactiveCourses()), and clears any Lap Anything activation. - * reset() returns the manager to normal detection. - * - * @param index Course index [0, getCourseCount()). - * @return True if the index was valid and the course was selected. - */ - bool selectCourse(int index); - - // Detection state - bool isDetectionComplete() const; - int getActiveCourseIndex() const; - const char* getActiveCourseName() const; - int getCourseCount() const; - int getDetectionRejectionCount() const; - // True while the given course's timer is still being fed GPS data. - // Courses are deactivated by pruneInactiveCourses() after detection, or - // automatically when the Lap Anything fallback activates. - bool isCourseTimerActive(int index) const; - - // Timer access - DovesLapTimer* getActiveTimer(); - WaypointLapTimer* getLapAnythingTimer(); - bool isLapAnythingActive() const; - - // Track metadata - const char* getTrackName() const; - const char* getShortName() const; - - // Detector access - CourseDetector* getDetector(); - - // Threshold setters - void setSpeedThresholdMph(float mph); - void setWaypointProximityMeters(float meters); - void setDetectionProximityMeters(float meters); - -private: - template - void debug_print(Args&&... args) { - if(_serial) { _serial->print(std::forward(args)...); } - } - template - void debug_println(Args&&... args) { - if(_serial) { _serial->println(std::forward(args)...); } - } - - void _initCourses(TrackConfig& config); - void _handleCandidatesReady(float currentOdometer); - void _activateLapAnything(); - - Stream *_serial; - double _crossingThreshold; - - CourseTimerEntry _courseTimers[MAX_COURSES]; - int _courseCount; - - CourseDetector _detector; - WaypointLapTimer _lapAnythingTimer; - - int _activeCourseIndex; - bool _detectionComplete; - bool _lapAnythingActive; - int _detectionRejectionCount; - // Odometer reading beyond which incomplete detection falls back to Lap - // Anything (factor x longest configured course, floored — see - // COURSE_DETECT_FALLBACK_* in DovesLapTimer.h). - float _fallbackDistanceMeters; - - const char* _trackLongName; - const char* _trackShortName; -}; - -#endif +/** + * CourseManager - orchestrates multiple DovesLapTimer instances + CourseDetector + WaypointLapTimer. + * + * Feeds ALL course timers the same GPS data simultaneously. + * Once the CourseDetector identifies candidates, validates via raceStarted sanity check. + * Falls back to WaypointLapTimer ("Lap Anything") if detection fails. + * + * Implements the same updateCurrentTime() / loop() interface as DovesLapTimer, + * so the caller can feed it via duck typing. + */ + +#ifndef _COURSE_MANAGER_H +#define _COURSE_MANAGER_H + +#include +#include "DovesLapTimer.h" +#include "WaypointLapTimer.h" +#include "CourseDetector.h" + +struct CourseConfig { + const char* name; + float lengthFt; + double startALat, startALng, startBLat, startBLng; + double sector2ALat, sector2ALng, sector2BLat, sector2BLng; + double sector3ALat, sector3ALng, sector3BLat, sector3BLng; + bool hasSector2; + bool hasSector3; +}; + +struct TrackConfig { + const char* longName; + const char* shortName; + CourseConfig courses[MAX_COURSES]; + int courseCount; +}; + +struct CourseTimerEntry { + DovesLapTimer timer; + const char* name; + float lengthFt; + bool active; +}; + +class CourseManager { +public: + CourseManager(TrackConfig& config, double crossingThreshold = 7.0, Stream *debugSerial = NULL); + + void updateCurrentTime(unsigned long ms); + int loop(double lat, double lng, float altMeters, float speedKnots); + void reset(); + void pruneInactiveCourses(); + /** + * @brief Selects a course directly, bypassing CourseDetector entirely. + * + * For callers that already know which layout is in play (an app-side + * default course choice, or sprint mode's newest-course-by-date rule — + * point-to-point driving can never satisfy the detector's + * drive-a-lap-back-to-your-waypoint premise). Marks detection complete, + * deactivates every other course timer (same effect as detection + + * pruneInactiveCourses()), and clears any Lap Anything activation. + * reset() returns the manager to normal detection. + * + * @param index Course index [0, getCourseCount()). + * @return True if the index was valid and the course was selected. + */ + bool selectCourse(int index); + + // Detection state + bool isDetectionComplete() const; + int getActiveCourseIndex() const; + const char* getActiveCourseName() const; + int getCourseCount() const; + int getDetectionRejectionCount() const; + // True while the given course's timer is still being fed GPS data. + // Courses are deactivated by pruneInactiveCourses() after detection, or + // automatically when the Lap Anything fallback activates. + bool isCourseTimerActive(int index) const; + + // Timer access + DovesLapTimer* getActiveTimer(); + WaypointLapTimer* getLapAnythingTimer(); + bool isLapAnythingActive() const; + + // Track metadata + const char* getTrackName() const; + const char* getShortName() const; + + // Detector access + CourseDetector* getDetector(); + + // Threshold setters + void setSpeedThresholdMph(float mph); + void setWaypointProximityMeters(float meters); + void setDetectionProximityMeters(float meters); + +private: +#ifdef DOVES_DISABLE_DEBUG + // Compile-time debug kill switch: the runtime _serial null-check still + // keeps every debug string and print call-site in flash on production + // builds that never attach a debug Stream. Defining DOVES_DISABLE_DEBUG + // (e.g. -DDOVES_DISABLE_DEBUG in build flags) replaces the debug + // templates with empty inlines so the compiler drops it all — several + // KB on a fully-featured firmware. Debug behavior is unchanged when + // the macro is not defined. + template void debug_print(Args&&...) {} + template void debug_println(Args&&...) {} +#else + template + void debug_print(Args&&... args) { + if(_serial) { _serial->print(std::forward(args)...); } + } + template + void debug_println(Args&&... args) { + if(_serial) { _serial->println(std::forward(args)...); } + } +#endif + + void _initCourses(TrackConfig& config); + void _handleCandidatesReady(float currentOdometer); + void _activateLapAnything(); + + Stream *_serial; + double _crossingThreshold; + + CourseTimerEntry _courseTimers[MAX_COURSES]; + int _courseCount; + + CourseDetector _detector; + WaypointLapTimer _lapAnythingTimer; + + int _activeCourseIndex; + bool _detectionComplete; + bool _lapAnythingActive; + int _detectionRejectionCount; + // Odometer reading beyond which incomplete detection falls back to Lap + // Anything (factor x longest configured course, floored — see + // COURSE_DETECT_FALLBACK_* in DovesLapTimer.h). + float _fallbackDistanceMeters; + + const char* _trackLongName; + const char* _trackShortName; +}; + +#endif diff --git a/src/CrossingEngine.h b/src/CrossingEngine.h index d24de1f..9bfc337 100644 --- a/src/CrossingEngine.h +++ b/src/CrossingEngine.h @@ -149,6 +149,17 @@ class CrossingEngine { double& outOdometer); private: +#ifdef DOVES_DISABLE_DEBUG + // Compile-time debug kill switch: the runtime _serial null-check still + // keeps every debug string and print call-site in flash on production + // builds that never attach a debug Stream. Defining DOVES_DISABLE_DEBUG + // (e.g. -DDOVES_DISABLE_DEBUG in build flags) replaces the debug + // templates with empty inlines so the compiler drops it all — several + // KB on a fully-featured firmware. Debug behavior is unchanged when + // the macro is not defined. + template void debug_print(Args&&...) {} + template void debug_println(Args&&...) {} +#else template void debug_print(Args&&... args) { if(_serial) { _serial->print(std::forward(args)...); } @@ -157,6 +168,7 @@ class CrossingEngine { void debug_println(Args&&... args) { if(_serial) { _serial->println(std::forward(args)...); } } +#endif /** * @brief Finds the straddling fix pair in the buffer and interpolates the diff --git a/src/DovesLapTimer.h b/src/DovesLapTimer.h index 484b49f..544ce0d 100644 --- a/src/DovesLapTimer.h +++ b/src/DovesLapTimer.h @@ -1,648 +1,654 @@ -/** - * GPS-based lap timing library for go-kart and racing applications. - * This library does NOT interface with your GPS, simply feed it data and check the state. - * Supports start/finish line detection, 3-sector split timing, pace comparison, and distance tracking. - * - * The development of this library has been overseen, and all documentation has been generated using chatGPT4. - */ - -#ifndef _DOVES_LAP_TIMER_H -#define _DOVES_LAP_TIMER_H - -#include "ArxTypeTraits.h" -#include "GeoMath.h" -#include "CrossingEngine.h" -using TRITYPE = double; - -// On classic AVR (Mega, Uno) `double` is a 32-bit float (~7 significant -// digits). At real-world latitudes/longitudes that quantizes position to -// roughly 0.2-0.75 m and pushes per-fix haversine half-angle differences -// below float precision: lap *counting* still works (the 7 m crossing -// threshold absorbs it) but odometer increments and crossing interpolation -// carry large relative error. The full advertised precision requires a -// target with true 64-bit double (nRF52840, ESP32, SAMD51, RP2040, ...). -// See README "Hardware" notes. -#if defined(__SIZEOF_DOUBLE__) && (__SIZEOF_DOUBLE__ < 8) -#warning "DovesLapTimer: 'double' is only 32 bits on this target (classic AVR). Lap counting works but GPS math runs degraded - distances and interpolated crossing times will be noticeably less accurate. Use a 64-bit-double MCU (e.g. XIAO nRF52840) for full precision." -#endif - -// CROSSING_LINE_SIDE_* now live in CrossingEngine.h (included above). - -// Course detection constants -#define COURSE_DETECT_SPEED_THRESHOLD_MPH 20 -#define COURSE_DETECT_WAYPOINT_PROXIMITY_METERS 10.0 -#define COURSE_DETECT_MIN_DISTANCE_METERS 200.0 -#define COURSE_DETECT_DISTANCE_TOLERANCE_PCT 0.25 -#define COURSE_DETECT_MAX_REJECTIONS 3 -// Completed proximity passes (full laps back at the waypoint) that matched -// no configured course length before CourseManager falls back to Lap -// Anything. Without this, a wrong/missing course config left detection in -// WAYPOINT_SET forever while the WaypointLapTimer's laps were never surfaced. -#define COURSE_DETECT_MAX_NO_MATCH_PASSES 3 -// Distance failsafe: if detection is still incomplete after driving -// factor x (longest configured course length), or the floor below for very -// short courses, fall back to Lap Anything. Catches the "never returns to -// within 10m of the waypoint" hang that pass counting can't see. -#define COURSE_DETECT_FALLBACK_DISTANCE_FACTOR 4.0 -#define COURSE_DETECT_FALLBACK_MIN_METERS 2000.0 -#define METERS_TO_FEET GEOMATH_METERS_TO_FEET - -// Waypoint lap timer constants -#define WAYPOINT_LAP_MIN_DISTANCE_METERS 100.0 -#define WAYPOINT_LAP_PROXIMITY_METERS 30.0 - -// Direction detection -#define DIR_UNKNOWN 0 -#define DIR_FORWARD 1 -#define DIR_REVERSE 2 - -// Course detection states -#define DETECT_STATE_IDLE 0 -#define DETECT_STATE_WAITING_FOR_SPEED 1 -#define DETECT_STATE_WAYPOINT_SET 2 -#define DETECT_STATE_CANDIDATES_READY 3 -#define DETECT_STATE_DETECTED 4 - -// Maximum courses supported -#define MAX_COURSES 8 - -// DOVES_MILLIS_PER_DAY, timeSinceMidnightDelta(), CROSSING_MAX_FIX_GAP_MS, -// CROSSING_PAIR_SPACING_FACTOR, LineDetectResult, and -// crossingPointBufferEntry now live in CrossingEngine.h (included above). - -// GPS input validation: a single-fix jump beyond this is treated as a glitch -// and dropped; after GPS_JUMP_REACCEPT_COUNT consecutive far fixes the new -// position is accepted as real (signal re-acquisition) and the position is -// re-seeded without crediting the gap to the odometer. -#define GPS_MAX_PLAUSIBLE_JUMP_METERS 500.0 -#define GPS_JUMP_REACCEPT_COUNT 3 - -struct DirectionDetector { - int direction; // DIR_UNKNOWN, DIR_FORWARD, DIR_REVERSE - bool raceSeen; - // Per-lap window: timestamps of physical S2/S3 crossings since the last - // start/finish. Direction is decided only when BOTH are present, by their - // temporal order — independent of the lap calculation's sector output. - unsigned long lapS2CrossingTime; - unsigned long lapS3CrossingTime; - - DirectionDetector() - : direction(DIR_UNKNOWN), raceSeen(false), - lapS2CrossingTime(0), lapS3CrossingTime(0) {} - void reset() { - direction = DIR_UNKNOWN; - raceSeen = false; - lapS2CrossingTime = 0; - lapS3CrossingTime = 0; - } - void onLineCrossing(int sectorNumber, unsigned long crossingTime); - bool isReverse() const { return direction == DIR_REVERSE; } - bool isResolved() const { return direction != DIR_UNKNOWN; } -}; - -class DovesLapTimer { -public: - DovesLapTimer(double crossingThresholdMeters = 7, Stream *debugSerial = NULL); - - /** - * @brief Updates a few internal stats and then checks the status of crossing a line - * - * This should be run every time the GPS is fixed and gets a new location is aquired! - * All of the magic happens here!!!!!! - * - * @param currentLat Latitude of the current position in decimal degrees. - * @param currentLng Longitude of the current position in decimal degrees. - * @param currentAltitudeMeters Altitude of the current position in meters. - * @param currentSpeedKnots The current speed in knots - */ - int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots); - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - /** - * @brief Checks if the triangle formed by the given coordinates is an obtuse triangle. - * - * @param lat1 Latitude of the first point - * @param lon1 Longitude of the first point - * @param lat2 Latitude of the second point - * @param lon2 Longitude of the second point - * @param lat3 Latitude of the third point - * @param lon3 Longitude of the third point - * @return true if the triangle is an obtuse triangle, false otherwise - */ - bool isObtuseTriangle(double lat1, double lon1, double lat2, double lon2, double lat3, double lon3); - - /** - * @brief Check if a driver is within a threshold distance of the line formed by the two crossing points. - * - * This function checks whether the driver is within a threshold distance from the line formed by - * crossing points A and B. The threshold is defined by crossingThresholdMeters. - * - * @param driverLat Latitude of the driver's position. - * @param driverLon Longitude of the driver's position. - * @param crossingPointALat Latitude of crossing point A. - * @param crossingPointALon Longitude of crossing point A. - * @param crossingPointBLat Latitude of crossing point B. - * @param crossingPointBLon Longitude of crossing point B. - * @return True if the driver is within the threshold distance, otherwise False. - */ - bool insideLineThreshold(double driverLat, double driverLon, double crossingPointALat, double crossingPointALon, double crossingPointBLat, double crossingPointBLon); - - /** - * @brief Determines which side of a line a driver is on. - * - * Given a point's position and two points defining a line segment, this function computes - * the side of the line the point is on. The line is treated as infinite for the side determination. - * - * @param driverLat The latitude of the point's position. - * @param driverLng The longitude of the point's position. - * @param pointALat The latitude of the first point of the line. - * @param pointALng The longitude of the first point of the line. - * @param pointBLat The latitude of the second point of the line. - * @param pointBLng The longitude of the second point of the line. - * @return Returns 1 if the point is on one side of the line, -1 if the point is on the other side, and 0 if the point is exactly on the line. - */ - int pointOnSideOfLine(double driverLat, double driverLng, double pointALat, double pointALng, double pointBLat, double pointBLng); - /** - * @brief Calculate the shortest distance between a point and a line segment. - * - * This function takes the coordinates of a point (pointX, pointY) and a line segment - * defined by two endpoints (startX, startY) and (endX, endY), and returns the shortest - * distance between the point and the line segment. Inputs are decimal-degree - * coordinates; the projection onto the segment happens in degree space, but - * every return path measures the final distance via haversine, so the - * result is in **meters**. - * - * @param pointX The x-coordinate of the point. - * @param pointY The y-coordinate of the point. - * @param startX The x-coordinate of the first endpoint of the line segment. - * @param startY The y-coordinate of the first endpoint of the line segment. - * @param endX The x-coordinate of the second endpoint of the line segment. - * @param endY The y-coordinate of the second endpoint of the line segment. - * @return The shortest distance between the point and the line segment, in meters. - */ - double pointLineSegmentDistance(double pointX, double pointY, double startX, double startY, double endX, double endY); - /** - * @brief Calculates the great-circle distance between two points on the Earth's surface using the Haversine formula. - * - * This function takes the latitude and longitude of two points in decimal degrees and returns the distance between - * them in meters. The Haversine formula is used to account for the Earth's curvature, providing accurate results - * for relatively short distances (up to a few thousand kilometers). - * - * Note: This function assumes that the Earth is a perfect sphere with a radius of 6,371 kilometers. - * - * @param lat1 Latitude of the first point in decimal degrees - * @param lon1 Longitude of the first point in decimal degrees - * @param lat2 Latitude of the second point in decimal degrees - * @param lon2 Longitude of the second point in decimal degrees - * @return double The great-circle distance between the two points in meters - */ - double haversine(double lat1, double lon1, double lat2, double lon2); - /** - * @brief Calculates the distance between two GPS points, including altitude difference. - * - * This function computes the distance between two GPS points using the haversine formula, - * and takes into account the altitude difference between the points. The resulting distance - * is the true 3D distance between the points, rather than just the 2D distance on the Earth's surface. - * - * @param prevLat Latitude of the first GPS point in decimal degrees. - * @param prevLng Longitude of the first GPS point in decimal degrees. - * @param prevAlt Altitude of the first GPS point in meters. - * @param currentLat Latitude of the second GPS point in decimal degrees. - * @param currentLng Longitude of the second GPS point in decimal degrees. - * @param currentAlt Altitude of the second GPS point in meters. - * @return The 3D distance between the two GPS points in meters. - */ - double haversine3D(double prevLat, double prevLng, double prevAlt, double currentLat, double currentLng, double currentAlt); - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - - /** - * @brief Reset all parameters back to 0 - */ - void reset(); - /** - * @brief Sets the start/finish line using two points (A and B). - * - * @param pointALat Latitude of point A in decimal degrees. - * @param pointALng Longitude of point A in decimal degrees. - * @param pointBLat Latitude of point B in decimal degrees. - * @param pointBLng Longitude of point B in decimal degrees. - */ - void setStartFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng); - /** - * @brief Sets the sector 2 line using two points (A and B). - * - * @param pointALat Latitude of point A in decimal degrees. - * @param pointALng Longitude of point A in decimal degrees. - * @param pointBLat Latitude of point B in decimal degrees. - * @param pointBLng Longitude of point B in decimal degrees. - */ - void setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng); - /** - * @brief Sets the sector 3 line using two points (A and B). - * - * @param pointALat Latitude of point A in decimal degrees. - * @param pointALng Longitude of point A in decimal degrees. - * @param pointBLat Latitude of point B in decimal degrees. - * @param pointBLng Longitude of point B in decimal degrees. - */ - void setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng); - /** - * @brief Updates the current GPS time since midnight. - * - * @param currentTimeMilliseconds The current time in milliseconds. - */ - void updateCurrentTime(unsigned long currentTimeMilliseconds); - /** - * @brief forces linear interpolation when checking crossing line - * - * Might maybe be more accurate if your track(s) finishline is on a straight or other location you expect constant speed - */ - void forceLinearInterpolation(); - /** - * @brief Forces Catmull-Rom spline interpolation when checking crossing line. - * - * Catmull-Rom interpolation uses 4 control points to create a smooth curve, - * which can provide more accurate crossing time calculation when the vehicle - * path curves near the line. Falls back to linear interpolation automatically - * if insufficient control points are available (crossing detected too early - * in the buffer). - */ - void forceCatmullRomInterpolation(); - /** - * @brief Gets the race started status (passed the line one time). - * - * @return True if the race has started, false otherwise. - */ - bool getRaceStarted() const; - /** - * @brief Gets the crossing status. - * - * @return True if crossing the start/finish line, false otherwise. - */ - bool getCrossing() const; - /** - * @brief Gets the current lap start time. - * - * @return The current lap start time in milliseconds. - */ - unsigned long getCurrentLapStartTime() const; - /** - * @brief Gets the current lap time. - * - * @return The current lap time in milliseconds. - */ - unsigned long getCurrentLapTime() const; - /** - * @brief Gets the last lap time. - * - * @return The last lap time in milliseconds. - */ - unsigned long getLastLapTime() const; - /** - * @brief Gets the best lap time. - * - * @return The best lap time in milliseconds. - */ - unsigned long getBestLapTime() const; - /** - * @brief Gets the current lap odometer start. - * - * @return The distance traveled at the start of the current lap in meters. - */ - float getCurrentLapOdometerStart() const; - /** - * @brief Gets the current lap distance. - * - * @return The distance traveled during the current lap in meters. - */ - float getCurrentLapDistance() const; - /** - * @brief Gets the last lap distance. - * - * @return The distance traveled during the last lap in meters. - */ - float getLastLapDistance() const; - /** - * @brief Gets the best lap distance. - * - * @return The distance traveled during the best lap in meters. - */ - float getBestLapDistance() const; - /** - * @brief Gets the total distance traveled. - * - * @return The total distance traveled in meters. - */ - float getTotalDistanceTraveled() const; - /** - * @brief Gets the best lap number. - * - * @return The lap number of the best lap. - */ - int getBestLapNumber() const; - /** - * @brief Gets the total number of laps completed. - * - * @return The total number of laps completed. - */ - int getLaps() const; - /** - * @brief Calculates the pace difference between the current lap and the best lap. - * - * Pace is time over distance, so the returned delta is in **milliseconds - * per meter** (current lap pace minus best lap pace) — multiply by the - * remaining lap distance for a projected time gap. A positive value means - * the current lap is running slower than the best lap's pace, negative - * means faster. Returns 0 until both laps have distance recorded. - * - * @return Pace delta in milliseconds per meter. - */ - float getPaceDifference() const; - /** - * @brief Gets the current speed in kilometers per hour. - * - * @return The current speed in km/h. - */ - float getCurrentSpeedKmh() const; - /** - * @brief Gets the current speed in miles per hour. - * - * @return The current speed in mph. - */ - float getCurrentSpeedMph() const; - - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Sector timing methods - - /** - * @brief Gets the best sector 1 time. - * - * @return The best sector 1 time in milliseconds, or 0 if no valid sector 1 time recorded. - */ - unsigned long getBestSector1Time() const; - /** - * @brief Gets the best sector 2 time. - * - * @return The best sector 2 time in milliseconds, or 0 if no valid sector 2 time recorded. - */ - unsigned long getBestSector2Time() const; - /** - * @brief Gets the best sector 3 time. - * - * @return The best sector 3 time in milliseconds, or 0 if no valid sector 3 time recorded. - */ - unsigned long getBestSector3Time() const; - /** - * @brief Gets the current lap sector 1 time. - * - * @return The current lap sector 1 time in milliseconds, or 0 if sector 1 not yet completed. - */ - unsigned long getCurrentLapSector1Time() const; - /** - * @brief Gets the current lap sector 2 time. - * - * @return The current lap sector 2 time in milliseconds, or 0 if sector 2 not yet completed. - */ - unsigned long getCurrentLapSector2Time() const; - /** - * @brief Gets the current lap sector 3 time. - * - * @return The current lap sector 3 time in milliseconds, or 0 if sector 3 not yet completed. - */ - unsigned long getCurrentLapSector3Time() const; - /** - * @brief Gets the optimal lap time calculated from best sector times. - * - * @return The sum of best sector 1, 2, and 3 times in milliseconds, or 0 if sectors not configured. - */ - unsigned long getOptimalLapTime() const; - /** - * @brief Gets the lap number that achieved the best sector 1 time. - * - * @return The lap number, or 0 if no valid sector 1 time recorded. - */ - int getBestSector1LapNumber() const; - /** - * @brief Gets the lap number that achieved the best sector 2 time. - * - * @return The lap number, or 0 if no valid sector 2 time recorded. - */ - int getBestSector2LapNumber() const; - /** - * @brief Gets the lap number that achieved the best sector 3 time. - * - * @return The lap number, or 0 if no valid sector 3 time recorded. - */ - int getBestSector3LapNumber() const; - /** - * @brief Gets the current sector the driver is in. - * - * @return 0 if race not started, 1/2/3 for current sector. - */ - int getCurrentSector() const; - /** - * @brief Checks if sector lines are configured. - * - * @return True if both sector 2 and sector 3 lines are configured, false otherwise. - */ - bool areSectorLinesConfigured() const; - /** - * @brief Number of crossing-zone exits whose interpolation was rejected. - * - * A rejection means the GPS data inside the zone never produced a usable - * straddling pair (no side change, pair too far from the line, incoherent - * timestamps, or crossing landing off the line segment). A steadily - * climbing count with a non-incrementing lap counter is the signature of - * a misplaced line or an unsuitable GPS setup — previously this failure - * was visible only on debug serial. - * - * @return Count of rejected crossings since construction or reset(). - */ - unsigned int getRejectedCrossingCount() const; - /** - * @brief Checks if the start/finish line is configured. - * - * Until setStartFinishLine() has been called with a valid (non-degenerate, - * finite) line, loop() performs no start/finish crossing detection. - * - * @return True if a valid start/finish line has been set, false otherwise. - */ - bool isStartFinishLineConfigured() const; - - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // Direction detection methods - - /** - * @brief Gets the detected driving direction. - * - * @return DIR_UNKNOWN (0), DIR_FORWARD (1), or DIR_REVERSE (2). - */ - int getDirection() const; - /** - * @brief Checks if the driving direction has been resolved. - * - * @return True if direction is known (forward or reverse), false if still unknown. - */ - bool isDirectionResolved() const; - -private: - template - void debug_print(Args&&... args) { - if(_serial) { - _serial->print(std::forward(args)...); - } - } - template - void debug_println(Args&&... args) { - if(_serial) { - _serial->println(std::forward(args)...); - } - } - - /** - * @brief Checks if the kart is crossing the start/finish line and calculates lap time and crossing point. - * - * This function is responsible for detecting when the kart is crossing the start/finish line. It compares - * the current position to the start/finish line and, if it is within a specified threshold distance, - * starts saving GPS data to a buffer. When the kart moves away from the line, the function calls - * interpolateCrossingPoint() to calculate the precise point at which the kart crossed the line, - * and computes the lap time. - * - * @param currentLat Latitude of the current position in decimal degrees. - * @param currentLng Longitude of the current position in decimal degrees. - * @param currentTimeMilliseconds The current time in milliseconds. - */ - bool checkStartFinish(double currentLat, double currentLng); - /** - * @brief Checks if the kart is crossing a sector line and handles sector timing. - * - * @param currentLat Latitude of the current position in decimal degrees. - * @param currentLng Longitude of the current position in decimal degrees. - * @param pointALat Latitude of sector line point A. - * @param pointALng Longitude of sector line point A. - * @param pointBLat Latitude of sector line point B. - * @param pointBLng Longitude of sector line point B. - * @param crossingFlag Reference to the crossing state flag for this line. - * @param sectorNumber The sector number (2 or 3) being checked. - * @return True if near or crossing the line, false otherwise. - */ - bool checkSectorLine(double currentLat, double currentLng, double pointALat, double pointALng, double pointBLat, double pointBLng, bool& crossingFlag, int sectorNumber); - /** - * @brief Thin wrapper over CrossingEngine::detect — supplies this timer's - * vehicle state (time, odometer, speed, previous-fix snapshot) so - * checkStartFinish and checkSectorLine keep their historical call shape. - * - * @param currentLat / currentLng Current GPS position. - * @param pointALat / pointALng Line endpoint A. - * @param pointBLat / pointBLng Line endpoint B. - * @param crossingFlag Reference to the per-line in-zone flag. - * @param lineLabel Debug label: 0 = start/finish, 2 / 3 = sector. - * @param[out] outLat / outLng / outTime / outOdometer Interpolated crossing - * point — only valid when the return value is LINE_DETECT_COMPLETED. - * @return LINE_DETECT_NONE / IN_ZONE / COMPLETED. See enum docs. - */ - LineDetectResult _detectLineCrossing( - double currentLat, double currentLng, - double pointALat, double pointALng, - double pointBLat, double pointBLng, - bool& crossingFlag, - int lineLabel, - double& outLat, double& outLng, - unsigned long& outTime, - double& outOdometer); - /** - * @brief Handles the logic when a line is crossed, updating sector times. - * - * @param crossingTime The time when the line was crossed. - * @param sectorNumber 0 for start/finish, 2 for sector 2, 3 for sector 3. - */ - void handleLineCrossing(unsigned long crossingTime, int sectorNumber); - /** - * @brief Updates best sector times if current lap sector times are better. - */ - void updateBestSectors(); - - Stream *_serial; - DirectionDetector _directionDetector; - // The shared crossing pipeline (buffer + interpolation). ONE engine for - // all three lines — the historical shared-buffer design, kept so the - // by-value CourseManager timer array doesn't triple in size. loop() - // enforces the resulting only-one-line-crossing-at-a-time exclusion. - CrossingEngine _crossingEngine; - - unsigned long millisecondsSinceMidnight = 0; - // Timing variables - double crossingThresholdMeters; - bool raceStarted = false; - bool crossing = false; - unsigned long currentLapStartTime = 0; - unsigned long lastLapTime = 0; - unsigned long bestLapTime = 0; - float currentLapOdometerStart = 0.0; - float lastLapDistance = 0.0; - float bestLapDistance = 0.0; - float currentSpeedkmh = 0.0; - int bestLapNumber = 0; - int laps = 0; - - // Sector timing state - int currentSector = 0; // 0=not started, 1/2/3=in sector - unsigned long currentSectorStartTime = 0; - bool crossingSector2 = false; - bool crossingSector3 = false; - - // Current lap sector times (reset each lap) - unsigned long currentLapSector1Time = 0; - unsigned long currentLapSector2Time = 0; - unsigned long currentLapSector3Time = 0; - - // Best sector times (persistent across laps) - unsigned long bestSector1Time = 0; - unsigned long bestSector2Time = 0; - unsigned long bestSector3Time = 0; - - // Lap numbers that achieved best sectors - int bestSector1LapNumber = 0; - int bestSector2LapNumber = 0; - int bestSector3LapNumber = 0; - - float totalDistanceTraveled = 0; - float positionPrevAlt = 0.00; - double positionPrevLat = 0.00; - double positionPrevLng = 0.00; - bool firstPositionReceived = false; // Explicit flag for first GPS fix detection - - // Previous GPS fix snapshot (used as Catmull-Rom pre-crossing control point) - crossingPointBufferEntry prevFix = {0, 0, 0, 0, 0}; - bool hasPrevFix = false; - - double startFinishPointALat = 0.0; - double startFinishPointALng = 0.0; - double startFinishPointBLat = 0.0; - double startFinishPointBLng = 0.0; - - // Sector 2 line coordinates - double sector2PointALat = 0.0; - double sector2PointALng = 0.0; - double sector2PointBLat = 0.0; - double sector2PointBLng = 0.0; - - // Sector 3 line coordinates - double sector3PointALat = 0.0; - double sector3PointALng = 0.0; - double sector3PointBLat = 0.0; - double sector3PointBLng = 0.0; - - // Line configuration flags — loop() skips detection for unconfigured lines - bool startFinishLineConfigured = false; - bool sector2LineConfigured = false; - bool sector3LineConfigured = false; - - // Consecutive fixes rejected for jumping > GPS_MAX_PLAUSIBLE_JUMP_METERS - int consecutiveJumpCount = 0; -}; - +/** + * GPS-based lap timing library for go-kart and racing applications. + * This library does NOT interface with your GPS, simply feed it data and check the state. + * Supports start/finish line detection, 3-sector split timing, pace comparison, and distance tracking. + * + * The development of this library has been overseen, and all documentation has been generated using chatGPT4. + */ + +#ifndef _DOVES_LAP_TIMER_H +#define _DOVES_LAP_TIMER_H + +#include "ArxTypeTraits.h" +#include "GeoMath.h" +#include "CrossingEngine.h" +using TRITYPE = double; + +// On classic AVR (Mega, Uno) `double` is a 32-bit float (~7 significant +// digits). At real-world latitudes/longitudes that quantizes position to +// roughly 0.2-0.75 m and pushes per-fix haversine half-angle differences +// below float precision: lap *counting* still works (the 7 m crossing +// threshold absorbs it) but odometer increments and crossing interpolation +// carry large relative error. The full advertised precision requires a +// target with true 64-bit double (nRF52840, ESP32, SAMD51, RP2040, ...). +// See README "Hardware" notes. +#if defined(__SIZEOF_DOUBLE__) && (__SIZEOF_DOUBLE__ < 8) +#warning "DovesLapTimer: 'double' is only 32 bits on this target (classic AVR). Lap counting works but GPS math runs degraded - distances and interpolated crossing times will be noticeably less accurate. Use a 64-bit-double MCU (e.g. XIAO nRF52840) for full precision." +#endif + +// CROSSING_LINE_SIDE_* now live in CrossingEngine.h (included above). + +// Course detection constants +#define COURSE_DETECT_SPEED_THRESHOLD_MPH 20 +#define COURSE_DETECT_WAYPOINT_PROXIMITY_METERS 10.0 +#define COURSE_DETECT_MIN_DISTANCE_METERS 200.0 +#define COURSE_DETECT_DISTANCE_TOLERANCE_PCT 0.25 +#define COURSE_DETECT_MAX_REJECTIONS 3 +// Completed proximity passes (full laps back at the waypoint) that matched +// no configured course length before CourseManager falls back to Lap +// Anything. Without this, a wrong/missing course config left detection in +// WAYPOINT_SET forever while the WaypointLapTimer's laps were never surfaced. +#define COURSE_DETECT_MAX_NO_MATCH_PASSES 3 +// Distance failsafe: if detection is still incomplete after driving +// factor x (longest configured course length), or the floor below for very +// short courses, fall back to Lap Anything. Catches the "never returns to +// within 10m of the waypoint" hang that pass counting can't see. +#define COURSE_DETECT_FALLBACK_DISTANCE_FACTOR 4.0 +#define COURSE_DETECT_FALLBACK_MIN_METERS 2000.0 +#define METERS_TO_FEET GEOMATH_METERS_TO_FEET + +// Waypoint lap timer constants +#define WAYPOINT_LAP_MIN_DISTANCE_METERS 100.0 +#define WAYPOINT_LAP_PROXIMITY_METERS 30.0 + +// Direction detection +#define DIR_UNKNOWN 0 +#define DIR_FORWARD 1 +#define DIR_REVERSE 2 + +// Course detection states +#define DETECT_STATE_IDLE 0 +#define DETECT_STATE_WAITING_FOR_SPEED 1 +#define DETECT_STATE_WAYPOINT_SET 2 +#define DETECT_STATE_CANDIDATES_READY 3 +#define DETECT_STATE_DETECTED 4 + +// Maximum courses supported +#define MAX_COURSES 8 + +// DOVES_MILLIS_PER_DAY, timeSinceMidnightDelta(), CROSSING_MAX_FIX_GAP_MS, +// CROSSING_PAIR_SPACING_FACTOR, LineDetectResult, and +// crossingPointBufferEntry now live in CrossingEngine.h (included above). + +// GPS input validation: a single-fix jump beyond this is treated as a glitch +// and dropped; after GPS_JUMP_REACCEPT_COUNT consecutive far fixes the new +// position is accepted as real (signal re-acquisition) and the position is +// re-seeded without crediting the gap to the odometer. +#define GPS_MAX_PLAUSIBLE_JUMP_METERS 500.0 +#define GPS_JUMP_REACCEPT_COUNT 3 + +struct DirectionDetector { + int direction; // DIR_UNKNOWN, DIR_FORWARD, DIR_REVERSE + bool raceSeen; + // Per-lap window: timestamps of physical S2/S3 crossings since the last + // start/finish. Direction is decided only when BOTH are present, by their + // temporal order — independent of the lap calculation's sector output. + unsigned long lapS2CrossingTime; + unsigned long lapS3CrossingTime; + + DirectionDetector() + : direction(DIR_UNKNOWN), raceSeen(false), + lapS2CrossingTime(0), lapS3CrossingTime(0) {} + void reset() { + direction = DIR_UNKNOWN; + raceSeen = false; + lapS2CrossingTime = 0; + lapS3CrossingTime = 0; + } + void onLineCrossing(int sectorNumber, unsigned long crossingTime); + bool isReverse() const { return direction == DIR_REVERSE; } + bool isResolved() const { return direction != DIR_UNKNOWN; } +}; + +class DovesLapTimer { +public: + DovesLapTimer(double crossingThresholdMeters = 7, Stream *debugSerial = NULL); + + /** + * @brief Updates a few internal stats and then checks the status of crossing a line + * + * This should be run every time the GPS is fixed and gets a new location is aquired! + * All of the magic happens here!!!!!! + * + * @param currentLat Latitude of the current position in decimal degrees. + * @param currentLng Longitude of the current position in decimal degrees. + * @param currentAltitudeMeters Altitude of the current position in meters. + * @param currentSpeedKnots The current speed in knots + */ + int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * @brief Checks if the triangle formed by the given coordinates is an obtuse triangle. + * + * @param lat1 Latitude of the first point + * @param lon1 Longitude of the first point + * @param lat2 Latitude of the second point + * @param lon2 Longitude of the second point + * @param lat3 Latitude of the third point + * @param lon3 Longitude of the third point + * @return true if the triangle is an obtuse triangle, false otherwise + */ + bool isObtuseTriangle(double lat1, double lon1, double lat2, double lon2, double lat3, double lon3); + + /** + * @brief Check if a driver is within a threshold distance of the line formed by the two crossing points. + * + * This function checks whether the driver is within a threshold distance from the line formed by + * crossing points A and B. The threshold is defined by crossingThresholdMeters. + * + * @param driverLat Latitude of the driver's position. + * @param driverLon Longitude of the driver's position. + * @param crossingPointALat Latitude of crossing point A. + * @param crossingPointALon Longitude of crossing point A. + * @param crossingPointBLat Latitude of crossing point B. + * @param crossingPointBLon Longitude of crossing point B. + * @return True if the driver is within the threshold distance, otherwise False. + */ + bool insideLineThreshold(double driverLat, double driverLon, double crossingPointALat, double crossingPointALon, double crossingPointBLat, double crossingPointBLon); + + /** + * @brief Determines which side of a line a driver is on. + * + * Given a point's position and two points defining a line segment, this function computes + * the side of the line the point is on. The line is treated as infinite for the side determination. + * + * @param driverLat The latitude of the point's position. + * @param driverLng The longitude of the point's position. + * @param pointALat The latitude of the first point of the line. + * @param pointALng The longitude of the first point of the line. + * @param pointBLat The latitude of the second point of the line. + * @param pointBLng The longitude of the second point of the line. + * @return Returns 1 if the point is on one side of the line, -1 if the point is on the other side, and 0 if the point is exactly on the line. + */ + int pointOnSideOfLine(double driverLat, double driverLng, double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Calculate the shortest distance between a point and a line segment. + * + * This function takes the coordinates of a point (pointX, pointY) and a line segment + * defined by two endpoints (startX, startY) and (endX, endY), and returns the shortest + * distance between the point and the line segment. Inputs are decimal-degree + * coordinates; the projection onto the segment happens in degree space, but + * every return path measures the final distance via haversine, so the + * result is in **meters**. + * + * @param pointX The x-coordinate of the point. + * @param pointY The y-coordinate of the point. + * @param startX The x-coordinate of the first endpoint of the line segment. + * @param startY The y-coordinate of the first endpoint of the line segment. + * @param endX The x-coordinate of the second endpoint of the line segment. + * @param endY The y-coordinate of the second endpoint of the line segment. + * @return The shortest distance between the point and the line segment, in meters. + */ + double pointLineSegmentDistance(double pointX, double pointY, double startX, double startY, double endX, double endY); + /** + * @brief Calculates the great-circle distance between two points on the Earth's surface using the Haversine formula. + * + * This function takes the latitude and longitude of two points in decimal degrees and returns the distance between + * them in meters. The Haversine formula is used to account for the Earth's curvature, providing accurate results + * for relatively short distances (up to a few thousand kilometers). + * + * Note: This function assumes that the Earth is a perfect sphere with a radius of 6,371 kilometers. + * + * @param lat1 Latitude of the first point in decimal degrees + * @param lon1 Longitude of the first point in decimal degrees + * @param lat2 Latitude of the second point in decimal degrees + * @param lon2 Longitude of the second point in decimal degrees + * @return double The great-circle distance between the two points in meters + */ + double haversine(double lat1, double lon1, double lat2, double lon2); + /** + * @brief Calculates the distance between two GPS points, including altitude difference. + * + * This function computes the distance between two GPS points using the haversine formula, + * and takes into account the altitude difference between the points. The resulting distance + * is the true 3D distance between the points, rather than just the 2D distance on the Earth's surface. + * + * @param prevLat Latitude of the first GPS point in decimal degrees. + * @param prevLng Longitude of the first GPS point in decimal degrees. + * @param prevAlt Altitude of the first GPS point in meters. + * @param currentLat Latitude of the second GPS point in decimal degrees. + * @param currentLng Longitude of the second GPS point in decimal degrees. + * @param currentAlt Altitude of the second GPS point in meters. + * @return The 3D distance between the two GPS points in meters. + */ + double haversine3D(double prevLat, double prevLng, double prevAlt, double currentLat, double currentLng, double currentAlt); + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * @brief Reset all parameters back to 0 + */ + void reset(); + /** + * @brief Sets the start/finish line using two points (A and B). + * + * @param pointALat Latitude of point A in decimal degrees. + * @param pointALng Longitude of point A in decimal degrees. + * @param pointBLat Latitude of point B in decimal degrees. + * @param pointBLng Longitude of point B in decimal degrees. + */ + void setStartFinishLine(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Sets the sector 2 line using two points (A and B). + * + * @param pointALat Latitude of point A in decimal degrees. + * @param pointALng Longitude of point A in decimal degrees. + * @param pointBLat Latitude of point B in decimal degrees. + * @param pointBLng Longitude of point B in decimal degrees. + */ + void setSector2Line(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Sets the sector 3 line using two points (A and B). + * + * @param pointALat Latitude of point A in decimal degrees. + * @param pointALng Longitude of point A in decimal degrees. + * @param pointBLat Latitude of point B in decimal degrees. + * @param pointBLng Longitude of point B in decimal degrees. + */ + void setSector3Line(double pointALat, double pointALng, double pointBLat, double pointBLng); + /** + * @brief Updates the current GPS time since midnight. + * + * @param currentTimeMilliseconds The current time in milliseconds. + */ + void updateCurrentTime(unsigned long currentTimeMilliseconds); + /** + * @brief forces linear interpolation when checking crossing line + * + * Might maybe be more accurate if your track(s) finishline is on a straight or other location you expect constant speed + */ + void forceLinearInterpolation(); + /** + * @brief Forces Catmull-Rom spline interpolation when checking crossing line. + * + * Catmull-Rom interpolation uses 4 control points to create a smooth curve, + * which can provide more accurate crossing time calculation when the vehicle + * path curves near the line. Falls back to linear interpolation automatically + * if insufficient control points are available (crossing detected too early + * in the buffer). + */ + void forceCatmullRomInterpolation(); + /** + * @brief Gets the race started status (passed the line one time). + * + * @return True if the race has started, false otherwise. + */ + bool getRaceStarted() const; + /** + * @brief Gets the crossing status. + * + * @return True if crossing the start/finish line, false otherwise. + */ + bool getCrossing() const; + /** + * @brief Gets the current lap start time. + * + * @return The current lap start time in milliseconds. + */ + unsigned long getCurrentLapStartTime() const; + /** + * @brief Gets the current lap time. + * + * @return The current lap time in milliseconds. + */ + unsigned long getCurrentLapTime() const; + /** + * @brief Gets the last lap time. + * + * @return The last lap time in milliseconds. + */ + unsigned long getLastLapTime() const; + /** + * @brief Gets the best lap time. + * + * @return The best lap time in milliseconds. + */ + unsigned long getBestLapTime() const; + /** + * @brief Gets the current lap odometer start. + * + * @return The distance traveled at the start of the current lap in meters. + */ + float getCurrentLapOdometerStart() const; + /** + * @brief Gets the current lap distance. + * + * @return The distance traveled during the current lap in meters. + */ + float getCurrentLapDistance() const; + /** + * @brief Gets the last lap distance. + * + * @return The distance traveled during the last lap in meters. + */ + float getLastLapDistance() const; + /** + * @brief Gets the best lap distance. + * + * @return The distance traveled during the best lap in meters. + */ + float getBestLapDistance() const; + /** + * @brief Gets the total distance traveled. + * + * @return The total distance traveled in meters. + */ + float getTotalDistanceTraveled() const; + /** + * @brief Gets the best lap number. + * + * @return The lap number of the best lap. + */ + int getBestLapNumber() const; + /** + * @brief Gets the total number of laps completed. + * + * @return The total number of laps completed. + */ + int getLaps() const; + /** + * @brief Calculates the pace difference between the current lap and the best lap. + * + * Pace is time over distance, so the returned delta is in **milliseconds + * per meter** (current lap pace minus best lap pace) — multiply by the + * remaining lap distance for a projected time gap. A positive value means + * the current lap is running slower than the best lap's pace, negative + * means faster. Returns 0 until both laps have distance recorded. + * + * @return Pace delta in milliseconds per meter. + */ + float getPaceDifference() const; + /** + * @brief Gets the current speed in kilometers per hour. + * + * @return The current speed in km/h. + */ + float getCurrentSpeedKmh() const; + /** + * @brief Gets the current speed in miles per hour. + * + * @return The current speed in mph. + */ + float getCurrentSpeedMph() const; + + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Sector timing methods + + /** + * @brief Gets the best sector 1 time. + * + * @return The best sector 1 time in milliseconds, or 0 if no valid sector 1 time recorded. + */ + unsigned long getBestSector1Time() const; + /** + * @brief Gets the best sector 2 time. + * + * @return The best sector 2 time in milliseconds, or 0 if no valid sector 2 time recorded. + */ + unsigned long getBestSector2Time() const; + /** + * @brief Gets the best sector 3 time. + * + * @return The best sector 3 time in milliseconds, or 0 if no valid sector 3 time recorded. + */ + unsigned long getBestSector3Time() const; + /** + * @brief Gets the current lap sector 1 time. + * + * @return The current lap sector 1 time in milliseconds, or 0 if sector 1 not yet completed. + */ + unsigned long getCurrentLapSector1Time() const; + /** + * @brief Gets the current lap sector 2 time. + * + * @return The current lap sector 2 time in milliseconds, or 0 if sector 2 not yet completed. + */ + unsigned long getCurrentLapSector2Time() const; + /** + * @brief Gets the current lap sector 3 time. + * + * @return The current lap sector 3 time in milliseconds, or 0 if sector 3 not yet completed. + */ + unsigned long getCurrentLapSector3Time() const; + /** + * @brief Gets the optimal lap time calculated from best sector times. + * + * @return The sum of best sector 1, 2, and 3 times in milliseconds, or 0 if sectors not configured. + */ + unsigned long getOptimalLapTime() const; + /** + * @brief Gets the lap number that achieved the best sector 1 time. + * + * @return The lap number, or 0 if no valid sector 1 time recorded. + */ + int getBestSector1LapNumber() const; + /** + * @brief Gets the lap number that achieved the best sector 2 time. + * + * @return The lap number, or 0 if no valid sector 2 time recorded. + */ + int getBestSector2LapNumber() const; + /** + * @brief Gets the lap number that achieved the best sector 3 time. + * + * @return The lap number, or 0 if no valid sector 3 time recorded. + */ + int getBestSector3LapNumber() const; + /** + * @brief Gets the current sector the driver is in. + * + * @return 0 if race not started, 1/2/3 for current sector. + */ + int getCurrentSector() const; + /** + * @brief Checks if sector lines are configured. + * + * @return True if both sector 2 and sector 3 lines are configured, false otherwise. + */ + bool areSectorLinesConfigured() const; + /** + * @brief Number of crossing-zone exits whose interpolation was rejected. + * + * A rejection means the GPS data inside the zone never produced a usable + * straddling pair (no side change, pair too far from the line, incoherent + * timestamps, or crossing landing off the line segment). A steadily + * climbing count with a non-incrementing lap counter is the signature of + * a misplaced line or an unsuitable GPS setup — previously this failure + * was visible only on debug serial. + * + * @return Count of rejected crossings since construction or reset(). + */ + unsigned int getRejectedCrossingCount() const; + /** + * @brief Checks if the start/finish line is configured. + * + * Until setStartFinishLine() has been called with a valid (non-degenerate, + * finite) line, loop() performs no start/finish crossing detection. + * + * @return True if a valid start/finish line has been set, false otherwise. + */ + bool isStartFinishLineConfigured() const; + + ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Direction detection methods + + /** + * @brief Gets the detected driving direction. + * + * @return DIR_UNKNOWN (0), DIR_FORWARD (1), or DIR_REVERSE (2). + */ + int getDirection() const; + /** + * @brief Checks if the driving direction has been resolved. + * + * @return True if direction is known (forward or reverse), false if still unknown. + */ + bool isDirectionResolved() const; + +private: +#ifdef DOVES_DISABLE_DEBUG + // Compile-time debug kill switch — see CrossingEngine.h for the rationale. + template void debug_print(Args&&...) {} + template void debug_println(Args&&...) {} +#else + template + void debug_print(Args&&... args) { + if(_serial) { + _serial->print(std::forward(args)...); + } + } + template + void debug_println(Args&&... args) { + if(_serial) { + _serial->println(std::forward(args)...); + } + } +#endif + + /** + * @brief Checks if the kart is crossing the start/finish line and calculates lap time and crossing point. + * + * This function is responsible for detecting when the kart is crossing the start/finish line. It compares + * the current position to the start/finish line and, if it is within a specified threshold distance, + * starts saving GPS data to a buffer. When the kart moves away from the line, the function calls + * interpolateCrossingPoint() to calculate the precise point at which the kart crossed the line, + * and computes the lap time. + * + * @param currentLat Latitude of the current position in decimal degrees. + * @param currentLng Longitude of the current position in decimal degrees. + * @param currentTimeMilliseconds The current time in milliseconds. + */ + bool checkStartFinish(double currentLat, double currentLng); + /** + * @brief Checks if the kart is crossing a sector line and handles sector timing. + * + * @param currentLat Latitude of the current position in decimal degrees. + * @param currentLng Longitude of the current position in decimal degrees. + * @param pointALat Latitude of sector line point A. + * @param pointALng Longitude of sector line point A. + * @param pointBLat Latitude of sector line point B. + * @param pointBLng Longitude of sector line point B. + * @param crossingFlag Reference to the crossing state flag for this line. + * @param sectorNumber The sector number (2 or 3) being checked. + * @return True if near or crossing the line, false otherwise. + */ + bool checkSectorLine(double currentLat, double currentLng, double pointALat, double pointALng, double pointBLat, double pointBLng, bool& crossingFlag, int sectorNumber); + /** + * @brief Thin wrapper over CrossingEngine::detect — supplies this timer's + * vehicle state (time, odometer, speed, previous-fix snapshot) so + * checkStartFinish and checkSectorLine keep their historical call shape. + * + * @param currentLat / currentLng Current GPS position. + * @param pointALat / pointALng Line endpoint A. + * @param pointBLat / pointBLng Line endpoint B. + * @param crossingFlag Reference to the per-line in-zone flag. + * @param lineLabel Debug label: 0 = start/finish, 2 / 3 = sector. + * @param[out] outLat / outLng / outTime / outOdometer Interpolated crossing + * point — only valid when the return value is LINE_DETECT_COMPLETED. + * @return LINE_DETECT_NONE / IN_ZONE / COMPLETED. See enum docs. + */ + LineDetectResult _detectLineCrossing( + double currentLat, double currentLng, + double pointALat, double pointALng, + double pointBLat, double pointBLng, + bool& crossingFlag, + int lineLabel, + double& outLat, double& outLng, + unsigned long& outTime, + double& outOdometer); + /** + * @brief Handles the logic when a line is crossed, updating sector times. + * + * @param crossingTime The time when the line was crossed. + * @param sectorNumber 0 for start/finish, 2 for sector 2, 3 for sector 3. + */ + void handleLineCrossing(unsigned long crossingTime, int sectorNumber); + /** + * @brief Updates best sector times if current lap sector times are better. + */ + void updateBestSectors(); + + Stream *_serial; + DirectionDetector _directionDetector; + // The shared crossing pipeline (buffer + interpolation). ONE engine for + // all three lines — the historical shared-buffer design, kept so the + // by-value CourseManager timer array doesn't triple in size. loop() + // enforces the resulting only-one-line-crossing-at-a-time exclusion. + CrossingEngine _crossingEngine; + + unsigned long millisecondsSinceMidnight = 0; + // Timing variables + double crossingThresholdMeters; + bool raceStarted = false; + bool crossing = false; + unsigned long currentLapStartTime = 0; + unsigned long lastLapTime = 0; + unsigned long bestLapTime = 0; + float currentLapOdometerStart = 0.0; + float lastLapDistance = 0.0; + float bestLapDistance = 0.0; + float currentSpeedkmh = 0.0; + int bestLapNumber = 0; + int laps = 0; + + // Sector timing state + int currentSector = 0; // 0=not started, 1/2/3=in sector + unsigned long currentSectorStartTime = 0; + bool crossingSector2 = false; + bool crossingSector3 = false; + + // Current lap sector times (reset each lap) + unsigned long currentLapSector1Time = 0; + unsigned long currentLapSector2Time = 0; + unsigned long currentLapSector3Time = 0; + + // Best sector times (persistent across laps) + unsigned long bestSector1Time = 0; + unsigned long bestSector2Time = 0; + unsigned long bestSector3Time = 0; + + // Lap numbers that achieved best sectors + int bestSector1LapNumber = 0; + int bestSector2LapNumber = 0; + int bestSector3LapNumber = 0; + + float totalDistanceTraveled = 0; + float positionPrevAlt = 0.00; + double positionPrevLat = 0.00; + double positionPrevLng = 0.00; + bool firstPositionReceived = false; // Explicit flag for first GPS fix detection + + // Previous GPS fix snapshot (used as Catmull-Rom pre-crossing control point) + crossingPointBufferEntry prevFix = {0, 0, 0, 0, 0}; + bool hasPrevFix = false; + + double startFinishPointALat = 0.0; + double startFinishPointALng = 0.0; + double startFinishPointBLat = 0.0; + double startFinishPointBLng = 0.0; + + // Sector 2 line coordinates + double sector2PointALat = 0.0; + double sector2PointALng = 0.0; + double sector2PointBLat = 0.0; + double sector2PointBLng = 0.0; + + // Sector 3 line coordinates + double sector3PointALat = 0.0; + double sector3PointALng = 0.0; + double sector3PointBLat = 0.0; + double sector3PointBLng = 0.0; + + // Line configuration flags — loop() skips detection for unconfigured lines + bool startFinishLineConfigured = false; + bool sector2LineConfigured = false; + bool sector3LineConfigured = false; + + // Consecutive fixes rejected for jumping > GPS_MAX_PLAUSIBLE_JUMP_METERS + int consecutiveJumpCount = 0; +}; + #endif \ No newline at end of file diff --git a/src/SprintTimer.h b/src/SprintTimer.h index 3983c35..a262365 100644 --- a/src/SprintTimer.h +++ b/src/SprintTimer.h @@ -186,6 +186,17 @@ class SprintTimer { bool isDirectionResolved() const { return false; } private: +#ifdef DOVES_DISABLE_DEBUG + // Compile-time debug kill switch: the runtime _serial null-check still + // keeps every debug string and print call-site in flash on production + // builds that never attach a debug Stream. Defining DOVES_DISABLE_DEBUG + // (e.g. -DDOVES_DISABLE_DEBUG in build flags) replaces the debug + // templates with empty inlines so the compiler drops it all — several + // KB on a fully-featured firmware. Debug behavior is unchanged when + // the macro is not defined. + template void debug_print(Args&&...) {} + template void debug_println(Args&&...) {} +#else template void debug_print(Args&&... args) { if(_serial) { _serial->print(std::forward(args)...); } @@ -194,6 +205,7 @@ class SprintTimer { void debug_println(Args&&... args) { if(_serial) { _serial->println(std::forward(args)...); } } +#endif /** @brief Segments in a complete run: 1 + configured splits. */ int _expectedSegments() const; diff --git a/src/WaypointLapTimer.h b/src/WaypointLapTimer.h index 466e4b4..839e8ef 100644 --- a/src/WaypointLapTimer.h +++ b/src/WaypointLapTimer.h @@ -1,137 +1,149 @@ -/** - * WaypointLapTimer - universal fallback lap timer ("Lap Anything" mode). - * - * Uses single-point proximity detection instead of crossing lines. - * Algorithm: - * 1. Wait for speed >= 20 mph, drop a waypoint - * 2. Drive away (min distance traveled) - * 3. On return to waypoint proximity, buffer approach points - * 4. On exit from proximity, use closest-approach point's time for lap split - * 5. Repeat for subsequent laps - * - * Duck-typed to match DovesLapTimer's public API so the display/logger - * can use either timer interchangeably. - */ - -#ifndef _WAYPOINT_LAP_TIMER_H -#define _WAYPOINT_LAP_TIMER_H - -#include -#include "DovesLapTimer.h" -#include "GeoMath.h" - -// Internal states -#define WLT_STATE_IDLE 0 -#define WLT_STATE_WAITING_SPEED 1 -#define WLT_STATE_DRIVING 2 -#define WLT_STATE_IN_PROXIMITY 3 - -class WaypointLapTimer { -public: - WaypointLapTimer(Stream *debugSerial = NULL); - - void updateCurrentTime(unsigned long currentTimeMilliseconds); - int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots); - void reset(); - void setSpeedThresholdMph(float mph); - void setProximityMeters(float meters); - - // Timing getters (duck-typed to DovesLapTimer) - bool getRaceStarted() const; - bool getCrossing() const; - int getLaps() const; - unsigned long getCurrentLapTime() const; - unsigned long getLastLapTime() const; - unsigned long getBestLapTime() const; - float getCurrentLapDistance() const; - float getTotalDistanceTraveled() const; - int getBestLapNumber() const; - float getPaceDifference() const; - float getCurrentSpeedKmh() const; - float getCurrentSpeedMph() const; - - float getLastLapDistance() const; - float getBestLapDistance() const; - - // Waypoint access - bool hasWaypoint() const; - double getWaypointLat() const; - double getWaypointLng() const; - - // Sector getters (not supported, return 0) - int getCurrentSector() const { return 0; } - bool areSectorLinesConfigured() const { return false; } - unsigned long getCurrentLapSector1Time() const { return 0; } - unsigned long getCurrentLapSector2Time() const { return 0; } - unsigned long getCurrentLapSector3Time() const { return 0; } - unsigned long getBestSector1Time() const { return 0; } - unsigned long getBestSector2Time() const { return 0; } - unsigned long getBestSector3Time() const { return 0; } - unsigned long getOptimalLapTime() const { return 0; } - int getBestSector1LapNumber() const { return 0; } - int getBestSector2LapNumber() const { return 0; } - int getBestSector3LapNumber() const { return 0; } - - // Direction (not applicable) - int getDirection() const { return DIR_UNKNOWN; } - bool isDirectionResolved() const { return false; } - -private: - template - void debug_print(Args&&... args) { - if(_serial) { _serial->print(std::forward(args)...); } - } - template - void debug_println(Args&&... args) { - if(_serial) { _serial->println(std::forward(args)...); } - } - - void _resetState(); - void _checkSpeed(double lat, double lng); - void _checkProximity(double lat, double lng); - void _updateProximity(double lat, double lng); - void _resetClosestApproach(); - void _finalizeProximityPass(); - - Stream *_serial; - - int _state; - unsigned long _millisecondsSinceMidnight; - - // Waypoint - double _waypointLat; - double _waypointLng; - float _waypointOdometer; - - // Odometer / position - float _totalDistanceTraveled; - double _positionPrevLat; - double _positionPrevLng; - bool _firstPositionReceived; - int _consecutiveJumpCount; - float _currentSpeedKmh; - float _speedThresholdMph; - float _proximityMeters; - - // Timing - bool _raceStarted; - bool _crossing; - unsigned long _currentLapStartTime; - unsigned long _lastLapTime; - unsigned long _bestLapTime; - float _currentLapOdometerStart; - float _lastLapDistance; - float _bestLapDistance; - int _bestLapNumber; - int _laps; - - // Closest approach to the waypoint during the current proximity pass. - // Only these three scalars are needed for the lap split — the old - // 50-entry buffer of every in-proximity fix was written and never read - // (1.6 KB of dead SRAM per instance). - float _closestDist; - unsigned long _closestTime; - float _closestOdometer; -}; - -#endif +/** + * WaypointLapTimer - universal fallback lap timer ("Lap Anything" mode). + * + * Uses single-point proximity detection instead of crossing lines. + * Algorithm: + * 1. Wait for speed >= 20 mph, drop a waypoint + * 2. Drive away (min distance traveled) + * 3. On return to waypoint proximity, buffer approach points + * 4. On exit from proximity, use closest-approach point's time for lap split + * 5. Repeat for subsequent laps + * + * Duck-typed to match DovesLapTimer's public API so the display/logger + * can use either timer interchangeably. + */ + +#ifndef _WAYPOINT_LAP_TIMER_H +#define _WAYPOINT_LAP_TIMER_H + +#include +#include "DovesLapTimer.h" +#include "GeoMath.h" + +// Internal states +#define WLT_STATE_IDLE 0 +#define WLT_STATE_WAITING_SPEED 1 +#define WLT_STATE_DRIVING 2 +#define WLT_STATE_IN_PROXIMITY 3 + +class WaypointLapTimer { +public: + WaypointLapTimer(Stream *debugSerial = NULL); + + void updateCurrentTime(unsigned long currentTimeMilliseconds); + int loop(double currentLat, double currentLng, float currentAltitudeMeters, float currentSpeedKnots); + void reset(); + void setSpeedThresholdMph(float mph); + void setProximityMeters(float meters); + + // Timing getters (duck-typed to DovesLapTimer) + bool getRaceStarted() const; + bool getCrossing() const; + int getLaps() const; + unsigned long getCurrentLapTime() const; + unsigned long getLastLapTime() const; + unsigned long getBestLapTime() const; + float getCurrentLapDistance() const; + float getTotalDistanceTraveled() const; + int getBestLapNumber() const; + float getPaceDifference() const; + float getCurrentSpeedKmh() const; + float getCurrentSpeedMph() const; + + float getLastLapDistance() const; + float getBestLapDistance() const; + + // Waypoint access + bool hasWaypoint() const; + double getWaypointLat() const; + double getWaypointLng() const; + + // Sector getters (not supported, return 0) + int getCurrentSector() const { return 0; } + bool areSectorLinesConfigured() const { return false; } + unsigned long getCurrentLapSector1Time() const { return 0; } + unsigned long getCurrentLapSector2Time() const { return 0; } + unsigned long getCurrentLapSector3Time() const { return 0; } + unsigned long getBestSector1Time() const { return 0; } + unsigned long getBestSector2Time() const { return 0; } + unsigned long getBestSector3Time() const { return 0; } + unsigned long getOptimalLapTime() const { return 0; } + int getBestSector1LapNumber() const { return 0; } + int getBestSector2LapNumber() const { return 0; } + int getBestSector3LapNumber() const { return 0; } + + // Direction (not applicable) + int getDirection() const { return DIR_UNKNOWN; } + bool isDirectionResolved() const { return false; } + +private: +#ifdef DOVES_DISABLE_DEBUG + // Compile-time debug kill switch: the runtime _serial null-check still + // keeps every debug string and print call-site in flash on production + // builds that never attach a debug Stream. Defining DOVES_DISABLE_DEBUG + // (e.g. -DDOVES_DISABLE_DEBUG in build flags) replaces the debug + // templates with empty inlines so the compiler drops it all — several + // KB on a fully-featured firmware. Debug behavior is unchanged when + // the macro is not defined. + template void debug_print(Args&&...) {} + template void debug_println(Args&&...) {} +#else + template + void debug_print(Args&&... args) { + if(_serial) { _serial->print(std::forward(args)...); } + } + template + void debug_println(Args&&... args) { + if(_serial) { _serial->println(std::forward(args)...); } + } +#endif + + void _resetState(); + void _checkSpeed(double lat, double lng); + void _checkProximity(double lat, double lng); + void _updateProximity(double lat, double lng); + void _resetClosestApproach(); + void _finalizeProximityPass(); + + Stream *_serial; + + int _state; + unsigned long _millisecondsSinceMidnight; + + // Waypoint + double _waypointLat; + double _waypointLng; + float _waypointOdometer; + + // Odometer / position + float _totalDistanceTraveled; + double _positionPrevLat; + double _positionPrevLng; + bool _firstPositionReceived; + int _consecutiveJumpCount; + float _currentSpeedKmh; + float _speedThresholdMph; + float _proximityMeters; + + // Timing + bool _raceStarted; + bool _crossing; + unsigned long _currentLapStartTime; + unsigned long _lastLapTime; + unsigned long _bestLapTime; + float _currentLapOdometerStart; + float _lastLapDistance; + float _bestLapDistance; + int _bestLapNumber; + int _laps; + + // Closest approach to the waypoint during the current proximity pass. + // Only these three scalars are needed for the lap split — the old + // 50-entry buffer of every in-proximity fix was written and never read + // (1.6 KB of dead SRAM per instance). + float _closestDist; + unsigned long _closestTime; + float _closestOdometer; +}; + +#endif From 359b6ec5d9fa248d1d3e52b113df2e681749717d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:18:28 +0000 Subject: [PATCH 7/7] =?UTF-8?q?chore:=20cut=20v4.3.0=20release=20=E2=80=94?= =?UTF-8?q?=20version=20bumps=20+=20CHANGELOG=20heading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - library.properties: 4.2.0 -> 4.3.0 - Doxyfile PROJECT_NUMBER: stale 4.0.0 -> 4.3.0 - CLAUDE.md version line -> 4.3.0 - CHANGELOG: move Unreleased entries under [4.3.0] - 2026-08-10 with release summary; add the 4.3.0 tag link Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TQK29n1LCebSUxf1nD12FW --- CHANGELOG.md | 8 ++++++++ CLAUDE.md | 2 +- Doxyfile | 2 +- library.properties | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f9be36..e1e7a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [4.3.0] – 2026-08-10 + +A feature release: point-to-point "sprint mode" timing, a compile-time debug +kill switch for production flash budgets, and the crossing-detection core +extracted into a reusable engine. No breaking API changes; all existing +`DovesLapTimer`/`CourseManager` behavior is pinned by the NMEA replay goldens. + ### Added - **`DOVES_DISABLE_DEBUG` compile-time debug kill switch.** The library's debug output is gated by a runtime `if (_serial)` check, so production @@ -324,6 +331,7 @@ actually matters. For pre-4.0 history (initial sector timing, Catmull-Rom interpolation work, etc.) see the git log directly. +[4.3.0]: https://github.com/TheAngryRaven/DovesLapTimer/releases/tag/v4.3.0 [4.2.0]: https://github.com/TheAngryRaven/DovesLapTimer/releases/tag/v4.2.0 [4.1.0]: https://github.com/TheAngryRaven/DovesLapTimer/releases/tag/v4.1.0 [4.0.0]: https://github.com/TheAngryRaven/DovesLapTimer/releases/tag/v4.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index b88d30b..a0d9af0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ In short: tests with changes, changelog updated, docs truthful, no broken window **What**: GPS-based lap timing Arduino library for go-kart / racing applications. **Author**: Michael Champagne (crimsondove) -**Version**: 4.2.0 +**Version**: 4.3.0 **Repo**: https://github.com/TheAngryRaven/DovesLapTimer **License**: GPL v3 **Dependency**: ArxTypeTraits (auto-included by Arduino Library Manager) diff --git a/Doxyfile b/Doxyfile index de1befd..6bdb1c3 100644 --- a/Doxyfile +++ b/Doxyfile @@ -9,7 +9,7 @@ # ----- Project metadata ----- PROJECT_NAME = "DovesLapTimer" -PROJECT_NUMBER = "4.0.0" +PROJECT_NUMBER = "4.3.0" PROJECT_BRIEF = "GPS-based lap timing Arduino library — go-karts to race cars" OUTPUT_DIRECTORY = docs-build OUTPUT_LANGUAGE = English diff --git a/library.properties b/library.properties index 18b17d7..4f00433 100644 --- a/library.properties +++ b/library.properties @@ -1,5 +1,5 @@ name=DovesLapTimer -version=4.2.0 +version=4.3.0 author=Michael Champagne (crimsondove) maintainer=Michael Champagne sentence=GPS-based lap timing library with course detection and multi-course support