Finding Zero on a Motorcycle Lean Angle App
Tags:#ios#motorcyclesI wanted to know how far I lean the bike in corners. There are apps for this, and dedicated data loggers, but I already have an iPhone on a Quad Lock mount on the handlebars, with a gyroscope and accelerometer in it. So I built Lean With It, a native Swift app that records the phone’s motion sensors at 50 Hz and GPS once a second while I ride, with the screen locked.
Recording turned out to be the easy part. The hard part is knowing what zero is.
The phone doesn’t know which way is up
Lean angle is the bike’s tilt from vertical. The phone can measure its own tilt against gravity very precisely, but the phone isn’t the bike. It sits on the mount at whatever angle it ended up at, and nothing tells it how that angle relates to the bike standing straight up.
My first version handled that the obvious way: a “Calibrate & Start Ride” button. Hold the bike upright, tap the button, and the app saves the phone’s attitude as the reference. Lean is then the rotation from that reference around the bike’s forward axis.
The first real ride went 71 minutes and 32.8 miles. The recording was perfect: 214,548 motion samples at 50 Hz, no gaps over 200 ms, screen locked the whole time. The lean numbers were garbage. They drifted toward ±180°, which would mean riding upside down.
Two things were wrong. The estimator took the twist out of the whole attitude change since calibration, and once the bike changed compass heading, that change included yaw. Every turn at an intersection leaked into the lean. And I’d started the ride on the side stand, so “upright” was really about 24° over.
Because the app writes raw sensor data to disk and computes summaries from it afterward, none of that was lost. I could fix the math and run it again on the same ride.
Bank against gravity
The replacement measures bank against gravity, around the bike’s forward axis as it is right now. Turning to a new heading doesn’t change it:
let worldUp = Vector3(x: 0, y: 0, z: 1)
let referenceUp = QuaternionMath.rotate(
worldUp,
by: QuaternionMath.inverse(calibration.referenceAttitude)
)
let forward = projectedUnit(
calibration.orientation.forwardAxis,
perpendicularTo: referenceUp
)
let right = unit(cross(forward, referenceUp))
let mountedUp = unit(cross(right, forward))
let currentRight = QuaternionMath.rotate(right, by: attitude)
let currentUp = QuaternionMath.rotate(mountedUp, by: attitude)
let radians = atan2(-dot(worldUp, currentRight), dot(worldUp, currentUp))
The mount reference is still captured when the ride starts, but only to find the bike’s forward axis in the phone’s frame. It no longer defines upright.
Learning zero while riding
Upright is now learned from the ride. The app watches GPS for straight riding: at least 8 m/s (about 18 mph), good horizontal and course accuracy, and less than 1° of course change per second. Motion samples during those stretches are candidates for “upright,” and the riding zero is their median. That means a ride can start with the bike on its side stand, and the start screen says so:

For this post I ported that estimator to Python and ran it on the first ride again. It came out to a riding zero of 23.573490138463896°, the same number the phone stored, to every digit. Here’s that ride before and after:

Parked on the side stand, the phone reads 0°, because that’s where it was calibrated. Riding upright, it reads about 23.6°. Subtract that and the ride centers on zero, with peaks of 39° left and 42° right. The flat line near the end is the bike parked on the stand again. That part gets left out of the peaks, because the app ignores motion when the bike isn’t moving.
The second fix
The first version of automatic zero was too eager. On the next ride it locked in after the first 100 motion samples, about two seconds into the first straight stretch, and picked 3.72°. The full ride, re-analyzed afterward, said 0.88°. The live gauge would have been off by 2.84° for the whole ride.
Version two waits for real evidence before it commits. It needs at least 500 candidate samples from at least 8 separate straight intervals spanning at least 8 seconds, and the candidates have to agree: a median absolute deviation of 2.5° or less and an interquartile range of 5° or less. Until then the gauge says it’s still finding riding zero.
On the next ride it accepted 2.32° after 39 seconds, within 0.22° of the full-ride answer. The two rides after that came in at 0.52° and 1.04°. Once it accepts a zero live, it saves it, and the ride summary reuses that number instead of quietly switching to a different one afterward.

What I don’t know yet
Every ride gets a written analysis in the repo, and each one is careful about what it doesn’t prove. I don’t have a reference measurement, so absolute accuracy is unknown. There’s no vibration filtering yet. On the second ride, high-frequency acceleration went from about 0.04 g standing still to 0.29 g riding, and the app can’t yet tell engine and road vibration apart from real motion. Road camber and hills count as “straight” to GPS. On that same ride, left versus right agreed with GPS course changes 85% of the time, and 93% when each GPS course change was paired with motion from half a second earlier, since GPS course updates lag the start of a turn. That’s a good sign, but I still need to check it against turns I remember.
So far that’s four road rides, one of them recorded in two legs. It’s Swift 6 and SwiftUI with Core Motion and Core Location, targeting my iPhone 17 on iOS 26, and for now it only runs on my phone.