Skip to content

Conversation

@jctoledo
Copy link
Owner

@jctoledo jctoledo commented Jan 26, 2026

Major release adding a complete lap timer system with track management, plus a full dashboard UI redesign with improved sensor fusion.

Lap Timer Features

  • Line crossing detection using GPS lat/lon coordinates with sub-sample precision
  • Track Manager with IndexedDB persistence for saved tracks and sessions
  • Point-to-Point mode for drag strips and hillclimbs
  • Track auto-detection recognizes saved tracks when approaching start line
  • Session history tracks all laps with best lap highlighting
  • Direction validation prevents false triggers from opposite-direction crossings

Dashboard Redesign

  • Apple-style neumorphism UI with maximum whitespace and clean typography
  • Redesigned G-meter with mathematically correct 1-point perspective
  • Unified 2-color system with smooth transitions
  • GPS status indicator shows fix quality in real-time
  • IndexedDB chunked recording for multi-hour sessions

Sensor Fusion Improvements

  • GPS-corrected orientation (ArduPilot-style AHRS error compensation)
  • Centripetal lateral G calculation - mount-independent, no drift
  • Biquad vibration filter for cleaner accelerometer data
  • OrientationCorrector diagnostics with color-coded thresholds
  • 50% reduction in mode detection latency

EKF Fixes

  • Fixed GPS velocity direction bug causing speedometer issues
  • Fixed position divergence and slow recovery after stops
  • Fixed GPS data corruption causing position explosion
  • Added innovation gating for heading alignment
  • Expanded app partition from 1.5MB to 2.5MB

Documentation

  • New docs/LAP_TIMER.md - comprehensive lap timer guide
  • New docs/SENSOR_FUSION.md - detailed fusion architecture
  • Condensed CLAUDE.md from 1054 to 389 lines
  • Restructured README with TOC and better organization

Stats

  • 100 commits since main
  • +16,144 / -3,691 lines across 30 files
  • New crate: framework/src/lap_timer.rs (968 lines)

