From 64246e787641cf03f9e556ff2b5f4c2ebdc9c36b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 03:44:33 +0000 Subject: [PATCH 1/2] =?UTF-8?q?plan=200015:=20drag=20mode=20=E2=80=94=20di?= =?UTF-8?q?stance=20runs=20without=20a=20track?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main menu -> Drag -> pick 1/8 Mile / 1000 ft / 1/4 Mile / 1/2 Mile / 1 Mile and the session starts: no track file, no detection. Stage at a standstill, rollout-style launch (11.25 in past a re-latching standstill anchor, ET start interpolated between 25 Hz fixes), run ends at the target distance with interpolated ET + trap speed and a 0-60 split, then re-arms automatically for the next pass. All passes land in one DOVEX session with race_mode=DRAG (laps line = run ETs, same trailing-column scheme as SPRINT). - New host-tested pure unit drag_timer.{h,cpp}: the whole run state machine (ARMED/STAGED/LAUNCHED), the distance table, silent aborts (mid-run standstill, >=2 s fix gap — also how queue-creep phantom launches self-cancel), 14 doctest cases. - Sketch glue mirrors sprint: dragTimer != nullptr IS drag mode, drag branch first in every activeTimer*() helper, run capture on the run-count edge, trackDetected latched, idle-grace re-arm on each run AND each fresh STAGED latch. - UI: Drag main-menu row, PAGE_DRAG_DISTANCE picker (scrolling window), drag branches on the lap-time/pace/best pages (live ET, *staged*, trap/0-60 subtext, live 0-60 readout), LED pace pip suppressed between runs like sprint. - Sim: golden walk updated for the new menu row + a picker fixture; goldens regenerated; soak/determinism/goldens/oracles all pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BKa62h4fhn61pKn1LygrMK --- ARCHITECTURE.md | 7 + BirdsEye/BirdsEye.ino | 145 ++++++++++- BirdsEye/display_pages.h | 5 + BirdsEye/display_pages.ino | 116 ++++++++- BirdsEye/display_ui.ino | 26 +- BirdsEye/dovex_header.h | 11 +- BirdsEye/drag_timer.cpp | 238 ++++++++++++++++++ BirdsEye/drag_timer.h | 180 +++++++++++++ BirdsEye/gps_functions.ino | 7 + BirdsEye/idle_policy.h | 2 +- BirdsEye/neopixel.ino | 3 +- BirdsEye/sim/CMakeLists.txt | 1 + BirdsEye/sim/golden/golden_hashes.txt | 7 +- BirdsEye/sim/golden_main.cpp | 22 +- BirdsEye/sim/sim_prototypes.h | 11 + CHANGELOG.md | 11 + CLAUDE.md | 44 +++- docs/plans/0015-drag-mode.md | 153 ++++++++++++ tests/CMakeLists.txt | 2 + tests/drag_timer_test.cpp | 347 ++++++++++++++++++++++++++ 20 files changed, 1308 insertions(+), 30 deletions(-) create mode 100644 BirdsEye/drag_timer.cpp create mode 100644 BirdsEye/drag_timer.h create mode 100644 docs/plans/0015-drag-mode.md create mode 100644 tests/drag_timer_test.cpp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c5e458b..ae05089 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -185,6 +185,13 @@ to the matching `*_LOOP()`. - **Settings** (`settings`) — JSON key/value store on the SD card. - **CourseManager** (external library) — owns course detection, sector timing, and the Lap Anything fallback once a track is matched. +- **Drag mode** (`drag_timer` pure unit) — distance runs with no track at + all: pick 1/8 mile…1 mile from the menu, stage at a standstill, and a + rollout-style launch starts the clock. ET, trap speed, and a 0-60 split + per run; re-arms automatically after each pass. The whole state machine + (re-latching standstill anchor, interpolated rollout/finish crossings, + silent aborts) is host-tested; the sketch keeps only sprint-style glue + (plan 0015). ## Design decisions worth knowing diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index ee21c1b..5a8f521 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -95,6 +95,7 @@ #include "display_pages.h" #include "display_ui.h" #include "dovex_header.h" +#include "drag_timer.h" #include "gps_functions.h" #include "gps_status_page.h" #include "haversine.h" @@ -166,6 +167,14 @@ int detectedTrackIndex = -1; SprintTimer* sprintTimer = nullptr; char sprintCourseName[MAX_LAYOUT_LENGTH] = ""; int sprintLastRunCount = 0; // run-complete edge for lap history capture + +// Drag mode (plan 0015): distance runs with no track at all. Same shape +// as sprint — a non-null dragTimer IS drag mode, courseManager stays +// null for the session, and runs duck-type as laps everywhere. +drag_timer::DragTimer* dragTimer = nullptr; +int dragLastRunCount = 0; // run-complete edge for lap history capture +int dragDistanceIdx = -1; // index into drag_timer's distance table +bool dragWasStaged = false; // ARMED->STAGED edge for the idle-grace re-arm unsigned long idleStartTime = 0; bool idleTimerRunning = false; bool raceActive = false; @@ -501,6 +510,33 @@ unsigned long lapHistory[lapHistoryMaxLaps]; int lapHistoryCount = 0; void checkForNewLapData() { + // Drag mode: same run-count-edge capture as sprint below (identical + // consecutive ETs are normal). Two idle-grace re-arms keep a staging + // queue from ending the session on tach-less cars: every completed run + // (sprint precedent) and every fresh STAGED latch — queue creep + // re-stages every couple of minutes, so an active queue never idles + // out, while a genuinely parked car still ends after the grace. + if (dragTimer != nullptr) { + const bool stagedNow = dragTimer->staged(); + if (stagedNow && !dragWasStaged) { + raceSessionStartedAt = millis(); + } + dragWasStaged = stagedNow; + + int runs = dragTimer->runs(); + if (runs > dragLastRunCount) { + dragLastRunCount = runs; + raceSessionStartedAt = millis(); + if (lapHistoryCount < lapHistoryMaxLaps) { + lastLap = dragTimer->lastEtMs(); + lapHistory[lapHistoryCount] = lastLap; + lapHistoryCount++; + debugln(F("New drag run added to history...")); + } + } + return; + } + // Sprint mode: capture on the RUN-COMPLETE EDGE (run count increment), // not on value change — two identical run times in a row are normal at // autocross and the value-change dedupe below would silently drop the @@ -760,6 +796,9 @@ const int PAGE_COURSE_POINT = -15; // "Save current pos" averaging hold // two-row confirm, not one of the model's screens, so it must not be handed // to course_creator::rowCount(). const int PAGE_COURSE_PRUNE = -16; // "Track full - drop N old runs?" +// Drag mode (plan 0015): the distance picker between the main menu's +// Drag row and the race rotation. Five distances + Back. +const int PAGE_DRAG_DISTANCE = -17; // running menu (these must be in order) #if BIRDSEYE_ENABLE_PROFILING @@ -1290,6 +1329,41 @@ bool sprintModeIsActive() { return sprintTimer != nullptr; } +/** + * @brief Drag mode (plan 0015) — a non-null dragTimer IS drag mode, the + * same construction as sprint. Every activeTimer*() helper below checks + * the drag branch first; drag runs duck-type as laps. + */ +bool dragModeIsActive() { + return dragTimer != nullptr; +} + +// Drag-mode display accessors (null-safe): the trap/0-60 stats have no +// lap-timer analog, so they don't ride the activeTimer*() surface — +// display_pages reads these directly, like the sprint pages read +// sprintModeIsActive(). +bool dragIsStaged() { + return dragTimer != nullptr && dragTimer->staged(); +} +const char* dragDistanceLabel() { + return drag_timer::label(dragDistanceIdx); +} +float dragLastTrapMph() { + return dragTimer != nullptr ? dragTimer->lastTrapMph() : 0.0f; +} +unsigned long dragLast0to60Ms() { + return dragTimer != nullptr ? dragTimer->last0to60Ms() : 0; +} +float dragBestTrapMph() { + return dragTimer != nullptr ? dragTimer->bestTrapMph() : 0.0f; +} +unsigned long dragBest0to60Ms() { + return dragTimer != nullptr ? dragTimer->best0to60Ms() : 0; +} +unsigned long dragCurrent0to60Ms() { + return dragTimer != nullptr ? dragTimer->current0to60Ms() : 0; +} + /** * @brief True while timing is "live": a sprint run in progress, or (in * circuit mode) the race started. Drives the sprint pages' *waiting* @@ -1297,6 +1371,7 @@ bool sprintModeIsActive() { * but Current Lap / Pace show *waiting* instead of a dead 0:00. */ bool activeTimerRunActive() { + if (dragTimer != nullptr) return dragTimer->runActive(); if (sprintTimer != nullptr) return sprintTimer->isRunActive(); return activeTimerRaceStarted(); } @@ -1319,6 +1394,7 @@ WaypointLapTimer* getActiveTimerWLT() { // Unified getter helpers for display pages bool activeTimerRaceStarted() { + if (dragTimer != nullptr) return dragTimer->runActive() || dragTimer->runs() > 0; if (sprintTimer != nullptr) return sprintTimer->getRaceStarted(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getRaceStarted(); @@ -1328,6 +1404,7 @@ bool activeTimerRaceStarted() { } bool activeTimerCrossing() { + if (dragTimer != nullptr) return false; // no lines to cross if (sprintTimer != nullptr) return sprintTimer->getCrossing(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCrossing(); @@ -1337,6 +1414,7 @@ bool activeTimerCrossing() { } int activeTimerLaps() { + if (dragTimer != nullptr) return dragTimer->runs(); if (sprintTimer != nullptr) return sprintTimer->getRuns(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getLaps(); @@ -1346,6 +1424,7 @@ int activeTimerLaps() { } unsigned long activeTimerCurrentLapTime() { + if (dragTimer != nullptr) return dragTimer->currentEtMs(getGpsTimeInMilliseconds()); if (sprintTimer != nullptr) return sprintTimer->getCurrentRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCurrentLapTime(); @@ -1355,6 +1434,7 @@ unsigned long activeTimerCurrentLapTime() { } unsigned long activeTimerLastLapTime() { + if (dragTimer != nullptr) return dragTimer->lastEtMs(); if (sprintTimer != nullptr) return sprintTimer->getLastRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getLastLapTime(); @@ -1364,6 +1444,7 @@ unsigned long activeTimerLastLapTime() { } unsigned long activeTimerBestLapTime() { + if (dragTimer != nullptr) return dragTimer->bestEtMs(); if (sprintTimer != nullptr) return sprintTimer->getBestRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getBestLapTime(); @@ -1373,6 +1454,7 @@ unsigned long activeTimerBestLapTime() { } int activeTimerBestLapNumber() { + if (dragTimer != nullptr) return dragTimer->bestRunNumber(); if (sprintTimer != nullptr) return sprintTimer->getBestRunNumber(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getBestLapNumber(); @@ -1382,6 +1464,7 @@ int activeTimerBestLapNumber() { } float activeTimerPaceDifference() { + if (dragTimer != nullptr) return 0.0f; // no reference lap to pace against if (sprintTimer != nullptr) return sprintTimer->getPaceDifference(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getPaceDifference(); @@ -1391,6 +1474,7 @@ float activeTimerPaceDifference() { } float activeTimerTotalDistance() { + if (dragTimer != nullptr) return dragTimer->distanceFt(); // NB: feet, not meters if (sprintTimer != nullptr) return sprintTimer->getTotalDistanceTraveled(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getTotalDistanceTraveled(); @@ -1400,6 +1484,7 @@ float activeTimerTotalDistance() { } unsigned long activeTimerOptimalLapTime() { + if (dragTimer != nullptr) return 0; // no sectors, no optimal if (sprintTimer != nullptr) return sprintTimer->getOptimalLapTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getOptimalLapTime(); @@ -1407,6 +1492,7 @@ unsigned long activeTimerOptimalLapTime() { } bool activeTimerSectorsConfigured() { + if (dragTimer != nullptr) return false; if (sprintTimer != nullptr) return sprintTimer->areSectorLinesConfigured(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->areSectorLinesConfigured(); @@ -1417,6 +1503,7 @@ bool activeTimerSectorsConfigured() { // Sprint-first like every sibling; WaypointLapTimer (Lap Anything) has // no sectors, so those sessions return 0 and the monitor stays reset. int activeTimerCurrentSector() { + if (dragTimer != nullptr) return 0; if (sprintTimer != nullptr) return sprintTimer->getCurrentSector(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCurrentSector(); @@ -1424,6 +1511,7 @@ int activeTimerCurrentSector() { } unsigned long activeTimerLapSectorTime(int sector) { + if (dragTimer != nullptr) return 0; if (sprintTimer != nullptr) { if (sector == 1) return sprintTimer->getCurrentLapSector1Time(); if (sector == 2) return sprintTimer->getCurrentLapSector2Time(); @@ -1440,6 +1528,7 @@ unsigned long activeTimerLapSectorTime(int sector) { } unsigned long activeTimerBestSectorTime(int sector) { + if (dragTimer != nullptr) return 0; if (sprintTimer != nullptr) { if (sector == 1) return sprintTimer->getBestSector1Time(); if (sector == 2) return sprintTimer->getBestSector2Time(); @@ -1529,6 +1618,39 @@ bool createSprintSession() { return true; } +/** + * @brief Stand up a drag session (plan 0015): no track, no detection — + * just a distance target picked on PAGE_DRAG_DISTANCE. trackDetected is + * latched the way sprint latches it, so trackDetectionLoop() can't stand + * up a CourseManager when the strip happens to be near a saved track; + * dragTimer must exist BEFORE startRaceSession() so its Lap Anything + * fallback stays suppressed. + */ +void startDragSession(int distanceIdx) { + // Defensive mirror of createSprintSession() — nothing can exist when + // this is reached from the menu, but the deletes keep that a fact. + if (courseManager != nullptr) { + delete courseManager; + courseManager = nullptr; + } + if (sprintTimer != nullptr) { + delete sprintTimer; + sprintTimer = nullptr; + } + if (dragTimer == nullptr) { + dragTimer = new drag_timer::DragTimer(); + } + dragTimer->setTarget(distanceIdx); + dragDistanceIdx = dragTimer->targetIdx(); // clamped by the unit + dragLastRunCount = 0; + dragWasStaged = false; + trackDetected = true; + startRaceSession(RACE_ENTRY_MANUAL); + + debug(F("Drag session ready — ")); + debugln(drag_timer::label(dragDistanceIdx)); +} + /** * @brief Scan track manifest for closest match to current GPS position * Creates CourseManager (circuit) or SprintTimer (sprint) when a match is @@ -1738,6 +1860,14 @@ void endRaceSession() { } sprintCourseName[0] = '\0'; sprintLastRunCount = 0; + // Clean up drag session (plan 0015) + if (dragTimer != nullptr) { + delete dragTimer; + dragTimer = nullptr; + } + dragLastRunCount = 0; + dragDistanceIdx = -1; + dragWasStaged = false; trackDetected = false; detectedTrackIndex = -1; raceActive = false; @@ -1761,6 +1891,7 @@ void endRaceSession() { void createLapAnythingCourseManager() { if (courseManager != nullptr) return; // Already exists if (sprintTimer != nullptr) return; // Sprint session owns timing + if (dragTimer != nullptr) return; // Drag session owns timing activeTrackConfig.longName = "Unknown"; activeTrackConfig.shortName = ""; activeTrackConfig.courseCount = 0; @@ -1806,7 +1937,8 @@ void checkAutoIdle() { (raceEntryCause == RACE_ENTRY_MANUAL || raceEntryCause == RACE_ENTRY_SPEED); pin.cameraRecording = cameraActivelyRecording(); pin.gpsLockHoldActive = gpsLockHoldActive; - pin.sprintEngineRunning = (sprintTimer != nullptr && tachLastReported > 0); + pin.sprintEngineRunning = + ((sprintTimer != nullptr || dragTimer != nullptr) && tachLastReported > 0); pin.speedMph = gps_speed_mph; const idle_policy::Decision d = idle_policy::evaluate(pin); @@ -2276,7 +2408,10 @@ void writeDovexHeader() { const char* courseName = "Lap Anything"; const char* shortName = ""; - if (sprintTimer != nullptr) { + if (dragTimer != nullptr) { + courseName = drag_timer::dovexName(dragDistanceIdx); + shortName = "DRAG"; + } else if (sprintTimer != nullptr) { courseName = sprintCourseName[0] ? sprintCourseName : "Sprint"; shortName = activeTrackMetadata.shortName; // from the session's parse } else if (courseManager != nullptr) { @@ -2293,8 +2428,10 @@ void writeDovexHeader() { activeTimerBestLapTime(), activeTimerOptimalLapTime(), settingDeviceName, - // Webapp loading helper: with SPRINT the laps line is a runs line. - sprintTimer != nullptr ? "SPRINT" : "CIRCUIT", + // Webapp loading helper: with SPRINT or DRAG the laps line is a + // runs line (drag runs are ETs). + dragTimer != nullptr ? "DRAG" + : (sprintTimer != nullptr ? "SPRINT" : "CIRCUIT"), }; static char headerBuf[dovex_header::kHeaderSize]; diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 8906215..3c0bee8 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -29,6 +29,11 @@ void displayPage_sd_format_progress(const __FlashStringHelper* line1, const __FlashStringHelper* line2); void displayPage_sleep_charging(); +// Drag mode (plan 0015) — the distance picker + the shared trap/0-60 +// results subtext used by the lap-time and best-lap pages. +void displayPage_drag_distance(); +void displayPrintDragStats(float trapMph, unsigned long split60Ms); + // Replay flow (DOVEX instant replay). void displayPage_replay_file_select(); void displayPage_replay_results(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 7249a7c..029faa7 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -136,7 +136,7 @@ void displayPage_main_menu() { // a size-1 scroll-hint line. Four full size-2 rows fill the panel's // nominal 64 px exactly, but the last row is cut off on real hardware // — so the window follows the selection instead. - static const char* const kMenuItems[] = {"Race", "Review", "Transfer", "Create", "Camera"}; + static const char* const kMenuItems[] = {"Race", "Drag", "Review", "Transfer", "Create", "Camera"}; const int itemCount = (int)(sizeof(kMenuItems) / sizeof(kMenuItems[0])); const int visibleRows = 3; @@ -165,6 +165,56 @@ void displayPage_main_menu() { safeDisplayUpdate(); } +void displayPage_drag_distance() { + resetDisplay(); + + // Same scrolling 3-row window as the main menu, plus a title line + // (title 8 px + 3 size-2 rows 48 px + hint line 8 px = the panel). + display.setTextSize(1); + display.println(F(" Drag Distance")); + + const int itemCount = drag_timer::kDistanceCount + 1; // + Back + const int visibleRows = 3; + int first = menuSelectionIndex - 1; + if (first < 0) first = 0; + if (first > itemCount - visibleRows) first = itemCount - visibleRows; + + display.setTextSize(2); + for (int i = first; i < first + visibleRows; i++) { + display.print(menuSelectionIndex == i ? "->" : " "); + display.println(i < drag_timer::kDistanceCount ? drag_timer::label(i) + : "Back"); + } + + display.setTextSize(1); + if (first > 0) { + display.print(F("^")); + } else { + display.print(F(" ")); + } + if (first + visibleRows < itemCount) { + display.print(F(" v more")); + } + + safeDisplayUpdate(); +} + +// Trap-speed + 0-60 subtext shared by the drag results renderings below. +// The ET itself always goes through lap_format like every other time; the +// split is seconds to two decimals, "0-60" omitted if never reached. +void displayPrintDragStats(float trapMph, unsigned long split60Ms) { + display.print(F("trap ")); + display.print(trapMph, 1); + if (split60Ms > 0) { + display.print(F(" 0-60 ")); + display.print(split60Ms / 1000); + display.print(F(".")); + unsigned long hundredths = (split60Ms % 1000) / 10; + if (hundredths < 10) display.print(F("0")); + display.print(hundredths); + } +} + void displayPage_bluetooth() { resetDisplay(); @@ -699,7 +749,12 @@ void displayPage_gps_speed() { void displayPage_gps_lap_time() { resetDisplay(); - display.println(F(" Current Lap Time")); + if (dragModeIsActive()) { + display.print(F(" Drag ")); + display.println(dragDistanceLabel()); + } else { + display.println(F(" Current Lap Time")); + } display.print(F("\n\n")); display.setTextSize(3); @@ -707,7 +762,23 @@ void displayPage_gps_lap_time() { bool raceStarted = activeTimerRaceStarted(); unsigned long currentLapTimeMs = activeTimerCurrentLapTime(); - if (sprintModeIsActive() && !activeTimerRunActive()) { + if (dragModeIsActive() && !activeTimerRunActive()) { + if (activeTimerLaps() > 0) { + // Between runs with a result: show the last ET big, trap + 0-60 + // under it — the "what did I just run" glance at the top end. + char lapStr[lap_format::kLapTimeStrLen]; + lap_format::formatLapTime(activeTimerLastLapTime(), lap_format::kSpace, + lapStr, sizeof(lapStr)); + display.print(lapStr); + display.setTextSize(1); + display.print(F("\n\n ")); + displayPrintDragStats(dragLastTrapMph(), dragLast0to60Ms()); + } else { + // No run yet: staged = clock armed, launch when ready. + display.setTextSize(2); + display.print(dragIsStaged() ? F(" *staged*") : F(" *waiting*")); + } + } else if (sprintModeIsActive() && !activeTimerRunActive()) { // Sprint mode, between runs: the session stays live (all pages work), // but there is no lap ticking — say so instead of a dead 0:00. display.setTextSize(2); @@ -726,7 +797,13 @@ void displayPage_gps_lap_time() { void displayPage_gps_pace() { resetDisplay(); - display.println(F(" Current Lap Pace")); + // Drag mode has no reference lap to pace against — this page becomes + // the live 0-60 readout during a run instead. + if (dragModeIsActive()) { + display.println(F(" 0-60 Split")); + } else { + display.println(F(" Current Lap Pace")); + } int paceLaps = activeTimerLaps(); float paceDiff = activeTimerPaceDifference(); @@ -763,11 +840,29 @@ void displayPage_gps_pace() { display.setCursor(1, lineHeight); display.setTextSize(3); display.print(F("STOPPED")); - } else if (sprintModeIsActive() && !activeTimerRunActive()) { - // Sprint mode, between runs — no live pace to compare (see lap page). + } else if ((sprintModeIsActive() || dragModeIsActive()) && + !activeTimerRunActive()) { + // Sprint/drag, between runs — no live pace to compare (see lap page). display.setCursor(0, lineHeight); display.setTextSize(2); - display.print(F(" *waiting*")); + display.print(dragModeIsActive() && dragIsStaged() ? F(" *staged*") + : F(" *waiting*")); + } else if (dragModeIsActive()) { + // Run in progress: the split once 60 is crossed, dashes until then. + display.setCursor(0, lineHeight); + display.setTextSize(3); + unsigned long split60 = dragCurrent0to60Ms(); + if (split60 > 0) { + display.print(F(" ")); + display.print(split60 / 1000); + display.print(F(".")); + unsigned long hundredths = (split60 % 1000) / 10; + if (hundredths < 10) display.print(F("0")); + display.print(hundredths); + display.print(F("s")); + } else { + display.print(F(" -.--")); + } } else if (paceRaceStarted && paceLaps >= 1) { display.setCursor(0, lineHeight); display.setTextSize(4); @@ -825,6 +920,13 @@ void displayPage_gps_best_lap() { display.print(F("\n\n")); display.print(F("Lap: ")); display.print(bestLapNum); + + if (dragModeIsActive()) { + // The best run's own trap + 0-60 (snapshotted with the best ET). + display.setTextSize(1); + display.print(F("\n\n ")); + displayPrintDragStats(dragBestTrapMph(), dragBest0to60Ms()); + } } else { display.print(F("\n")); display.setTextSize(3); diff --git a/BirdsEye/display_ui.ino b/BirdsEye/display_ui.ino index 08c635a..cf42943 100644 --- a/BirdsEye/display_ui.ino +++ b/BirdsEye/display_ui.ino @@ -331,6 +331,10 @@ void handleMenuPageSelection() { startRaceSession(RACE_ENTRY_MANUAL); switchToDisplayPage(GPS_SPEED); } else if (menuSelectionIndex == 1) { + // Drag mode (plan 0015) — pick a distance, then straight to racing. + debugln(F("Main Menu: Drag selected")); + switchToDisplayPage(PAGE_DRAG_DISTANCE); + } else if (menuSelectionIndex == 2) { // Replay selected debugln(F("Main Menu: Replay selected")); resetReplayState(); @@ -341,11 +345,11 @@ void handleMenuPageSelection() { internalNotification[sizeof(internalNotification) - 1] = '\0'; switchToDisplayPage(PAGE_INTERNAL_WARNING); } - } else if (menuSelectionIndex == 2) { + } else if (menuSelectionIndex == 3) { // Transfer selected — open the Bluetooth-vs-USB submenu debugln(F("Main Menu: Transfer selected")); switchToDisplayPage(PAGE_TRANSFER_MENU); - } else if (menuSelectionIndex == 3) { + } else if (menuSelectionIndex == 4) { // Create Course — walk the cones and capture the timing lines. debugln(F("Main Menu: Create Course selected")); if (!courseCreatorEnter()) { @@ -365,6 +369,16 @@ void handleMenuPageSelection() { } switchToDisplayPage(PAGE_PAIR_CAMERA); } + } else if (currentPage == PAGE_DRAG_DISTANCE) { + // Five distances + Back (last row). Selecting a distance starts the + // session immediately — staging/launch is the drag timer's job. + if (menuSelectionIndex >= drag_timer::kDistanceCount) { + debugln(F("Drag: Back selected")); + switchToDisplayPage(PAGE_MAIN_MENU); + } else { + startDragSession(menuSelectionIndex); + switchToDisplayPage(GPS_SPEED); + } } else if (currentPage == PAGE_PAIR_CAMERA) { // Only a menu while paired (Back / Test / Unpair) — the unpaired // pairing screen handles its buttons in the custom branch in @@ -628,6 +642,8 @@ void displayLoop() { displayPage_gps_status(); } else if (currentPage == PAGE_MAIN_MENU) { displayPage_main_menu(); + } else if (currentPage == PAGE_DRAG_DISTANCE) { + displayPage_drag_distance(); } else if (currentPage == PAGE_BLUETOOTH) { displayPage_bluetooth(); } else if (currentPage == PAGE_TRANSFER_MENU) { @@ -730,6 +746,7 @@ void displayLoop() { if ( currentPage == PAGE_MAIN_MENU || + currentPage == PAGE_DRAG_DISTANCE || currentPage == PAGE_BLUETOOTH || currentPage == PAGE_TRANSFER_MENU || currentPage == PAGE_USB_STORAGE || @@ -746,7 +763,9 @@ void displayLoop() { ) { insideMenu = true; if (currentPage == PAGE_MAIN_MENU) { - menuLimit = 5; // Race, Review, Transfer, Create Course, Camera + menuLimit = 6; // Race, Drag, Review, Transfer, Create Course, Camera + } else if (currentPage == PAGE_DRAG_DISTANCE) { + menuLimit = drag_timer::kDistanceCount + 1; // distances + Back } else if (courseCreatorActive()) { // Row count is the model's to decide — it changes with course type // (sprint grows a finish row) and with which screen is up. @@ -795,6 +814,7 @@ void displayLoop() { // items the direction was unobservable (either button wrapped to the // other row), so it was never noticed. Its third row makes it visible. bool reverseDirection = (currentPage == PAGE_MAIN_MENU || + currentPage == PAGE_DRAG_DISTANCE || currentPage == PAGE_PAIR_CAMERA || currentPage == PAGE_CAMERA_TEST || currentPage == PAGE_COURSE_PRUNE || diff --git a/BirdsEye/dovex_header.h b/BirdsEye/dovex_header.h index 47e322b..c46a180 100644 --- a/BirdsEye/dovex_header.h +++ b/BirdsEye/dovex_header.h @@ -21,10 +21,11 @@ // device_name and race_mode are trailing columns so they stay backwards // compatible: older readers stop at optimal_ms and ignore them, and logs // written before the columns existed parse back with empty values. -// race_mode is "CIRCUIT" or "SPRINT" (compare case-insensitively; empty -// or absent = CIRCUIT, so every legacy log reads as a circuit session). -// It is deliberately just a loading helper for the webapp — with SPRINT, -// the laps line is a runs line — nothing on-device depends on it. +// race_mode is "CIRCUIT", "SPRINT" or "DRAG" (compare case-insensitively; +// empty or absent = CIRCUIT, so every legacy log reads as a circuit +// session). It is deliberately just a loading helper for the webapp — +// with SPRINT or DRAG, the laps line is a runs line (drag runs are ETs) +// — nothing on-device depends on it. /////////////////////////////////////////// #include @@ -42,7 +43,7 @@ constexpr size_t kCourseLen = 32; constexpr size_t kShortNameLen = 16; constexpr size_t kLapStrLen = 16; // "N/A" or numeric lap time as text constexpr size_t kDeviceLen = 32; -constexpr size_t kRaceModeLen = 8; // "CIRCUIT"/"SPRINT" + NUL +constexpr size_t kRaceModeLen = 8; // "CIRCUIT"/"SPRINT"/"DRAG" + NUL // Input to format(). All const char* fields must be non-null and // well-formed; format() does NOT escape commas inside them. diff --git a/BirdsEye/drag_timer.cpp b/BirdsEye/drag_timer.cpp new file mode 100644 index 0000000..3f34242 --- /dev/null +++ b/BirdsEye/drag_timer.cpp @@ -0,0 +1,238 @@ +#include "drag_timer.h" + +#include + +namespace drag_timer { + +namespace { +constexpr double kDegToRad = 0.017453292519943295; // M_PI / 180.0 +// 3958.8 mi (haversine.cpp's radius) in feet — targets are feet-native. +constexpr double kEarthRadiusFeet = 20902464.0; + +constexpr float kTargetsFt[kDistanceCount] = { + 660.0f, // 1/8 mile + 1000.0f, // 1000 ft + 1320.0f, // 1/4 mile + 2640.0f, // 1/2 mile + 5280.0f, // 1 mile +}; +const char* const kLabels[kDistanceCount] = { + "1/8 Mile", "1000 ft", "1/4 Mile", "1/2 Mile", "1 Mile", +}; +// DOVEX course names (must fit dovex_header::kCourseLen). +const char* const kDovexNames[kDistanceCount] = { + "DRAG 1/8 MILE", "DRAG 1000 FT", "DRAG 1/4 MILE", + "DRAG 1/2 MILE", "DRAG 1 MILE", +}; + +double lerp(double a, double b, double f) { return a + (b - a) * f; } +} // namespace + +float targetFeet(int idx) { + if (idx < 0 || idx >= kDistanceCount) return kTargetsFt[0]; + return kTargetsFt[idx]; +} + +const char* label(int idx) { + if (idx < 0 || idx >= kDistanceCount) return kLabels[0]; + return kLabels[idx]; +} + +const char* dovexName(int idx) { + if (idx < 0 || idx >= kDistanceCount) return kDovexNames[0]; + return kDovexNames[idx]; +} + +double distanceFeet(double lat1, double lng1, double lat2, double lng2) { + const double dLat = (lat2 - lat1) * kDegToRad; + const double dLng = (lng2 - lng1) * kDegToRad; + const double a = sin(dLat / 2) * sin(dLat / 2) + + cos(lat1 * kDegToRad) * cos(lat2 * kDegToRad) * + sin(dLng / 2) * sin(dLng / 2); + const double c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return kEarthRadiusFeet * c; +} + +void DragTimer::setTarget(int idx) { + if (idx < 0 || idx >= kDistanceCount) idx = 0; + targetIdx_ = idx; + targetFt_ = kTargetsFt[idx]; + havePrev_ = false; + runs_ = 0; + lastEtMs_ = 0; + lastTrapMph_ = 0.0f; + last0to60Ms_ = 0; + bestEtMs_ = 0; + bestRunNumber_ = 0; + bestTrapMph_ = 0.0f; + best0to60Ms_ = 0; + resetToArmed(); +} + +void DragTimer::resetToArmed() { + phase_ = Phase::kArmed; + anchorValid_ = false; + anchorCount_ = 0; + stillTracking_ = false; + runDistFt_ = 0.0f; + sixtyCrossed_ = false; + run0to60Ms_ = 0; + slowTracking_ = false; +} + +void DragTimer::foldAnchor(double lat, double lng) { + if (!anchorValid_) { + anchorValid_ = true; + anchorCount_ = 1; + anchorLat_ = lat; + anchorLng_ = lng; + return; + } + if (anchorCount_ < kAnchorMeanWindow) anchorCount_++; + anchorLat_ += (lat - anchorLat_) / anchorCount_; + anchorLng_ += (lng - anchorLng_) / anchorCount_; +} + +bool DragTimer::onFix(double lat, double lng, float speedMph, + uint64_t gpsTimeMs) { + // Non-monotonic GPS time (a time step mid-lock) — drop the fix whole + // rather than let a negative/zero dt into any of the math below. + if (havePrev_ && gpsTimeMs <= prevTimeMs_) return false; + + bool completed = false; + + switch (phase_) { + case Phase::kArmed: { + if (speedMph <= kStagedMaxMph) { + if (!stillTracking_) { + stillTracking_ = true; + stillSinceMs_ = gpsTimeMs; + anchorValid_ = false; // fresh stop, fresh anchor + anchorCount_ = 0; + } + foldAnchor(lat, lng); + if (gpsTimeMs - stillSinceMs_ >= kStageHoldMs) { + phase_ = Phase::kStaged; + } + } else { + stillTracking_ = false; + } + break; + } + + case Phase::kStaged: { + // Re-latching anchor: the mean follows the parked car's drifting + // fix, so only real motion can open the rollout gap (plan 0015). + if (speedMph <= kStagedMaxMph) foldAnchor(lat, lng); + + const double d = distanceFeet(anchorLat_, anchorLng_, lat, lng); + if (d >= kRolloutFt && speedMph >= kLaunchMinMph) { + // Launch. ET starts at the interpolated rollout crossing on the + // displacement curve between the previous fix and this one. + double f = 1.0; + if (havePrev_) { + const double dPrev = + distanceFeet(anchorLat_, anchorLng_, prevLat_, prevLng_); + if (d > dPrev) f = (kRolloutFt - dPrev) / (d - dPrev); + if (f < 0.0) f = 0.0; + if (f > 1.0) f = 1.0; + runStartMs_ = lerp((double)prevTimeMs_, (double)gpsTimeMs, f); + } else { + runStartMs_ = (double)gpsTimeMs; + } + phase_ = Phase::kLaunched; + runDistFt_ = (float)(d - kRolloutFt); // overshoot past rollout + sixtyCrossed_ = false; + run0to60Ms_ = 0; + slowTracking_ = false; + } else if (d >= kRestageFt) { + // Moved to a new spot without ever reaching launch speed — a + // slow reposition (staging-lane creep). Re-stage from scratch. + resetToArmed(); + } + break; + } + + case Phase::kLaunched: { + if (!havePrev_) { // cannot happen (launch requires a prev), guard + resetToArmed(); + break; + } + const uint64_t dt = gpsTimeMs - prevTimeMs_; + if (dt >= kFixGapAbortMs) { + // Fix gap at speed — the chord across it is not distance we can + // stand behind. Abandon silently; nothing recorded. + resetToArmed(); + break; + } + + const double stepFt = distanceFeet(prevLat_, prevLng_, lat, lng); + const float distBefore = runDistFt_; + const float distAfter = runDistFt_ + (float)stepFt; + + // 0-60 split: first fix pair straddling the target speed. + if (!sixtyCrossed_ && prevSpeedMph_ < kSplitTargetMph && + speedMph >= kSplitTargetMph) { + double f = 1.0; + if (speedMph > prevSpeedMph_) { + f = (kSplitTargetMph - prevSpeedMph_) / (speedMph - prevSpeedMph_); + } + const double t60 = lerp((double)prevTimeMs_, (double)gpsTimeMs, f); + sixtyCrossed_ = true; + run0to60Ms_ = (unsigned long)(t60 - runStartMs_ + 0.5); + } + + if (distAfter >= targetFt_ && stepFt > 0.0) { + // Finish — ET and trap interpolated at the target distance. + const double f = (targetFt_ - distBefore) / stepFt; + const double tFin = lerp((double)prevTimeMs_, (double)gpsTimeMs, f); + const float trap = (float)lerp(prevSpeedMph_, speedMph, f); + + runs_++; + lastEtMs_ = (unsigned long)(tFin - runStartMs_ + 0.5); + lastTrapMph_ = trap; + last0to60Ms_ = run0to60Ms_; + if (bestEtMs_ == 0 || lastEtMs_ < bestEtMs_) { + bestEtMs_ = lastEtMs_; + bestRunNumber_ = runs_; + bestTrapMph_ = trap; + best0to60Ms_ = run0to60Ms_; + } + completed = true; + resetToArmed(); + break; + } + runDistFt_ = distAfter; + + // Mid-run standstill: driver lifted / wrong distance / phantom + // creep launch. Abandon after the hold, silently. + if (speedMph <= kAbortBelowMph) { + if (!slowTracking_) { + slowTracking_ = true; + slowSinceMs_ = gpsTimeMs; + } else if (gpsTimeMs - slowSinceMs_ >= kAbortHoldMs) { + resetToArmed(); + } + } else { + slowTracking_ = false; + } + break; + } + } + + havePrev_ = true; + prevLat_ = lat; + prevLng_ = lng; + prevSpeedMph_ = speedMph; + prevTimeMs_ = gpsTimeMs; + return completed; +} + +unsigned long DragTimer::currentEtMs(uint64_t nowGpsMs) const { + if (phase_ != Phase::kLaunched) return 0; + const double et = (double)nowGpsMs - runStartMs_; + if (et <= 0.0) return 0; + return (unsigned long)(et + 0.5); +} + +} // namespace drag_timer diff --git a/BirdsEye/drag_timer.h b/BirdsEye/drag_timer.h new file mode 100644 index 0000000..73f543f --- /dev/null +++ b/BirdsEye/drag_timer.h @@ -0,0 +1,180 @@ +#pragma once + +#include + +/////////////////////////////////////////// +// DRAG MODE RUN TIMER (plan 0015) +// The whole drag-run state machine: stage at a standstill, launch +// rollout-style (clock starts when the car has moved 11.25 in from its +// staged position), accumulate GPS distance to the selected target, and +// record ET + trap speed + a 0-60 mph split per run. Re-arms itself +// after every run — come back to a standstill and it stages again. +// +// The sketch keeps only the glue (sprint-timer precedent): every +// transition here edges on a GPS fix passed to onFix(), the glue gates +// on gpsData.fix, and a dropout reaches this unit purely as a timestamp +// gap. Units at the boundary: mph, feet, GPS epoch milliseconds — no +// millis(), no Arduino headers, host-tested. +// +// The load-bearing detail is the STAGED anchor: it is a running mean of +// standstill fixes that keeps re-latching while the car is stopped, so +// GPS drift over a multi-minute staging-lane wait cannot walk the fix +// past the rollout radius and fake a launch. The only false-launch +// source left is real motion (queue creep), and that self-cancels +// through the mid-run standstill abort without recording anything. +/////////////////////////////////////////// + +namespace drag_timer { + +/////////////////////////////////////////// +// Distance table — the single source for the picker labels, the run +// targets, and the DOVEX course name. Index order is picker order. +/////////////////////////////////////////// +constexpr int kDistanceCount = 5; +float targetFeet(int idx); // 660 / 1000 / 1320 / 2640 / 5280 +const char* label(int idx); // "1/8 Mile", "1000 ft", ... +const char* dovexName(int idx); // "DRAG 1/8 MILE", "DRAG 1000 FT", ... + +/////////////////////////////////////////// +// Tunables +/////////////////////////////////////////// + +// Drag-strip standard rollout: 11.25 in between pre-stage and the ET +// clock starting. GPS h_acc is larger than this, but the start is +// interpolated on the displacement curve between two 25 Hz fixes during +// a hard launch, so the error is bounded by fix-to-fix noise plus the +// anchor mean's residual, not absolute accuracy (plan 0015). +constexpr float kRolloutFt = 0.9375f; + +// At/below this the car counts as stopped (staging + anchor folding). +constexpr float kStagedMaxMph = 1.0f; + +// Standstill must hold this long before ARMED promotes to STAGED. +constexpr uint32_t kStageHoldMs = 1000; + +// The rollout displacement only launches with real speed behind it — +// position jitter and dead-slow creep never start a clock. +constexpr float kLaunchMinMph = 2.0f; + +// The bonus split. Interpolated on speed between the straddling fixes; +// 0 when the run never reaches it (short cars on the 1/8). +constexpr float kSplitTargetMph = 60.0f; + +// Mid-run standstill this long -> the run is abandoned silently +// (driver lifted, wrong distance selected, or a phantom creep launch). +constexpr float kAbortBelowMph = 2.0f; +constexpr uint32_t kAbortHoldMs = 3000; + +// A fix gap this long mid-run aborts the run: the chord across the gap +// is untrustworthy distance at speed. Gaps below it are tolerated (the +// chord ~ the path; drag runs are straight). +constexpr uint32_t kFixGapAbortMs = 2000; + +// STAGED car that drifts this far from its anchor WITHOUT launching +// (speed stayed under kLaunchMinMph) has moved to a new spot — drop +// back to ARMED and re-stage there. Deliberately much larger than the +// rollout so standstill jitter can never un-stage a car that is about +// to launch; only a real slow reposition trips it. +constexpr float kRestageFt = 10.0f; + +// Anchor running-mean window (fix count). Averaging shrinks the anchor +// noise well below a single fix's jitter; the cap keeps it responsive +// to the re-latch (the mean follows the parked car's drifting fix). +constexpr int kAnchorMeanWindow = 32; + +enum class Phase : uint8_t { + kArmed, // waiting for a standstill (also post-run / post-abort) + kStaged, // stopped, anchor latched, watching for the rollout + kLaunched, // clock running, accumulating distance +}; + +class DragTimer { + public: + // idx into the distance table above. Resets all run state. + void setTarget(int idx); + + // One valid GPS fix (glue gates on gpsData.fix). speedMph is ground + // speed in mph, gpsTimeMs is GPS epoch milliseconds. Returns true + // exactly when this fix COMPLETED a run (the glue's run-count edge + // fires off runs() anyway; the bool is a convenience). + bool onFix(double lat, double lng, float speedMph, uint64_t gpsTimeMs); + + Phase phase() const { return phase_; } + bool runActive() const { return phase_ == Phase::kLaunched; } + bool staged() const { return phase_ == Phase::kStaged; } + int targetIdx() const { return targetIdx_; } + float targetFt() const { return targetFt_; } + + int runs() const { return runs_; } + + // Live ET while a run is on, 0 otherwise. nowGpsMs lets the display + // tick between fixes. + unsigned long currentEtMs(uint64_t nowGpsMs) const; + + // Cumulative distance covered in the current run, feet. + float distanceFt() const { return runDistFt_; } + + // Live 0-60 of the run IN PROGRESS: 0 until the crossing (and outside + // a run); the split the moment it happens. The pace page shows this. + unsigned long current0to60Ms() const { + return phase_ == Phase::kLaunched ? run0to60Ms_ : 0; + } + + // Last completed run. 0 / 0.0f until one exists; last0to60Ms() is 0 + // when that run never reached 60 mph. + unsigned long lastEtMs() const { return lastEtMs_; } + float lastTrapMph() const { return lastTrapMph_; } + unsigned long last0to60Ms() const { return last0to60Ms_; } + + // Best run = lowest ET. Trap/split are snapshots OF that run. + unsigned long bestEtMs() const { return bestEtMs_; } + int bestRunNumber() const { return bestRunNumber_; } // 1-based + float bestTrapMph() const { return bestTrapMph_; } + unsigned long best0to60Ms() const { return best0to60Ms_; } + + private: + void resetToArmed(); + void foldAnchor(double lat, double lng); + + int targetIdx_ = 0; + float targetFt_ = 0.0f; + + Phase phase_ = Phase::kArmed; + + // Previous fix (valid once havePrev_). + bool havePrev_ = false; + double prevLat_ = 0.0, prevLng_ = 0.0; + float prevSpeedMph_ = 0.0f; + uint64_t prevTimeMs_ = 0; + + // Standstill anchor (running mean) + the stage-hold clock. + bool anchorValid_ = false; + double anchorLat_ = 0.0, anchorLng_ = 0.0; + int anchorCount_ = 0; + bool stillTracking_ = false; + uint64_t stillSinceMs_ = 0; + + // Live run. + double runStartMs_ = 0.0; // interpolated -> fractional ms internally + float runDistFt_ = 0.0f; + bool sixtyCrossed_ = false; + unsigned long run0to60Ms_ = 0; + bool slowTracking_ = false; + uint64_t slowSinceMs_ = 0; + + // Records. + int runs_ = 0; + unsigned long lastEtMs_ = 0; + float lastTrapMph_ = 0.0f; + unsigned long last0to60Ms_ = 0; + unsigned long bestEtMs_ = 0; + int bestRunNumber_ = 0; + float bestTrapMph_ = 0.0f; + unsigned long best0to60Ms_ = 0; +}; + +// Great-circle distance in FEET (spherical Earth, R matching +// haversine.cpp's 3958.8 mi). Exposed for the tests' synthetic tracks. +double distanceFeet(double lat1, double lng1, double lat2, double lng2); + +} // namespace drag_timer diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index d085c60..a891865 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -486,6 +486,13 @@ void GPS_LOOP() { sprintTimer->updateCurrentTime(getGpsTimeInMilliseconds()); sprintTimer->loop(gpsData.latitudeDegrees, gpsData.longitudeDegrees, gpsData.altitude, gpsData.speed); + } else if (gpsData.fix && dragTimer != nullptr) { + // Drag mode (plan 0015): the run state machine lives in the + // host-tested drag_timer unit; run completion is captured on the + // run-count edge in checkForNewLapData() like sprint. + dragTimer->onFix(gpsData.latitudeDegrees, gpsData.longitudeDegrees, + (float)(gpsData.speed * 1.15078), // knots -> mph + getGpsTimeInMilliseconds()); } #ifdef SD_CARD_LOGGING_ENABLED diff --git a/BirdsEye/idle_policy.h b/BirdsEye/idle_policy.h index b5d9aba..acfb80d 100644 --- a/BirdsEye/idle_policy.h +++ b/BirdsEye/idle_policy.h @@ -44,7 +44,7 @@ struct Inputs { bool speedRuleSession = false; // cause is MANUAL or SPEED (after promotion) bool cameraRecording = false; // camera FSM in kRecording bool gpsLockHoldActive = false; // session still waiting for its GPS time lock - bool sprintEngineRunning = false; // sprint mode AND tach reads > 0 + bool sprintEngineRunning = false; // sprint OR drag mode AND tach reads > 0 float speedMph = 0.0f; }; diff --git a/BirdsEye/neopixel.ino b/BirdsEye/neopixel.ino index 69d24cf..751108c 100644 --- a/BirdsEye/neopixel.ino +++ b/BirdsEye/neopixel.ino @@ -392,7 +392,8 @@ void NEOPIXEL_LOOP() { } else { const bool paceValid = activeTimerRaceStarted() && activeTimerLaps() >= 1 && - !(sprintModeIsActive() && !activeTimerRunActive()); + !((sprintModeIsActive() || dragModeIsActive()) && + !activeTimerRunActive()); if (paceValid) { led_modes::renderPace(activeTimerPaceDifference(), stripPx); } else if (raceEntryCause == RACE_ENTRY_TACH) { diff --git a/BirdsEye/sim/CMakeLists.txt b/BirdsEye/sim/CMakeLists.txt index 648fcea..20c9584 100644 --- a/BirdsEye/sim/CMakeLists.txt +++ b/BirdsEye/sim/CMakeLists.txt @@ -141,6 +141,7 @@ set(SIM_CORE_SOURCES ${BIRDSEYE_DIR}/course_prune.cpp ${BIRDSEYE_DIR}/crossing_pattern.cpp ${BIRDSEYE_DIR}/dovex_header.cpp + ${BIRDSEYE_DIR}/drag_timer.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_status_page.cpp ${BIRDSEYE_DIR}/gps_time.cpp diff --git a/BirdsEye/sim/golden/golden_hashes.txt b/BirdsEye/sim/golden/golden_hashes.txt index f65b4aa..e98c6eb 100644 --- a/BirdsEye/sim/golden/golden_hashes.txt +++ b/BirdsEye/sim/golden/golden_hashes.txt @@ -1,5 +1,6 @@ gps_status_no_fix 900 f2276ffc -main_menu_race -1 34b50cd7 +main_menu_race -1 14716beb +drag_distance_picker -17 659e277d main_menu_transfer -1 40da09f8 transfer_menu -4 ae15b2a8 bluetooth_waiting -2 c0f9211b @@ -10,7 +11,7 @@ course_lines_circuit_empty -13 0a3d740a course_line_detail -14 5ff1d3f5 course_point_idle -15 28961e61 course_line_point_a_done -14 646ca520 -main_menu_after_create -1 34b50cd7 -main_menu_parked_on_line -1 34b50cd7 +main_menu_after_create -1 14716beb +main_menu_parked_on_line -1 14716beb pair_camera_unpaired -6 5d1129cc camera_serial_entry -7 56588408 diff --git a/BirdsEye/sim/golden_main.cpp b/BirdsEye/sim/golden_main.cpp index c65a47a..7dfe191 100644 --- a/BirdsEye/sim/golden_main.cpp +++ b/BirdsEye/sim/golden_main.cpp @@ -74,6 +74,7 @@ void capture(const char* name, int expectPage) { // outside the firmware TU). The golden test breaks loudly if they drift. constexpr int kPageGpsStatus = 900; constexpr int kPageMainMenu = -1; +constexpr int kPageDragDistance = -17; constexpr int kPageTransferMenu = -4; constexpr int kPageBluetooth = -2; constexpr int kPagePairCamera = -6; @@ -131,9 +132,20 @@ void runScript() { press(1); capture("main_menu_race", kPageMainMenu); + // Down to Drag (index 1), select -> the distance picker (plan 0015). + press(2); + press(1); + capture("drag_distance_picker", kPageDragDistance); + + // Leave via Back: one visual UP from the top row wraps to the Back + // row (reversed statically-rendered menu), select -> main menu. + press(0); + press(1); + // Main menu renders top-to-bottom; btn3 walks down the list. press(2); press(2); + press(2); capture("main_menu_transfer", kPageMainMenu); // Select -> Transfer submenu (Bluetooth / USB). @@ -144,21 +156,23 @@ void runScript() { press(1); capture("bluetooth_waiting", kPageBluetooth); - // Exit BLE page -> menu; walk down once -> Replay; select -> the VFS - // has no .dovex files, so the firmware shows the warning page. + // Exit BLE page -> menu; walk down to Replay (index 2); select -> the + // VFS has no .dovex files, so the firmware shows the warning page. press(1); press(2); + press(2); press(1); capture("warning_no_dovex", kPageWarning); // Any button dismisses the warning back to the menu; walk to Create - // Course (index 3) and select. With no fix injected yet, the creator + // Course (index 4) and select. With no fix injected yet, the creator // refuses up front rather than letting the user walk a course it could // neither capture nor name. press(1); press(2); press(2); press(2); + press(2); press(1); capture("warning_create_needs_gps", kPageWarning); @@ -170,6 +184,7 @@ void runScript() { press(2); press(2); press(2); + press(2); press(1); capture("course_type_select", kPageCourseType); @@ -235,6 +250,7 @@ void runScript() { press(2); press(2); press(2); + press(2); press(1); capture("pair_camera_unpaired", kPagePairCamera); diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index dc2c4e3..715d6b6 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -61,8 +61,17 @@ unsigned long activeTimerBestSectorTime(int sector); bool raceEngineStopped(); SprintTimer* getActiveTimerSprint(); bool sprintModeIsActive(); +bool dragModeIsActive(); +bool dragIsStaged(); +const char* dragDistanceLabel(); +float dragLastTrapMph(); +unsigned long dragLast0to60Ms(); +float dragBestTrapMph(); +unsigned long dragBest0to60Ms(); +unsigned long dragCurrent0to60Ms(); bool activeTimerRunActive(); bool createSprintSession(); +void startDragSession(int distanceIdx); void trackDetectionLoop(); void startRaceSession(RaceEntryCause cause); void endRaceSession(); @@ -91,6 +100,8 @@ void ACCEL_LOOP(); void displayPage_boot(); void displayPage_gps_status(); void displayPage_main_menu(); +void displayPage_drag_distance(); +void displayPrintDragStats(float trapMph, unsigned long split60Ms); void displayPage_bluetooth(); void displayPage_transfer_menu(); void displayPage_usb_storage(); diff --git a/CHANGELOG.md b/CHANGELOG.md index a723843..3dc3ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2 ## [Unreleased] +### Added +- **Drag mode** (plan 0015): main menu → **Drag** → pick a distance + (1/8 Mile, 1000 ft, 1/4 Mile, 1/2 Mile, 1 Mile) and the session starts + — no track file, no detection. The device stages at a standstill, + starts the clock rollout-style (11.25 in past the staged position), + and ends the run at the target distance, reporting ET, trap speed, + and a 0-60 mph split; it re-arms automatically for the next pass. All + runs land in one DOVEX session with `race_mode=DRAG` (the laps line is + the run ETs — same backwards-compatible trailing-column scheme as + SPRINT). The run state machine is the host-tested `drag_timer` unit. + ## [4.1.0] - 2026-08-24 MINOR — new settings and device behaviour, backwards compatible with the diff --git a/CLAUDE.md b/CLAUDE.md index e6d5f9f..28e8928 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,9 @@ Core capabilities: - 25 Hz GPS lap timing with sector support (DovesLapTimer library) - **"Just Drive" auto-detection** via CourseManager: automatic track proximity matching, course detection, and Lap Anything fallback +- **Drag mode**: distance runs (1/8 mi–1 mi) with no track at all — + rollout-style launch from a standstill, ET + trap speed + 0-60 split, + automatic re-arm between passes (subsystem 9, plan 0015) - RPM monitoring via inductive tachometer pickup - Accelerometer logging (g-force X/Y/Z) via onboard LSM6DS3 IMU - DOVEX data logging with reserved 1 KB header (crash-safe GPS data) @@ -145,6 +148,7 @@ desktop toolchain. This is where logic worth unit-testing lives. | `sensoregg_protocol.{h,cpp}` | SensorEgg `PW-ADV` v1+v2 advertising payload parser (magic filter, int16 deci-°C decode with `0x8000`→NaN sentinel, flags, sequence, v2 aux thermistor + battery) + wrap-safe 1 s staleness rule + passive-scan tuning constants | | `crossing_pattern.{h,cpp}` | The two-frame crossing animation as geometry (eight 16x16 cells, odd row bands, alternating phase) instead of 2 KB of stored bitmap; golden-tested byte-identical to the images it replaced | | `sprint_select.{h,cpp}` | Sprint mode selection: newest-course-by-`date_created` ordering (sortable ISO strings) + the circuit-vs-sprint tiebreak decision table (`race_mode` pref; circuit yields to a sprint course created today) | +| `drag_timer.{h,cpp}` | Drag mode (plan 0015): the whole run state machine — re-latching standstill anchor, rollout-style launch (interpolated crossing), cumulative-distance finish with interpolated ET + trap speed, 0-60 split, silent aborts (mid-run standstill, fix gap), automatic re-arm — plus the distance table (targets/labels/DOVEX names) | | `course_prune.{h,cpp}` | Which sprint courses to drop when a track file is full (subsystem 15): `N{YYMMDD}_{HHMM}` matcher, and the drop order — **renamed-in-the-app before device-named** (a rename proves the app has a copy), then oldest by `date_created` | | `course_creator.{h,cpp}` | On-device course creator model (subsystem 15): screen/row table, required-vs-optional lines, the two webapp-compat save rules, the point-averaging hold (3 s, ≥8 fixes, ≤10 m h_acc), and `N{YYMMDD}_{HHMM}` name generation | | `track_json.{h,cpp}` | The firmware's only track-JSON **writer** — course/track object emitters + a fixed-point coordinate formatter (integer math: no working `%f` on this core, and `dtostrf` doesn't exist on the host) | @@ -173,7 +177,7 @@ handoff spec. | `frame_hash.{h,cpp}` | FNV-1a 32 over the 1024-byte framebuffer (golden fixtures, viewer dirty-check, future HIL tap) | | `png_dump.{h,cpp}` | Dependency-free PNG writer (stored-deflate + repo crc32) for eyeballing frames | | `native_main.cpp` | Phase-1 driver: boot → skip GPS status page → 60 s soak, state prints | -| `golden_main.cpp` | Phase-2 driver: scripted real-menu walk capturing 8 golden page hashes (`golden/golden_hashes.txt`; regenerate with `--print`, eyeball with `--dump`) | +| `golden_main.cpp` | Phase-2 driver: scripted real-menu walk capturing golden page hashes (`golden/golden_hashes.txt`; regenerate with `--print`, eyeball with `--dump`) | | `oracle_main.cpp` | Phase-3 driver: lap-timing oracle. Default = synthetic constant-speed OKC circle (period exact by construction) through the whole real pipeline (boot page → race entry → proximity detect → CourseDetector "Normal" → laps ±40 ms); `--dovex ` replays a hardware log against its own header laps; diagnostic modes: `--dovex-noheader ` replays a header-less (crashed-session) log and prints live detection/lap state instead of asserting, `--two-session [break-min]` reproduces a full track day (synthetic session 1 → auto-idle end → parked break with GPS drift → real-log session 2) to test CourseManager state carryover | | `fixtures/okc_tillotson_1.dovex` | Hardware-recorded OKC session (13 laps) — the `--dovex` oracle's CI fixture; the sim reproduces its header lap list to the exact millisecond (also the `--two-session` carryover test's session 2) | | `API.md` | Canonical WASM API contract (v1): artifact set, method surface, injectPvt schema, deltas from the handoff-spec draft (async `reset()` via module re-instantiation) | @@ -555,6 +559,9 @@ loop() ~250 Hz is a range test. `PAGE_COURSE_PRUNE` (-16) sits deliberately OUTSIDE that range: it is a plain two-row confirm, not one of the model's screens, so it must not be handed to `course_creator::rowCount()`. + - Drag mode: `PAGE_DRAG_DISTANCE` (-17), the distance picker between the + main menu's Drag row and the race rotation (five distances + Back, same + scrolling 3-row window as the main menu). - Errors: `PAGE_INTERNAL_WARNING` (100), `PAGE_INTERNAL_FAULT` (105), `PAGE_SD_FORMAT` (106, card responds but FAT won't mount — driven by `sdFormatPageLoop()`, buttons live unlike FAULT). @@ -835,6 +842,33 @@ loop() ~250 Hz (`sprintModeIsActive() && !activeTimerRunActive()`); everything else stays live. The DOVEX header gets `race_mode=SPRINT` and the sprint course name. +- **Drag mode (plan 0015)**: main menu → **Drag** → `PAGE_DRAG_DISTANCE` + picker (1/8 Mile, 1000 ft, 1/4 Mile, 1/2 Mile, 1 Mile) → the session + starts immediately via `startDragSession()` → `startRaceSession(MANUAL)`. + **No track, no detection**: `trackDetected` is latched true (the sprint + latch) so `trackDetectionLoop()` never stands up a CourseManager, and + `dragTimer != nullptr` IS drag mode — the same construction as sprint, + drag branch FIRST in every `activeTimer*()` helper, runs duck-typing as + laps. The whole run state machine is the host-tested `drag_timer` unit: + stage at a standstill (≤1 mph held 1 s; the anchor is a **re-latching + running mean** of standstill fixes so GPS drift in a staging lane can't + fake a launch), launch rollout-style (11.25 in displacement + ≥2 mph, + ET start interpolated between the straddling 25 Hz fixes), accumulate + chord distance to the target, finish with interpolated ET + trap speed + and a 0-60 split (0 if never reached). Mid-run standstill (3 s) or a + ≥2 s fix gap abandons the run **silently** — which is also how a + queue-creep phantom launch self-cancels — then the timer re-arms on the + next standstill, so a whole day of passes is one DOVEX session + (`race_mode=DRAG`, course `DRAG 1/4 MILE` etc., laps line = run ETs; + trap/0-60 deliberately NOT in the header — the 25 Hz rows carry speed). + Run capture rides `checkForNewLapData()`'s run-count edge; each + completed run AND each fresh STAGED latch re-arms the auto-idle grace + (an active staging queue never idles out; manual 5 min/5 mph rules + otherwise apply, with the usual tach promotion). Display: no new + rotation pages — the Current Lap page shows live ET / last ET + + `trap`/`0-60` subtext / `*staged*`; the Pace page becomes the live 0-60 + readout; the Best Lap page adds the best run's trap/0-60; the LED pace + pip is suppressed between runs like sprint. ### 10. Shutdown (System OFF) @@ -1710,8 +1744,9 @@ timestamp,sats,hdop,lat,lng,speed_mph,altitude_m,heading_deg,h_acc_m,rpm,accel_x (after `optimal_ms`, in that order). Appending keeps old logs readable (parsed as empty) and lets older readers ignore the extra columns — backwards compatible by design. `race_mode` is `CIRCUIT` / - `SPRINT` (empty = circuit): a webapp loading helper — with `SPRINT`, - the laps line is a runs line. Nothing on-device reads it back. + `SPRINT` / `DRAG` (empty = circuit): a webapp loading helper — with + `SPRINT` or `DRAG`, the laps line is a runs line (drag runs are ETs). + Nothing on-device reads it back. - **GPS data** (byte 1024+): CSV column header then streaming GPS rows. - **`Temp1` / `Junction1` / `Temp2`** (trailing columns): SensorEgg EGT + cold junction + v2 aux intake-air temp, all °C. Literal `nan` when the @@ -1890,6 +1925,9 @@ the one loaded). Sector lines stay optional — zero, one, or two. | Course creator h_acc gate | drop >10 m, warn >5 m | `course_creator.h` | | Course creator name format | `N{YYMMDD}_{HHMM}` (+ `MMDDHHMM` short name) | `course_creator.h` | | Sprint prune order | renamed-in-app first, then oldest `date_created`; confirm only when a device-named course would go | `course_prune.h` | +| Drag rollout | 0.9375 ft (11.25 in), ET start interpolated | `drag_timer.h` | +| Drag stage / launch / abort | ≤1 mph held 1 s / ≥2 mph + rollout / ≤2 mph held 3 s or ≥2 s fix gap (silent) | `drag_timer.h` | +| Drag distances | 660 / 1000 / 1320 / 2640 / 5280 ft (picker order) | `drag_timer.cpp` | | Track JSON coordinate precision | 8 decimals (~1.1 mm) | `track_json.h` | | Tach min pulse gap | 3 ms (`wasted`) / 6 ms (`single`) — same ~20 000 RPM ceiling either way | `tach_filter.h` (`minPulseGapUs`) | | Tach revs per pulse | `wasted` ? 1.0 : 2.0 — **no cylinder term** (plan 0014) | `tach_filter.h` (`revsPerPulse`) | diff --git a/docs/plans/0015-drag-mode.md b/docs/plans/0015-drag-mode.md new file mode 100644 index 0000000..1dd0159 --- /dev/null +++ b/docs/plans/0015-drag-mode.md @@ -0,0 +1,153 @@ +# Drag Mode — Distance Runs Without a Track + +> Status: **IMPLEMENTED**. A point-and-shoot acceleration-run mode: pick a +> distance from the menu, stage at a standstill, launch, and the device times +> the run to the target distance. No track file, no detection, no lines to +> walk — the whole point is that a drag strip (or any straight) needs zero +> setup. + +## What it is + +Main menu → **Drag** → pick a distance (1/8 Mile, 1000 ft, 1/4 Mile, +1/2 Mile, 1 Mile) → the session starts immediately. The device waits for a +standstill (staging), starts the clock **rollout-style** when the car has +moved 11.25 in from its staged position, and ends the run when cumulative +GPS distance reaches the target. Per run it reports **ET**, **trap speed** +(speed at the finish), and a **0–60 mph split**. After each run it re-arms +automatically — come back to a standstill and it stages again — so a whole +day of passes lands in one DOVEX session, saved per usual with +`race_mode=DRAG`. + +## Structure: sprint mode is the template + +Sprint mode (plan 0002) already proved the shape: a session type where +`courseManager` stays null, a single timer object IS the mode +(`dragTimer != nullptr`), runs duck-type as laps through the +`activeTimer*()` helpers, and run completion is captured on the run-count +edge in `checkForNewLapData()` (identical consecutive ETs are normal — +value-change dedupe would drop them). Drag follows it line for line: + +- `startDragSession(distanceIdx)` stands up the timer, latches + `trackDetected = true` (the same latch sprint uses — it keeps + `trackDetectionLoop()` from standing up a CourseManager when the strip + happens to be near a saved track), then routes through + `startRaceSession(RACE_ENTRY_MANUAL)` like every other session start. +- `createLapAnythingCourseManager()` gains a drag guard beside the sprint + one, `endRaceSession()` tears the timer down beside the sprint teardown. +- The GPS feed is a third branch in `gps_functions.ino` next to + courseManager/sprintTimer — the three are mutually exclusive by + construction. + +The run logic itself is a new **pure unit, `drag_timer.{h,cpp}`** — +Arduino-free, host-tested, in the sim build. The sketch keeps only the glue. + +## The state machine (drag_timer::DragTimer) + +Three phases; every transition edges on a GPS fix. The glue gates on +`gpsData.fix` (like the sprint feed), so a dropout reaches the unit purely +as a timestamp gap. + +- **ARMED** (initial, post-run, post-abort): speed ≤ 1 mph held 1 s → + STAGED. Standstill fixes feed the anchor mean the whole time. +- **STAGED**: the anchor is a running mean of standstill fixes and it + **keeps re-latching** while the car is stopped. This is the load-bearing + detail: GPS drift over a multi-minute staging-lane wait would otherwise + walk the fix past the 11.25 in rollout radius and fake a launch. With a + re-latching anchor the only false-launch source left is real motion + (queue creep), and that self-cancels through the abort rule without + recording anything. Launch = displacement from anchor ≥ rollout **AND** + speed ≥ 2 mph (jitter or dead-slow creep never launches); the ET start is + linearly interpolated on the displacement curve between the two straddling + fixes, and the run's distance is seeded with the overshoot past rollout. +- **LAUNCHED**: per fix, add the chord distance (drag runs are straight — + chord ≈ path at 25 Hz). `dt ≤ 0` → fix ignored (GPS time-step guard). + Gap ≥ 2 s → run abandoned (distance across the gap is untrustworthy at + speed). 0–60: first fix pair with `v_prev < 60 ≤ v`, interpolated on + speed; 0 if never reached (short cars on the 1/8). Finish: cumulative + distance crosses the target → ET and trap both interpolated between the + straddling fixes; run recorded (count, last/best ET, best-run trap and + 0–60 snapshot); back to ARMED. There is no FINISHED phase — "re-arm" + IS "wait for standstill", which is ARMED. Abort: ≤ 2 mph held 3 s before + the target → abandoned silently. + +Tunables (all `constexpr` in `drag_timer.h`): rollout 0.9375 ft (11.25 in, +the drag-strip standard), staged ≤ 1 mph held 1000 ms, launch ≥ 2 mph, +abort ≤ 2 mph held 3000 ms, fix-gap abort 2000 ms, split target 60 mph. + +### Units at the boundary + +The unit speaks **mph** (glue converts once: `gpsData.speed × 1.15078`, +knots→mph, the same constant the logging row uses), **feet** (targets are +exact integers: 660 / 1000 / 1320 / 2640 / 5280; the unit carries its own +haversine returning feet, R = 20 902 464 ft — self-contained rather than +`haversineDistanceMiles() × 5280` per fix), and **uint64 GPS ms** from +`getGpsTimeInMilliseconds()` — no `millis()` inside the unit, sprint-timer +precedent. + +## Accuracy — what's honest to claim + +- GPS h_acc (~0.5–1.5 m CEP) is *larger* than the 0.29 m rollout, so the + absolute rollout position is noisy. What makes the ET credible anyway is + that the start is **interpolated on the displacement curve between two + 25 Hz fixes during a hard launch** (a car covers the rollout in + ~150–250 ms), so the timing error is bounded by fix-to-fix noise plus the + anchor mean's residual (~±1–2 ft ≈ ±10–30 ms), not by absolute accuracy. +- Cumulative 25 Hz haversine over 660–5280 ft carries sub-0.5 % distance + error; the anchor is the dominant term. +- **Trap speed is the interpolated instantaneous speed at the target + distance**, not a strip-style 66 ft trap-zone average. Deliberate + simplification — GPS speed at 25 Hz is already smooth, and the rows carry + everything needed to compute a zone average offline if anyone cares. + +## DOVEX — no format change + +Header: `race_mode = DRAG` (fits the 8-byte field; `dovex_header` passes +any string through and legacy readers ignore trailing columns — the same +backwards-compat story as SPRINT), course name = `DRAG 1/4 MILE` etc. +(single source: `drag_timer::dovexName()`), short name = `DRAG`. The laps +line is the run ETs via the ordinary `lapHistory` path. + +**Trap and 0–60 are deliberately NOT in the header.** The 4-line header +layout is frozen for backwards compatibility, and the 25 Hz rows carry mph +speed, so any viewer can derive both exactly (and better — e.g. a real +trap-zone average). Follow-up for the webapp: a `DRAG` case in the +race_mode loader (the laps line is a runs line, same as SPRINT); an old +viewer degrades gracefully to "unknown mode with a runs line". + +## Session end (auto-idle) + +Drag is a manual-entry session, so the manual rules apply (5 min < 5 mph), +with the usual promotion to tach rules if the tach proves itself. Two +grace re-arms keep a staging queue from idling the session out on tach-less +cars: each **completed run** resets the grace (sprint precedent), and so +does each **ARMED→STAGED edge** — queue creep re-stages every couple of +minutes, so an active queue never idles out, while a genuinely parked car +still ends ~8 min after its last movement. The engine-aware sprint reset in +`checkAutoIdle()` was widened to cover drag too. + +## UI + +- Main menu grows a **Drag** row (index 1); the menu was already a + scrolling 3-row window so a 6th item costs nothing. +- `PAGE_DRAG_DISTANCE` (−17): distance picker, five rows from + `drag_timer::label()` + Back (every menu carries a Back row), rendered + with the same scrolling-window pattern as the main menu (6 rows can't fit + statically at size 2). +- **No new rotation pages.** The existing pages branch on + `dragModeIsActive()` the way they already branch for sprint's + `*waiting*`: the Current Lap page shows the live ET while running, the + last ET + `trap / 0-60` subtext after a run, and `*staged*` / + `*waiting*` between runs; the Best Lap page adds a best-run + `trap / 0-60` subtext; the Pace page shows the live 0–60 status during a + run instead of a meaningless +0.00 pace. The LED pace pip is suppressed + between runs exactly like sprint. +- No settings persistence for the distance choice — the picker is two + presses, and a stale remembered distance is worse than none. + +## What was deliberately NOT built + +- No staging-tree / reaction-time simulation — the device has no christmas + tree; ET starts at rollout like the strip's clocks. +- No trap-zone speed averaging (see Accuracy). +- No per-distance best history across sessions — the DOVEX files are the + record; the webapp is the place to compare days. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index dd5c317..119f070 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -43,6 +43,7 @@ add_executable(birdseye_tests sector_purple_test.cpp ble_stream_test.cpp loop_profile_test.cpp + drag_timer_test.cpp ${BIRDSEYE_DIR}/haversine.cpp ${BIRDSEYE_DIR}/gps_stats.cpp ${BIRDSEYE_DIR}/gps_time.cpp @@ -75,6 +76,7 @@ add_executable(birdseye_tests ${BIRDSEYE_DIR}/sector_purple.cpp ${BIRDSEYE_DIR}/ble_stream.cpp ${BIRDSEYE_DIR}/loop_profile.cpp + ${BIRDSEYE_DIR}/drag_timer.cpp ) target_include_directories(birdseye_tests PRIVATE diff --git a/tests/drag_timer_test.cpp b/tests/drag_timer_test.cpp new file mode 100644 index 0000000..9bff7b3 --- /dev/null +++ b/tests/drag_timer_test.cpp @@ -0,0 +1,347 @@ +#include +#include + +#include "doctest.h" +#include "dovex_header.h" +#include "drag_timer.h" + +using drag_timer::DragTimer; +using drag_timer::Phase; + +// --------------------------------------------------------------------------- +// Synthetic drag strip: fixes along a meridian at 25 Hz. Position is a +// 1-D "feet down the strip" coordinate converted to latitude with the +// unit's own Earth radius, so distanceFeet() round-trips exactly. +// --------------------------------------------------------------------------- +namespace { + +constexpr uint64_t kT0 = 1700000000000ULL; // arbitrary GPS epoch ms +constexpr uint64_t kDtMs = 40; // 25 Hz +constexpr double kFtPerSecToMph = 0.6818181818; + +double degPerFoot() { + static const double d = 1.0 / drag_timer::distanceFeet(0.0, 0.0, 1.0, 0.0); + return d; +} + +struct Strip { + DragTimer t; + uint64_t now = kT0; + + explicit Strip(int idx = 0) { t.setTarget(idx); } + + // One fix at `xFt` down the strip moving at `mph`, then advance time. + bool fix(double xFt, double mph) { + const bool done = t.onFix(xFt * degPerFoot(), 0.0, (float)mph, now); + now += kDtMs; + return done; + } + + // Hold a standstill at `xFt` for `ms`. + void standstill(double xFt, uint64_t ms) { + const uint64_t until = now + ms; + while (now < until) fix(xFt, 0.0); + } + + // Constant acceleration from rest at `x0`: x = x0 + a/2 t², v = a t. + // Runs until a run completes or `maxMs` elapses; returns completion. + bool launchConstAccel(double x0, double aFtPerS2, uint64_t maxMs) { + const uint64_t start = now; + while (now - start < maxMs) { + const double tS = (double)(now - start) / 1000.0; + const double x = x0 + 0.5 * aFtPerS2 * tS * tS; + const double v = aFtPerS2 * tS * kFtPerSecToMph; + if (fix(x, v)) return true; + } + return false; + } + + // Constant speed from `x0`; same contract. + bool cruise(double x0, double ftPerS, uint64_t maxMs) { + const uint64_t start = now; + while (now - start < maxMs) { + const double tS = (double)(now - start) / 1000.0; + if (fix(x0 + ftPerS * tS, ftPerS * kFtPerSecToMph)) return true; + } + return false; + } +}; + +} // namespace + +// --------------------------------------------------------------------------- +// Staging +// --------------------------------------------------------------------------- + +TEST_CASE("arms to staged after a standstill hold, not before") { + Strip s; + s.standstill(0.0, drag_timer::kStageHoldMs - 200); + CHECK(s.t.phase() == Phase::kArmed); + s.standstill(0.0, 400); + CHECK(s.t.phase() == Phase::kStaged); + CHECK(s.t.staged()); + CHECK_FALSE(s.t.runActive()); +} + +TEST_CASE("standstill position jitter never launches") { + Strip s; + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + // Minutes of ±2 ft jitter at 0 mph — well past the rollout radius but + // with no speed behind it. + for (int i = 0; i < 25 * 120; i++) { + s.fix((i % 2) ? 2.0 : -2.0, 0.0); + } + CHECK(s.t.phase() == Phase::kStaged); + CHECK(s.t.runs() == 0); +} + +TEST_CASE("slow anchor drift at standstill stays staged (anchor re-latch)") { + Strip s; + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + // GPS drift: the fix walks 6 ft over 3 minutes at 0 mph. Without the + // re-latching anchor this crosses the rollout radius and fakes a + // launch; with it the anchor follows. + const int n = 25 * 180; + for (int i = 0; i < n; i++) { + s.fix(6.0 * i / n, 0.0); + } + CHECK(s.t.phase() == Phase::kStaged); + CHECK(s.t.runs() == 0); +} + +TEST_CASE("slow reposition re-stages at the new spot") { + Strip s; + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + // Creep 40 ft at ~1.4 mph (above standstill, below launch speed). + const double v = 2.0; // ft/s = 1.36 mph + for (double x = 0.0; x < 40.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + CHECK(s.t.phase() == Phase::kArmed); + s.standstill(40.0, 2000); + CHECK(s.t.phase() == Phase::kStaged); + CHECK(s.t.runs() == 0); +} + +// --------------------------------------------------------------------------- +// The run: rollout, ET, trap, 0-60 +// --------------------------------------------------------------------------- + +TEST_CASE("rollout crossing starts the clock, interpolated") { + Strip s(0); // 1/8 mile = 660 ft + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + + const double a = 14.7; // ft/s² (~0-60 in 6 s) + REQUIRE(s.launchConstAccel(0.0, a, 60000)); + CHECK(s.t.runs() == 1); + + // Analytic: the clock starts at the rollout crossing and the timed + // distance counts from there, so the finish is at rollout + 660 ft of + // absolute travel. + const double tRoll = sqrt(2.0 * drag_timer::kRolloutFt / a); + const double tFin = sqrt(2.0 * (660.0 + drag_timer::kRolloutFt) / a); + const double etMs = (tFin - tRoll) * 1000.0; + CHECK(fabs((double)s.t.lastEtMs() - etMs) <= kDtMs); +} + +TEST_CASE("ET and trap interpolated at target distance (constant speed)") { + Strip s(0); // 660 ft + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + + const double v = 60.0; // ft/s = 40.9 mph + REQUIRE(s.cruise(0.0, v, 60000)); + CHECK(s.t.runs() == 1); + + // Timed distance counts from the rollout point, so at constant speed + // the ET is exactly target / v. + const double etMs = 660.0 / v * 1000.0; + CHECK(fabs((double)s.t.lastEtMs() - etMs) <= kDtMs); + CHECK(s.t.lastTrapMph() == doctest::Approx(v * kFtPerSecToMph).epsilon(0.01)); +} + +TEST_CASE("0-60 split interpolated between straddling fixes") { + Strip s(2); // 1/4 mile so 60 mph is reached well before the finish + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + + const double a = 14.6667; // ft/s²: 88 ft/s (60 mph) at exactly t=6 s + REQUIRE(s.launchConstAccel(0.0, a, 60000)); + CHECK(s.t.runs() == 1); + + const double tRoll = sqrt(2.0 * drag_timer::kRolloutFt / a); + const double splitMs = (6.0 - tRoll) * 1000.0; + REQUIRE(s.t.last0to60Ms() != 0); + // Speed is linear in t, so the speed interpolation is exact; the only + // error is the rollout start (bounded by one fix interval). + CHECK(fabs((double)s.t.last0to60Ms() - splitMs) <= kDtMs); +} + +TEST_CASE("split is 0 when 60 mph is never reached") { + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.cruise(0.0, 44.0, 60000)); // 30 mph the whole way + CHECK(s.t.runs() == 1); + CHECK(s.t.last0to60Ms() == 0); +} + +// --------------------------------------------------------------------------- +// Run bookkeeping + re-arm +// --------------------------------------------------------------------------- + +TEST_CASE("faster second run takes best with its trap/split snapshot") { + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.cruise(0.0, 50.0, 60000)); // run 1, slower + const unsigned long et1 = s.t.lastEtMs(); + + s.standstill(660.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); // re-armed and re-staged + REQUIRE(s.launchConstAccel(660.0, 25.0, 60000)); // run 2, faster + hits 60 + + CHECK(s.t.runs() == 2); + CHECK(s.t.lastEtMs() < et1); + CHECK(s.t.bestEtMs() == s.t.lastEtMs()); + CHECK(s.t.bestRunNumber() == 2); + CHECK(s.t.bestTrapMph() == doctest::Approx(s.t.lastTrapMph())); + CHECK(s.t.best0to60Ms() == s.t.last0to60Ms()); +} + +TEST_CASE("slower second run leaves best untouched") { + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.cruise(0.0, 60.0, 60000)); + const unsigned long best = s.t.bestEtMs(); + const float bestTrap = s.t.bestTrapMph(); + + s.standstill(660.0, 2000); + REQUIRE(s.cruise(660.0, 40.0, 60000)); + + CHECK(s.t.runs() == 2); + CHECK(s.t.bestEtMs() == best); + CHECK(s.t.bestRunNumber() == 1); + CHECK(s.t.bestTrapMph() == doctest::Approx(bestTrap)); +} + +// --------------------------------------------------------------------------- +// Aborts +// --------------------------------------------------------------------------- + +TEST_CASE("creep launch abandons silently on the mid-run standstill") { + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + // Queue creep: 30 ft at 5 mph trips a phantom launch... + const double v = 7.33; // ft/s = 5 mph + for (double x = 0.0; x < 30.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + CHECK(s.t.runActive()); + // ...then the car stops and the phantom run evaporates. + s.standstill(30.0, drag_timer::kAbortHoldMs + 500); + CHECK(s.t.runs() == 0); + CHECK_FALSE(s.t.runActive()); + // And it re-stages at the new spot for the real launch. + s.standstill(30.0, 2000); + CHECK(s.t.phase() == Phase::kStaged); +} + +TEST_CASE("fix gap over threshold aborts the run") { + Strip s(0); + s.standstill(0.0, 2000); + const double v = 60.0; + for (double x = 0.0; x < 200.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + REQUIRE(s.t.runActive()); + s.now += drag_timer::kFixGapAbortMs; // GPS dropout at speed + s.fix(500.0, v * kFtPerSecToMph); + CHECK_FALSE(s.t.runActive()); + CHECK(s.t.runs() == 0); +} + +TEST_CASE("fix gap under threshold accumulates the chord and continues") { + Strip s(0); + s.standstill(0.0, 2000); + const double v = 60.0; + bool done = false; + double x = 0.0; + int i = 0; + while (!done && x < 1000.0) { + x += v * 0.04; + if (i++ == 100) { // one 500 ms dropout mid-run + s.now += 460; // + the regular 40 ms step = 500 ms gap + x += v * 0.46; + } + done = s.fix(x, v * kFtPerSecToMph); + } + REQUIRE(done); + const double etMs = 660.0 / v * 1000.0; + CHECK(fabs((double)s.t.lastEtMs() - etMs) <= kDtMs); +} + +TEST_CASE("non-monotonic timestamps are ignored") { + Strip s(0); + s.standstill(0.0, 2000); + const double v = 60.0; + for (double x = 0.0; x < 200.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + REQUIRE(s.t.runActive()); + const float distBefore = s.t.distanceFt(); + // A fix from the past must not double-count distance or abort. + CHECK_FALSE(s.t.onFix(300.0 * degPerFoot(), 0.0, (float)(v * kFtPerSecToMph), + s.now - 5000)); + CHECK(s.t.runActive()); + CHECK(s.t.distanceFt() == doctest::Approx(distBefore)); +} + +// --------------------------------------------------------------------------- +// Live ET + distance table +// --------------------------------------------------------------------------- + +TEST_CASE("currentEtMs is live between fixes and zero outside a run") { + Strip s(0); + CHECK(s.t.currentEtMs(s.now) == 0); + s.standstill(0.0, 2000); + CHECK(s.t.currentEtMs(s.now) == 0); + const double v = 60.0; + for (double x = 0.0; x < 100.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + REQUIRE(s.t.runActive()); + const unsigned long a = s.t.currentEtMs(s.now); + const unsigned long b = s.t.currentEtMs(s.now + 20); + CHECK(a > 0); + CHECK(b == a + 20); +} + +TEST_CASE("distance table goldens") { + CHECK(drag_timer::targetFeet(0) == 660.0f); + CHECK(drag_timer::targetFeet(1) == 1000.0f); + CHECK(drag_timer::targetFeet(2) == 1320.0f); + CHECK(drag_timer::targetFeet(3) == 2640.0f); + CHECK(drag_timer::targetFeet(4) == 5280.0f); + + CHECK(strcmp(drag_timer::label(0), "1/8 Mile") == 0); + CHECK(strcmp(drag_timer::label(1), "1000 ft") == 0); + CHECK(strcmp(drag_timer::label(2), "1/4 Mile") == 0); + CHECK(strcmp(drag_timer::label(3), "1/2 Mile") == 0); + CHECK(strcmp(drag_timer::label(4), "1 Mile") == 0); + + CHECK(strcmp(drag_timer::dovexName(2), "DRAG 1/4 MILE") == 0); + // Every DOVEX name must fit the header's course field, and the DRAG + // race_mode token must fit its field. + for (int i = 0; i < drag_timer::kDistanceCount; i++) { + CHECK(strlen(drag_timer::dovexName(i)) < dovex_header::kCourseLen); + } + CHECK(strlen("DRAG") < dovex_header::kRaceModeLen); + + // Out-of-range indexes clamp instead of reading wild. + CHECK(drag_timer::targetFeet(-1) == 660.0f); + CHECK(drag_timer::targetFeet(99) == 660.0f); +} From 7e7dd93226ba20d6ee09b8f0f24591dfe107c258 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 02:16:55 +0000 Subject: [PATCH 2/2] =?UTF-8?q?plan=200015:=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20time=20base,=20launch=20guards,=20unit=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from the branch review, all fixed: - Time base: the glue fed getGpsTimeInMilliseconds() (time-of-day, wraps to zero at UTC midnight — evening sessions) into a unit whose contract says epoch ms. Both call sites now feed getGpsUnixTimestampMillis(), and the unit's non-monotonic guard resyncs on a backwards step instead of rejecting every subsequent fix — one step used to wedge the drag timer for the rest of the session. - Launch edge: a >=2 s fix gap while STAGED now re-stages instead of evaluating the launch, so a launch that happened inside a GPS dropout can't interpolate its ET start back to a parked-car fix. - Prove-out gate: a launch must reach 15 mph within 5 s of the ET start or the run is silently abandoned — a wave-off driven to the pits at 4 mph never holds the sub-2 mph standstill abort and used to record 660 ft of pit road as a ~90 s "run". - activeTimerTotalDistance() converts the drag branch to meters, matching every other branch of the accessor. - distanceFeet() delegates to the haversine unit (x 5280) instead of carrying a second copy of the formula and Earth radius. - The seconds.hundredths split rendering is now one function (displayPrintSplitSeconds) shared by the results subtext and the pace page's live 0-60 readout. Five new regression tests cover the wedge recovery, the staged-gap launch, the wave-off, a slow-but-real pass, and duplicate timestamps. Plan 0015 doc + CLAUDE.md constants updated to match. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BKa62h4fhn61pKn1LygrMK --- BirdsEye/BirdsEye.ino | 8 +++- BirdsEye/display_pages.h | 6 ++- BirdsEye/display_pages.ino | 23 +++++----- BirdsEye/drag_timer.cpp | 57 ++++++++++++++++++------ BirdsEye/drag_timer.h | 34 ++++++++++---- BirdsEye/gps_functions.ino | 7 ++- BirdsEye/sim/sim_prototypes.h | 1 + CLAUDE.md | 2 + docs/plans/0015-drag-mode.md | 41 ++++++++++++----- tests/drag_timer_test.cpp | 83 ++++++++++++++++++++++++++++++++--- 10 files changed, 209 insertions(+), 53 deletions(-) diff --git a/BirdsEye/BirdsEye.ino b/BirdsEye/BirdsEye.ino index 5a8f521..0670edf 100644 --- a/BirdsEye/BirdsEye.ino +++ b/BirdsEye/BirdsEye.ino @@ -1424,7 +1424,9 @@ int activeTimerLaps() { } unsigned long activeTimerCurrentLapTime() { - if (dragTimer != nullptr) return dragTimer->currentEtMs(getGpsTimeInMilliseconds()); + // Epoch ms, matching the feed in gps_functions.ino — the time-of-day + // clock wraps at UTC midnight and the mismatch would corrupt live ET. + if (dragTimer != nullptr) return dragTimer->currentEtMs(getGpsUnixTimestampMillis()); if (sprintTimer != nullptr) return sprintTimer->getCurrentRunTime(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getCurrentLapTime(); @@ -1474,7 +1476,9 @@ float activeTimerPaceDifference() { } float activeTimerTotalDistance() { - if (dragTimer != nullptr) return dragTimer->distanceFt(); // NB: feet, not meters + // Meters, like every other branch of this accessor — the unit is + // feet-native, so convert at the boundary. + if (dragTimer != nullptr) return dragTimer->distanceFt() * 0.3048f; if (sprintTimer != nullptr) return sprintTimer->getTotalDistanceTraveled(); DovesLapTimer* dlt = getActiveTimerDLT(); if (dlt) return dlt->getTotalDistanceTraveled(); diff --git a/BirdsEye/display_pages.h b/BirdsEye/display_pages.h index 3c0bee8..dd5155b 100644 --- a/BirdsEye/display_pages.h +++ b/BirdsEye/display_pages.h @@ -29,10 +29,12 @@ void displayPage_sd_format_progress(const __FlashStringHelper* line1, const __FlashStringHelper* line2); void displayPage_sleep_charging(); -// Drag mode (plan 0015) — the distance picker + the shared trap/0-60 -// results subtext used by the lap-time and best-lap pages. +// Drag mode (plan 0015) — the distance picker, the shared trap/0-60 +// results subtext used by the lap-time and best-lap pages, and the one +// seconds.hundredths split renderer both 0-60 readouts share. void displayPage_drag_distance(); void displayPrintDragStats(float trapMph, unsigned long split60Ms); +void displayPrintSplitSeconds(unsigned long ms); // Replay flow (DOVEX instant replay). void displayPage_replay_file_select(); diff --git a/BirdsEye/display_pages.ino b/BirdsEye/display_pages.ino index 029faa7..156ecaa 100644 --- a/BirdsEye/display_pages.ino +++ b/BirdsEye/display_pages.ino @@ -199,6 +199,17 @@ void displayPage_drag_distance() { safeDisplayUpdate(); } +// The ONE seconds.hundredths renderer for drag splits — both the results +// subtext and the pace page's live 0-60 readout go through it, so the +// format can't diverge between the two. +void displayPrintSplitSeconds(unsigned long ms) { + display.print(ms / 1000); + display.print(F(".")); + unsigned long hundredths = (ms % 1000) / 10; + if (hundredths < 10) display.print(F("0")); + display.print(hundredths); +} + // Trap-speed + 0-60 subtext shared by the drag results renderings below. // The ET itself always goes through lap_format like every other time; the // split is seconds to two decimals, "0-60" omitted if never reached. @@ -207,11 +218,7 @@ void displayPrintDragStats(float trapMph, unsigned long split60Ms) { display.print(trapMph, 1); if (split60Ms > 0) { display.print(F(" 0-60 ")); - display.print(split60Ms / 1000); - display.print(F(".")); - unsigned long hundredths = (split60Ms % 1000) / 10; - if (hundredths < 10) display.print(F("0")); - display.print(hundredths); + displayPrintSplitSeconds(split60Ms); } } @@ -854,11 +861,7 @@ void displayPage_gps_pace() { unsigned long split60 = dragCurrent0to60Ms(); if (split60 > 0) { display.print(F(" ")); - display.print(split60 / 1000); - display.print(F(".")); - unsigned long hundredths = (split60 % 1000) / 10; - if (hundredths < 10) display.print(F("0")); - display.print(hundredths); + displayPrintSplitSeconds(split60); display.print(F("s")); } else { display.print(F(" -.--")); diff --git a/BirdsEye/drag_timer.cpp b/BirdsEye/drag_timer.cpp index 3f34242..878951c 100644 --- a/BirdsEye/drag_timer.cpp +++ b/BirdsEye/drag_timer.cpp @@ -1,13 +1,11 @@ #include "drag_timer.h" -#include +#include "haversine.h" namespace drag_timer { namespace { -constexpr double kDegToRad = 0.017453292519943295; // M_PI / 180.0 -// 3958.8 mi (haversine.cpp's radius) in feet — targets are feet-native. -constexpr double kEarthRadiusFeet = 20902464.0; +constexpr double kFeetPerMile = 5280.0; constexpr float kTargetsFt[kDistanceCount] = { 660.0f, // 1/8 mile @@ -44,13 +42,9 @@ const char* dovexName(int idx) { } double distanceFeet(double lat1, double lng1, double lat2, double lng2) { - const double dLat = (lat2 - lat1) * kDegToRad; - const double dLng = (lng2 - lng1) * kDegToRad; - const double a = sin(dLat / 2) * sin(dLat / 2) + - cos(lat1 * kDegToRad) * cos(lat2 * kDegToRad) * - sin(dLng / 2) * sin(dLng / 2); - const double c = 2 * atan2(sqrt(a), sqrt(1 - a)); - return kEarthRadiusFeet * c; + // The ONE haversine in the codebase (track detection uses it too) — + // two copies of the formula/radius would be free to drift apart. + return haversineDistanceMiles(lat1, lng1, lat2, lng2) * kFeetPerMile; } void DragTimer::setTarget(int idx) { @@ -75,6 +69,7 @@ void DragTimer::resetToArmed() { anchorCount_ = 0; stillTracking_ = false; runDistFt_ = 0.0f; + provenOut_ = false; sixtyCrossed_ = false; run0to60Ms_ = 0; slowTracking_ = false; @@ -95,9 +90,18 @@ void DragTimer::foldAnchor(double lat, double lng) { bool DragTimer::onFix(double lat, double lng, float speedMph, uint64_t gpsTimeMs) { - // Non-monotonic GPS time (a time step mid-lock) — drop the fix whole - // rather than let a negative/zero dt into any of the math below. - if (havePrev_ && gpsTimeMs <= prevTimeMs_) return false; + // Non-monotonic GPS time. A duplicate timestamp is dropped whole; a + // BACKWARDS step (receiver clock correction mid-lock) means the + // timebase under any in-flight measurement is gone — abandon it and + // RESYNC by treating this as the first fix of a fresh stream. The + // pre-fix guard only returned, which never updated prevTimeMs_, so + // one backwards step rejected every later fix and wedged the timer + // for the rest of the session. + if (havePrev_ && gpsTimeMs <= prevTimeMs_) { + if (gpsTimeMs == prevTimeMs_) return false; + resetToArmed(); + havePrev_ = false; + } bool completed = false; @@ -121,6 +125,16 @@ bool DragTimer::onFix(double lat, double lng, float speedMph, } case Phase::kStaged: { + // A fix gap while staged: the launch may have happened INSIDE the + // gap, and interpolating the ET start across it would anchor the + // clock to a parked-car fix from before the dropout. Re-stage + // instead — a car that is in fact still parked is staged again + // one second later. + if (havePrev_ && gpsTimeMs - prevTimeMs_ >= kFixGapAbortMs) { + resetToArmed(); + break; + } + // Re-latching anchor: the mean follows the parked car's drifting // fix, so only real motion can open the rollout gap (plan 0015). if (speedMph <= kStagedMaxMph) foldAnchor(lat, lng); @@ -142,6 +156,7 @@ bool DragTimer::onFix(double lat, double lng, float speedMph, } phase_ = Phase::kLaunched; runDistFt_ = (float)(d - kRolloutFt); // overshoot past rollout + provenOut_ = speedMph >= kProveOutMph; sixtyCrossed_ = false; run0to60Ms_ = 0; slowTracking_ = false; @@ -166,6 +181,20 @@ bool DragTimer::onFix(double lat, double lng, float speedMph, break; } + // Prove-out: a real pass reaches kProveOutMph within seconds of + // the ET start. A wave-off driven to the pits at 4 mph does not, + // and it never holds the sub-2 mph standstill the abort below + // needs — without this gate it would record 660 ft of pit road + // as a run. + if (!provenOut_) { + if (speedMph >= kProveOutMph) { + provenOut_ = true; + } else if ((double)gpsTimeMs - runStartMs_ >= (double)kProveOutMs) { + resetToArmed(); + break; + } + } + const double stepFt = distanceFeet(prevLat_, prevLng_, lat, lng); const float distBefore = runDistFt_; const float distAfter = runDistFt_ + (float)stepFt; diff --git a/BirdsEye/drag_timer.h b/BirdsEye/drag_timer.h index 73f543f..5ce03c5 100644 --- a/BirdsEye/drag_timer.h +++ b/BirdsEye/drag_timer.h @@ -13,8 +13,12 @@ // The sketch keeps only the glue (sprint-timer precedent): every // transition here edges on a GPS fix passed to onFix(), the glue gates // on gpsData.fix, and a dropout reaches this unit purely as a timestamp -// gap. Units at the boundary: mph, feet, GPS epoch milliseconds — no -// millis(), no Arduino headers, host-tested. +// gap. Units at the boundary: mph, feet, and **Unix epoch milliseconds** +// (getGpsUnixTimestampMillis()) — NOT time-of-day ms, which wraps to +// zero at UTC midnight, i.e. prime evening test-and-tune hours across +// the US. A backwards time step (receiver clock correction) aborts any +// in-flight measurement and resyncs, so a step can never wedge the +// stream. No millis(), no Arduino headers, host-tested. // // The load-bearing detail is the STAGED anchor: it is a running mean of // standstill fixes that keeps re-latching while the car is stopped, so @@ -67,9 +71,20 @@ constexpr uint32_t kAbortHoldMs = 3000; // A fix gap this long mid-run aborts the run: the chord across the gap // is untrustworthy distance at speed. Gaps below it are tolerated (the -// chord ~ the path; drag runs are straight). +// chord ~ the path; drag runs are straight). The same threshold guards +// the STAGED launch edge — a launch that happened INSIDE a gap would +// interpolate its ET start back to a parked-car fix, so the gap +// re-stages instead (costs one second if the car is in fact parked). constexpr uint32_t kFixGapAbortMs = 2000; +// A launch must prove itself: reach kProveOutMph within kProveOutMs of +// the ET start or the run is abandoned silently. This is what separates +// a pass from being waved off and driving to the pits at 4 mph — that +// never holds a sub-2 mph standstill, so the abort rule alone would let +// 660 ft of pit road record as a ~90 s "run". +constexpr float kProveOutMph = 15.0f; +constexpr uint32_t kProveOutMs = 5000; + // STAGED car that drifts this far from its anchor WITHOUT launching // (speed stayed under kLaunchMinMph) has moved to a new spot — drop // back to ARMED and re-stage there. Deliberately much larger than the @@ -94,9 +109,10 @@ class DragTimer { void setTarget(int idx); // One valid GPS fix (glue gates on gpsData.fix). speedMph is ground - // speed in mph, gpsTimeMs is GPS epoch milliseconds. Returns true - // exactly when this fix COMPLETED a run (the glue's run-count edge - // fires off runs() anyway; the bool is a convenience). + // speed in mph, gpsTimeMs is Unix epoch milliseconds (see the header + // comment — never a wrapping time-of-day clock). Returns true exactly + // when this fix COMPLETED a run (the glue's run-count edge fires off + // runs() anyway; the bool is a convenience). bool onFix(double lat, double lng, float speedMph, uint64_t gpsTimeMs); Phase phase() const { return phase_; } @@ -157,6 +173,7 @@ class DragTimer { // Live run. double runStartMs_ = 0.0; // interpolated -> fractional ms internally float runDistFt_ = 0.0f; + bool provenOut_ = false; // reached kProveOutMph since the launch bool sixtyCrossed_ = false; unsigned long run0to60Ms_ = 0; bool slowTracking_ = false; @@ -173,8 +190,9 @@ class DragTimer { unsigned long best0to60Ms_ = 0; }; -// Great-circle distance in FEET (spherical Earth, R matching -// haversine.cpp's 3958.8 mi). Exposed for the tests' synthetic tracks. +// Great-circle distance in FEET — delegates to the haversine unit's +// haversineDistanceMiles() (ONE formula, one Earth radius, shared with +// track detection). Exposed for the tests' synthetic tracks. double distanceFeet(double lat1, double lng1, double lat2, double lng2); } // namespace drag_timer diff --git a/BirdsEye/gps_functions.ino b/BirdsEye/gps_functions.ino index a891865..6701182 100644 --- a/BirdsEye/gps_functions.ino +++ b/BirdsEye/gps_functions.ino @@ -489,10 +489,13 @@ void GPS_LOOP() { } else if (gpsData.fix && dragTimer != nullptr) { // Drag mode (plan 0015): the run state machine lives in the // host-tested drag_timer unit; run completion is captured on the - // run-count edge in checkForNewLapData() like sprint. + // run-count edge in checkForNewLapData() like sprint. EPOCH ms, + // not getGpsTimeInMilliseconds() — that clock wraps to zero at + // UTC midnight (evening sessions, US time zones) and a wrap + // aborts whatever run is in flight. dragTimer->onFix(gpsData.latitudeDegrees, gpsData.longitudeDegrees, (float)(gpsData.speed * 1.15078), // knots -> mph - getGpsTimeInMilliseconds()); + getGpsUnixTimestampMillis()); } #ifdef SD_CARD_LOGGING_ENABLED diff --git a/BirdsEye/sim/sim_prototypes.h b/BirdsEye/sim/sim_prototypes.h index 715d6b6..63f1597 100644 --- a/BirdsEye/sim/sim_prototypes.h +++ b/BirdsEye/sim/sim_prototypes.h @@ -102,6 +102,7 @@ void displayPage_gps_status(); void displayPage_main_menu(); void displayPage_drag_distance(); void displayPrintDragStats(float trapMph, unsigned long split60Ms); +void displayPrintSplitSeconds(unsigned long ms); void displayPage_bluetooth(); void displayPage_transfer_menu(); void displayPage_usb_storage(); diff --git a/CLAUDE.md b/CLAUDE.md index 28e8928..6501681 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1927,6 +1927,8 @@ the one loaded). Sector lines stay optional — zero, one, or two. | Sprint prune order | renamed-in-app first, then oldest `date_created`; confirm only when a device-named course would go | `course_prune.h` | | Drag rollout | 0.9375 ft (11.25 in), ET start interpolated | `drag_timer.h` | | Drag stage / launch / abort | ≤1 mph held 1 s / ≥2 mph + rollout / ≤2 mph held 3 s or ≥2 s fix gap (silent) | `drag_timer.h` | +| Drag prove-out | launch must reach 15 mph within 5 s of ET start, else silently abandoned | `drag_timer.h` | +| Drag time base | Unix epoch ms (`getGpsUnixTimestampMillis()`) — never time-of-day ms (wraps at UTC midnight) | `gps_functions.ino` | | Drag distances | 660 / 1000 / 1320 / 2640 / 5280 ft (picker order) | `drag_timer.cpp` | | Track JSON coordinate precision | 8 decimals (~1.1 mm) | `track_json.h` | | Tach min pulse gap | 3 ms (`wasted`) / 6 ms (`single`) — same ~20 000 RPM ceiling either way | `tach_filter.h` (`minPulseGapUs`) | diff --git a/docs/plans/0015-drag-mode.md b/docs/plans/0015-drag-mode.md index 1dd0159..0161221 100644 --- a/docs/plans/0015-drag-mode.md +++ b/docs/plans/0015-drag-mode.md @@ -49,7 +49,12 @@ as a timestamp gap. - **ARMED** (initial, post-run, post-abort): speed ≤ 1 mph held 1 s → STAGED. Standstill fixes feed the anchor mean the whole time. -- **STAGED**: the anchor is a running mean of standstill fixes and it +- **STAGED**: a fix gap ≥ 2 s **re-stages** instead of evaluating the + launch condition — a launch that happened inside a GPS dropout would + otherwise interpolate its ET start back to a parked-car fix from + before the gap, inflating the ET by up to the gap length (a car still + parked simply stages again one second later). The anchor is a running + mean of standstill fixes and it **keeps re-latching** while the car is stopped. This is the load-bearing detail: GPS drift over a multi-minute staging-lane wait would otherwise walk the fix past the 11.25 in rollout radius and fake a launch. With a @@ -60,29 +65,45 @@ as a timestamp gap. linearly interpolated on the displacement curve between the two straddling fixes, and the run's distance is seeded with the overshoot past rollout. - **LAUNCHED**: per fix, add the chord distance (drag runs are straight — - chord ≈ path at 25 Hz). `dt ≤ 0` → fix ignored (GPS time-step guard). + chord ≈ path at 25 Hz). A duplicate timestamp is dropped whole; a + backwards one resyncs (see below). Gap ≥ 2 s → run abandoned (distance across the gap is untrustworthy at speed). 0–60: first fix pair with `v_prev < 60 ≤ v`, interpolated on speed; 0 if never reached (short cars on the 1/8). Finish: cumulative distance crosses the target → ET and trap both interpolated between the straddling fixes; run recorded (count, last/best ET, best-run trap and 0–60 snapshot); back to ARMED. There is no FINISHED phase — "re-arm" - IS "wait for standstill", which is ARMED. Abort: ≤ 2 mph held 3 s before - the target → abandoned silently. + IS "wait for standstill", which is ARMED. Two aborts, both silent: + ≤ 2 mph held 3 s before the target (this also self-cancels queue-creep + phantom launches), and the **prove-out gate** — a launch that fails to + reach 15 mph within 5 s of the ET start is not a pass, it is a wave-off + driven to the pits at 4 mph, which never holds the sub-2 mph standstill + the first abort needs and would otherwise record 660 ft of pit road as + a ~90 s "run". + +A **backwards time step** (receiver clock correction) aborts whatever is +in flight and resyncs the stream — the first-cut guard just dropped the +fix without updating its reference, so one backwards step rejected every +later fix and wedged the timer for the rest of the session. Tunables (all `constexpr` in `drag_timer.h`): rollout 0.9375 ft (11.25 in, the drag-strip standard), staged ≤ 1 mph held 1000 ms, launch ≥ 2 mph, -abort ≤ 2 mph held 3000 ms, fix-gap abort 2000 ms, split target 60 mph. +prove-out ≥ 15 mph within 5000 ms, abort ≤ 2 mph held 3000 ms, fix-gap +abort 2000 ms (mid-run AND on the staged launch edge), split target +60 mph. ### Units at the boundary The unit speaks **mph** (glue converts once: `gpsData.speed × 1.15078`, knots→mph, the same constant the logging row uses), **feet** (targets are -exact integers: 660 / 1000 / 1320 / 2640 / 5280; the unit carries its own -haversine returning feet, R = 20 902 464 ft — self-contained rather than -`haversineDistanceMiles() × 5280` per fix), and **uint64 GPS ms** from -`getGpsTimeInMilliseconds()` — no `millis()` inside the unit, sprint-timer -precedent. +exact integers: 660 / 1000 / 1320 / 2640 / 5280; `distanceFeet()` +delegates to the existing `haversine` unit × 5280, so track detection and +drag distance can never disagree on the Earth), and **Unix epoch +milliseconds** from `getGpsUnixTimestampMillis()` — NOT +`getGpsTimeInMilliseconds()`, which is time-of-day and wraps to zero at +UTC midnight: prime evening test-and-tune hours across the US, and the +wrap would look like a backwards step every night. No `millis()` inside +the unit, sprint-timer precedent. ## Accuracy — what's honest to claim diff --git a/tests/drag_timer_test.cpp b/tests/drag_timer_test.cpp index 9bff7b3..68ed706 100644 --- a/tests/drag_timer_test.cpp +++ b/tests/drag_timer_test.cpp @@ -284,22 +284,95 @@ TEST_CASE("fix gap under threshold accumulates the chord and continues") { CHECK(fabs((double)s.t.lastEtMs() - etMs) <= kDtMs); } -TEST_CASE("non-monotonic timestamps are ignored") { +TEST_CASE("duplicate timestamp is dropped without disturbing the run") { Strip s(0); s.standstill(0.0, 2000); const double v = 60.0; - for (double x = 0.0; x < 200.0; x += v * 0.04) { + double x = 0.0; + for (; x < 200.0; x += v * 0.04) { s.fix(x, v * kFtPerSecToMph); } REQUIRE(s.t.runActive()); const float distBefore = s.t.distanceFt(); - // A fix from the past must not double-count distance or abort. - CHECK_FALSE(s.t.onFix(300.0 * degPerFoot(), 0.0, (float)(v * kFtPerSecToMph), - s.now - 5000)); + // Same timestamp as the last accepted fix: dropped whole. + CHECK_FALSE(s.t.onFix((x + 50.0) * degPerFoot(), 0.0, + (float)(v * kFtPerSecToMph), s.now - kDtMs)); CHECK(s.t.runActive()); CHECK(s.t.distanceFt() == doctest::Approx(distBefore)); } +TEST_CASE("backwards time step aborts the run and the timer recovers") { + // Regression for the session-wedge bug: the old guard rejected a + // backwards fix without resyncing its reference, so ONE backwards + // step (e.g. a wrapping time-of-day clock at UTC midnight, or a + // receiver clock correction) rejected every later fix for the rest + // of the session. A backwards step must abandon the in-flight run + // AND leave the timer fully functional on the new timebase. + Strip s(0); + s.standstill(0.0, 2000); + const double v = 60.0; + for (double x = 0.0; x < 200.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + REQUIRE(s.t.runActive()); + + s.now = 1000; // the clock "wrapped" — far in the past + s.fix(200.0, 0.0); + CHECK_FALSE(s.t.runActive()); + CHECK(s.t.runs() == 0); + + // The stream continues on the new timebase — a full stage + run works. + s.standstill(200.0, 2000); + CHECK(s.t.phase() == Phase::kStaged); + REQUIRE(s.cruise(200.0, v, 60000)); + CHECK(s.t.runs() == 1); +} + +TEST_CASE("fix gap while staged re-stages instead of timing across it") { + // A launch that happens INSIDE a GPS dropout must not be timed by + // interpolating across the gap — that anchors the ET start to a + // parked-car fix from before the dropout and inflates the ET by the + // gap length. + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + + s.now += 5000; // dropout; the car launched somewhere inside it + const double v = 60.0; + for (double x = 100.0; x < 400.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + CHECK(s.t.runs() == 0); // nothing recorded off the gap + + // Parked again after the pass -> stages normally. + s.standstill(400.0, 2000); + CHECK(s.t.phase() == Phase::kStaged); +} + +TEST_CASE("wave-off drive to the pits never records a run") { + // Waved off and driven away at a steady 4-5 mph: above the launch + // threshold, never below the abort threshold — without the prove-out + // gate this recorded 660 ft of pit road as a ~90 s "run". + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.t.phase() == Phase::kStaged); + const double v = 7.33; // ft/s = 5 mph + for (double x = 0.0; x < 700.0; x += v * 0.04) { + s.fix(x, v * kFtPerSecToMph); + } + CHECK(s.t.runs() == 0); + CHECK_FALSE(s.t.runActive()); // abandoned by the prove-out gate +} + +TEST_CASE("a slow but real pass proves out and records") { + // Gentle launch (~6 ft/s^2): 15 mph arrives well inside the prove-out + // window, so the gate never fires and the run completes normally. + Strip s(0); + s.standstill(0.0, 2000); + REQUIRE(s.launchConstAccel(0.0, 6.0, 60000)); + CHECK(s.t.runs() == 1); +} + // --------------------------------------------------------------------------- // Live ET + distance table // ---------------------------------------------------------------------------