Test Plan

  • Build and flash: cargo build --release && cargo espflash flash --release
  • Verify GPS lock (cyan LED pulse)
  • Test lap timer: create track, complete laps, verify timing
  • Test track auto-detection on approach
  • Verify G-meter responsiveness and centering
  • Check session history persistence across refreshes
  • Run tests: `cargo test -p sensor-fusion -p wt901 -p ublox-gps

jctoledo and others added 30 commits January 17, 2026 11:56
…-meter

Major UI redesign with:
- Golden ratio (φ) three-box vertical layout
- Top box: Speed-focused display with dominant readout, conditional peak,
  background mode symbol, and maneuver state
- Middle box: Perspective-warped grid G-meter with auto-zoom camera system
  (rolling peak window, asymmetric smoothing, deadband)
- Bottom box: Compact metrics, GPS status, recording controls
- Snake-trail indicator on G-meter with fading cells
- Two-color system: cyan (accelerating) / amber (braking)
- Peak speed tracking with 3-second delay rule

Includes dashboard-dev tool for local testing without hardware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Without GPS_MODEL="m9n" and GPS_RATE="25", firmware defaults to
NEO-6M at 9600 baud, causing GPS to show 0 Hz / No Fix on M9N hardware.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add time-based EMA (TAU=100ms) for G-meter dot smoothing
- Add dead zone formatting to prevent 0.00/-0.00 flicker
- Replace GPS coordinates box with compact header indicator
- GPS shows Hz when locked, '--' when no fix
- Apply changes to all three dashboard files
- Revert ModeSettings defaults to match main branch
  (brake_thr: -0.18, lat_thr: 0.12, yaw_thr: 0.05)
- Remove sim-panel from dashboard-dev (auto-drive only)
- Fix JS error from removed sim-hz element reference
- Add CLAUDE-design.md with comprehensive design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add Biquad IIR low-pass filter (2Hz cutoff) for vibration removal
- Add GPS-derived longitudinal acceleration from velocity changes
- Add configurable GPS/IMU blending (70/50/30% based on GPS rate)
- Add tilt estimation for mounting angle correction when stationary
- Add gravity estimation for continuous correction during steady driving
- Add hybrid mode detection using blended lon accel and filtered lat accel
- Update diagnostics with gps_fix_count() for fusion rate tracking
- Display corrected G-meter values instead of raw IMU data
- Tilt learning now visible on dashboard (G-meter returns to zero)
- Fix clippy warnings (doc comments, unused methods)
- Dashboard now shows blended longitudinal acceleration (same as mode classifier)
- Add unit tests verifying display values match classification inputs
- Fix CLAUDE.md blending ratios (70/50/30 not 90/70/30)
- Update README.md with noise cleanup features
- Add data flow diagram showing dashboard receives same values as mode.rs

Co-Authored-By: Claude <noreply@anthropic.com>
Root cause: Filtering lateral acceleration in earth frame before
transforming to vehicle frame caused the filter to smooth a rotating
vector. When the vehicle yaws, the filtered earth-frame values
get transformed with the new heading, causing lateral G to "stick"
at incorrect values until the vehicle stops.

Changes:
- Remove Biquad filter for lateral in earth frame entirely
- Use centripetal formula (speed * yaw_rate) for mode detection
  This is what pro racing systems (VBOX, Motec) use - mount-independent
- Filter longitudinal in vehicle frame (safe, no rotation issues)
- Increase filter cutoff from 2Hz to 5Hz for faster response
- Rename config fields: imu_sample_rate -> sample_rate,
  accel_filter_cutoff -> lon_filter_cutoff

Display continues to show accelerometer-based lateral with tilt/gravity
correction. Mode detection uses centripetal lateral which responds
immediately when yaw rate changes.

Added smart unit tests that simulate turn scenarios and verify
lateral returns to zero immediately when turn ends.
Added rule to CLAUDE.md: dead code must be removed or used, not silenced.

Fixed fusion.rs: removed get_lat_centripetal() getter, updated test to
use return value from process_imu() instead.
- README: Update filter description from 2Hz to 5Hz
- mode.rs: Fix update_hybrid docs - receives centripetal lateral, not filtered
- filter.rs: Add test for actual 5Hz config used in fusion.rs
PROBLEM: Mode detection had ~300ms latency due to double filtering
- Biquad (5Hz) in fusion.rs: ~70ms delay
- EMA (α=0.35) in mode.rs: ~116ms time constant
- Combined: 90% response in ~250-300ms

SOLUTION:
1. Remove Biquad filter for longitudinal acceleration
   - GPS blend already provides smooth signal (no vibration)
   - 70% GPS / 30% IMU means 70% of signal is already clean
   - mode.rs EMA handles residual noise

2. Increase EMA α from 0.35 to 0.50
   - Time constant reduced from 116ms to 72ms
   - 90% response: 165ms (was 267ms)

RESULT:
- Estimated 90% response: ~120-150ms (was ~300ms)
- Mode detection should feel significantly more responsive
- Corner detection unchanged (uses centripetal, already instant)

OTHER CHANGES:
- filter.rs moved to #[cfg(test)] - only compiled for tests
- Removed unused FusionConfig fields (lon_filter_cutoff, sample_rate)
- Updated README to reflect new architecture
When user changed mode thresholds via web UI, alpha was hardcoded to 0.25
instead of using the optimized 0.50. This would undo the latency improvement
after any settings change.

Also fixed:
- Outdated comment mentioning removed Biquad filter
- Misleading comment about lateral (display uses accelerometer, mode uses centripetal)
The bug where settings handler used alpha=0.25 while default used 0.50
was caused by the same value being hardcoded in two places.

Fix:
- Add DEFAULT_MODE_ALPHA constant in mode.rs
- Use constant in both ModeConfig::default() and main.rs settings handler
- Add unit test to verify default config uses the constant

This ensures future changes to alpha only need to update one place.
…ting

Moved mode.rs, fusion.rs, and filter.rs from the embedded blackbox crate
to the sensor-fusion framework crate. This enables running 52 unit tests
on the host machine without requiring ESP32 hardware.

Changes:
- framework/src/mode.rs: Mode classifier with 10 tests
- framework/src/fusion.rs: GPS/IMU blending with 9 tests
- framework/src/filter.rs: Biquad filter with 6 tests (unused but verified)
- sensors/blackbox/src/*.rs: Thin re-exports from framework
- README.md: Updated file structure, added test command

Tests now runnable with: cargo test -p sensor-fusion -p wt901 -p ublox-gps
- Add 5Hz Butterworth low-pass filter to IMU longitudinal acceleration
  to remove engine vibration (20-100Hz) while preserving driving dynamics
- Based on ArduPilot research: accelerometer outer loop uses 10Hz filter
- Add YawRateCalibrator to learn gyro bias during straight-line driving
- Switch lateral G to centripetal calculation (speed × yaw_rate)
  for mount-independent, instant response without sticking
- Add tests for vibration attenuation and dynamics passthrough
- Update CLAUDE.md with filter theory and data flow diagrams
Critical bug fix:
- lon_sample_rate was 20Hz but filter runs at 200Hz (IMU rate)
- This made filter ~10x more aggressive than intended, killing real dynamics

Filter improvements:
- Increase cutoff from 5Hz to 15Hz (ArduPilot uses 20Hz)
- Sharp braking events (up to 10Hz) now preserved

GPS blending improvements:
- Reduce GPS weight from 70% to 40% (trust filtered IMU more)
- Add GPS accel validity check: when GPS=0 but IMU has signal, use 100% IMU
- Use lon_blended for display (was GPS-only, often showing 0)

Analysis showed:
- 57% of samples had lon_g=0 due to GPS-only display
- Mode detection was 38% accurate for ACCEL, 43% for BRAKE
- 604 samples with |GT|>0.15g but reported 0

New tests:
- test_default_config_has_correct_filter_settings
- test_lon_display_equals_lon_blended
- test_sharp_braking_preserved_with_15hz_filter
- test_gps_accel_validity_check
The GravityEstimator tried to learn gravity offset during "steady state"
driving (constant speed, low yaw). This was fundamentally flawed because:
- Aerodynamic drag, rolling resistance, and road grade create real accelerations
- These were incorrectly learned as "gravity offsets"
- Result: -2.6 m/s² constant offset causing 76% false BRAKE detections

Changes:
- Remove GravityEstimator struct and all references
- Remove gravity fields from FusionDiagnostics and dashboard
- Add 3 new unit tests to catch similar regressions:
  - test_cruising_at_constant_speed_produces_zero_lon
  - test_tilt_only_updates_when_stationary
  - test_no_drift_during_extended_cruise
- Update CLAUDE.md documentation
- Fix stale comment (5Hz -> 15Hz filter)

The TiltEstimator (learns only when stationary) is sufficient for
mounting offset correction. This matches ArduPilot's approach: only
learn corrections when you KNOW the truth (stationary = zero velocity).
The WT901 AHRS cannot distinguish linear acceleration from tilt - during
forward acceleration it reports false pitch, corrupting gravity removal.

Solution: OrientationCorrector learns pitch/roll errors by comparing
IMU-derived acceleration with GPS-derived acceleration (ground truth).
The innovation reveals orientation error: pitch_error ≈ (ax_imu - ax_gps) / G

Key changes:
- Add OrientationCorrector to fusion.rs (learns pitch/roll from GPS comparison)
- New process_imu() API takes raw body-frame IMU + AHRS angles
- Confidence-based GPS/IMU blending (80% GPS initially → 30% when learned)
- Add drive_sim.rs example demonstrating AHRS error correction
- Update diagnostics with orientation correction fields
- Update CLAUDE.md, README.md, docs/index.html

Architecture: This is NOT a 9D EKF - OrientationCorrector runs separately
from the 7-state EKF as a modular correction layer. This approach is simpler,
testable, and appropriate for embedded constraints.

Benefits:
- Device can be mounted at any angle within ±15° (auto-corrected)
- Enables accurate 200 Hz IMU instead of being limited to 25 Hz GPS
- Fixes false mode detections caused by AHRS pitch error during acceleration
- Add Open Graph and Twitter Card meta tags for social sharing
- Add JSON-LD structured data for search engines
- Add "How It Works" section with:
  - Explanation of all 7 EKF states (x, y, psi, vx, vy, bax, bay)
  - Glossary defining: EKF, IMU, GPS, AHRS, ZUPT, Sensor Fusion, G-Force, WiFi AP
- Update feature cards with plain-language descriptions
- Add tooltips to hero stats
- Update navigation with How It Works link

Makes technical content accessible to anyone without prior knowledge.
- Add analyze_telemetry.py: analyzes CSV exports from dashboard
  - Computes timing, speed, acceleration statistics
  - Compares lon_g with GPS-derived acceleration (ground truth)
  - Calculates mode detection precision/recall
  - Reports correlation coefficient and bias detection
  - Provides diagnostic summary of potential issues

- Document drive_sim example in build commands
- Document analyze_telemetry.py in Python tools section
- Move plain English project description to the very top (before TOC)
- Add table of contents with 30+ links for easy navigation
- Add dedicated 'How It Works' section explaining sensor fusion in plain terms
- Consolidate 'Why build this' into intro section
- Document transforms, AHRS correction, and motion models for non-experts
Display GPS-corrected orientation metrics on diagnostics page:
- Pitch Correction (degrees) - learned AHRS pitch error
- Roll Correction (degrees) - learned AHRS roll error
- Orient Conf (pitch% / roll%) - confidence in corrections

Color coding:
- Green: correction < 10deg, confidence > 50%
- Yellow: moderate values
- Red: confidence < 10% (still learning)

These metrics help validate that GPS-corrected orientation is working
during test drives.
Color coding (green/yellow/red) added for:
- Loop Rate: >500 ok, >200 warn, <200 err
- Position σ: <5m ok, <10m warn, >10m err
- Velocity σ: <0.5 ok, <1.0 warn, >1.0 err
- Yaw σ: <5° ok, <10° warn, >10° err
- Bias X/Y: <0.3 ok, <0.5 warn, >0.5 err (absolute value)
- Heap: >40KB ok, >20KB warn, <20KB err
- Yaw Bias: <10 mrad/s ok, <50 warn, >50 err
- Tilt X/Y: <0.3 ok, <0.5 warn, >0.5 err (max of both)

Makes it easy to see at a glance if values are within spec.
- Fix Butterworth filter sample rate mismatch (200Hz -> 30Hz)
  The filter was misconfigured for 200Hz but called at ~30Hz telemetry rate,
  causing effective cutoff of ~1.5Hz instead of 10Hz and removing valid signals

- Add cruise bias learning to OrientationCorrector
  Learns mounting offset during constant-speed driving when GPS shows ~0 accel
  New fields: cruise_bias, cruise_bias_sum, cruise_bias_count, cruise_time

- Lower min_accel threshold from 0.5 to 0.3 m/s² for more learning opportunities

- Add dt parameter to OrientationCorrector::update() for cruise timing

- Add new tests for cruise bias learning behavior

- Update filter tests to use correct 30Hz sample rate

- Expand CSV export with fusion diagnostics columns

- Enhance analyze_telemetry.py with fusion diagnostics analysis
City preset changes:
- acc: unchanged at 0.10g (cruise bias learning handles offset)
- brake: 0.18 -> 0.25g (less aggressive, reduce false braking)
- lat: 0.12 -> 0.10g (more sensitive corner detection)
- yaw: 0.05 -> 0.04 rad/s (more sensitive corner detection)

Also updates:
- mode.rs default ModeConfig to match new city preset
- Slider default values in UI to match
- Summary display defaults
- Test values for combined brake+corner
- Fix critical bug: process_gps() now called with scalar speed on every
  new fix, not just when velocity_enu available (enables OrientationCorrector)
- Fix lon_sample_rate: 20Hz → 30Hz to match TelemetryConfig default (33ms)
- Fix gps_max_age: 0.2s → 0.4s for 25Hz GPS with processing margin
- Lower brake thresholds across all presets to account for mounting bias
- Sync ModeSettings::default() and HTML slider defaults with mode.rs
- Update CLAUDE.md preset documentation
- Enhance docs/index.html JSON-LD metadata for LLM discovery
jctoledo and others added 7 commits January 27, 2026 19:49
Root Cause Fix (main.rs):
- Position updates now happen BEFORE stationary check
- Previously, when stationary: ZUPT and lock_position ran but NO position update
- This caused EKF to diverge from GPS during extended stops
- Now position is always updated when GPS is valid (with 1000m sanity check)

GPS-Based Loop Detection (websocket_server.rs):
- Loop closure now uses GPS lat/lon instead of EKF position
- GPS is ground truth; EKF position can diverge
- Added _gpsDistanceMeters() for haversine-style distance calc
- Track gpsDistance separately from EKF-based totalDistance
- This ensures loops are detected even if EKF has drifted

Cleanup (ekf.rs):
- Removed recovery mechanism (consecutive_pos_rejections counter)
- With root cause fixed, recovery mechanism is unnecessary
- ArduPilot/PX4 use similar recovery tied to specific events, but
  preventing divergence in the first place is the correct approach

Testing coverage:
- test_position_gating_accepts_after_covariance_grows verifies
  that position updates succeed when covariance is high
- The orchestration fix (when to call update_position) is
  integration-level and tested via real-world driving

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Problem: After clicking "Use" on a saved track, the lap timer UI didn't
appear. User saw track name in setup text but no timer, no Armed state,
and no start line indicator showing distance/direction to start.

Root cause: activateTrack() relied on updateLapTimer() to show the active
UI, but updateLapTimer() only activated when firmware sent crossing data.
Since firmware sends zeros when "armed but not crossed", the UI stayed in
inactive state.

Fix (matching dashboard-dev behavior):
- activateTrack() now immediately sets lapTimerActive=true
- Removes 'inactive' class and adds 'active' class to lap-section
- Initializes timer display to Armed state (0:00.000, "Armed", etc.)
- This enables the start line indicator to show distance/direction

- deactivateTrack() now properly resets lapTimerActive=false and UI state

- updateLapTimer() no longer auto-deactivates based on firmware zeros
  (deactivation only happens via explicit deactivateTrack() call)

UX flow now works correctly:
1. Click "Use" → Lap card expands, shows "Armed", timer at 0:00.000
2. Start line indicator shows "Start line: XXm ↑" with distance/direction
3. As user approaches: "Approaching start" → "Almost there" → "Cross to begin!"
4. User crosses start line → Timer starts, indicator hides
5. Click stop → deactivateTrack() resets everything

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Document the complete user experience flows that were previously missing:
- Timer state machine (IDLE → ARMED → TIMING)
- Track activation flow with distance-based start line indicator
- Start/finish line indicator behavior and arrow directions
- Track recording flow including loop detection
- P2P warmup requirement for newly recorded tracks
- Track deactivation
- UI quick reference tables

Expand troubleshooting section with new scenarios for common UX issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Root cause: Track coordinates were stored using EKF x,y (firmware's
session-specific GPS origin) but tagged with jsGpsRef as gpsOrigin.
This caused ~300m offset between recorded tracks and current position.

Architectural fix:
- TrackRecorder now computes x,y from GPS lat/lon using gpsRef
- currentPos now computed from GPS lat/lon using jsGpsRef
- Both track coords and current position use same reference frame
- Removed post-hoc coordinate conversion (no longer needed)

Also includes:
- Fix bearingToArrow() sign error for arrow direction
- Fix direction_valid() to use math convention (0=East, CCW positive)
- Add _testGpsCoordinates() console self-test
- Add _testBearingToArrow() console self-test
- Document coordinate conventions in CLAUDE.md and lap_timer.rs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This eliminates cross-session coordinate frame mismatch that caused
lap timer to never start when using saved tracks from different sessions.

Changes:
- TimingLine now stores GPS lat/lon (f64) instead of local x,y (f32)
- LapTimer::update() takes GPS lat/lon directly from GPS parser
- Dashboard converts track's local coords to GPS using track's gpsOrigin
- Firmware API accepts p1_lat/p1_lon/p2_lat/p2_lon parameters
- Removed deprecated timing_line_from_path function
- Added GPS ref lat/lon to diagnostics for visibility

The key insight: timing lines now carry absolute GPS coordinates that
work regardless of firmware session origin. The to_local() conversion
uses the timing line's p1 as reference, ensuring consistent intersection
detection across sessions.
Lap Timer Fixes:
- Fix Best/Last/Delta not populating on lap completion
- Fix delta bar width (84% → 100%)
- Fix GPS accuracy showing actual sigma instead of hardcoded '2'
- Relax GPS quality thresholds for realistic ratings
- Add mode labels: EXIT (corner exit), TRAIL (trail braking)

G-Meter Redesign:
- Flip axes for driver's perspective (brake=up, accel=down)
- Replace crosshair with 3D ball indicator with dynamic glow
- Enhanced trail: 6s duration, thicker lines, glow on fresh segments
- Refined grid with dashed rings and subtle center dot
- Increased EMA smoothing for road jitter filtering

UI/UX Polish:
- Add card elevation shadows for depth
- Larger, bolder speed display with peak glow
- Responsive layout for S22/S24+/iPhone screens
- Media queries for compact screens when timing active

Dashboard-dev:
- Sync all production changes
- Add road jitter simulation for testing
Lap Timer Fixes:
- Add fallback lap detection via lapCnt increase (fixes missed NEW_LAP flags)
- Fix flash animation not restarting (force reflow before re-adding class)
- Fix delta bar width not filling container (add width:100%)
- Fix delta/last/flash not triggering on subsequent laps

G-Meter Fixes:
- Reduce EMA_TAU 0.15→0.07 for snappier response at 14-16Hz polling
- Fix off-center when timing (use explicit width/height vs max-width)
- Add tighter constraints for S22 screens (28vh when timing + max-height:700px)

Root cause: Firmware NEW_LAP flag only lasts ~40ms but dashboard polls
at 60-70ms, causing ~40% miss rate. Now detects completion via lapCnt
increment as fallback.
G-meter fixes:
- Add margin:0 auto to center frame when timing
- Call resize() after CSS transition (300ms delay) when entering/exiting timing
- Fixes rightward shift on smaller screens (S22)

Dashboard-dev sync:
- EMA_TAU 0.15→0.07 (snappier g-meter response)
- Flash animation reflow fix (void offsetWidth)
- G-meter width/height instead of max-width/max-height
Resolved all conflicts by keeping lap-timer branch versions.
The lap-timer branch contains all re-design features plus:
- Complete lap timer with line crossing detection
- Track manager with IndexedDB persistence
- GPS lat/lon based lap timing architecture
- EKF fixes and partition expansion
- Dashboard UI improvements
@jctoledo jctoledo marked this pull request as ready for review February 3, 2026 04:32
@jctoledo jctoledo merged commit f02fcf0 into main Feb 3, 2026
5 checks passed
@jctoledo jctoledo deleted the lap-timer branch February 3, 2026 04:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants