Disclosure: I'm the developer of ScooterLog, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the app's source code. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.
ScooterLog keeps a trip log for connected electric scooters, built from the odometer, battery and position readings that the scooter's cloud reports. It has no ride events to work from: no "ride started" and no "ride ended". It only gets readings, now and then, and during a ride there are often none at all.
This post is about the ~85-line Python function that turns those readings into trips, and about the part that took the longest: deciding when a trip's time cannot be trusted, even though its distance can.
The input
Each reading is a tuple, oldest first:
# (ms, soc, odo, state, lat, lng): illustrative values (1762412400000, 81.0, 4120.0, 'NOT_CHARGING', 28.61, 77.21) Any field but the time may be missing. The odometer is in whole kilometres, so it can tick late. The position can repeat the same stale fix for a whole ride.
Rule 1: a ride is a chain of steps in which something moved
Compare each reading with the one before it. A step counts as movement if the odometer rose by at least 0.5 km or the position moved at least 300 m, and the scooter is not charging. Consecutive moving steps with no more than 20 minutes between them form one trip.
TRIP_GAP_MS = 20 * 60 * 1000 # parked this long (or no readings) ends a trip CHARGING_STATES = ('SLOW_CHARGING', 'FAST_CHARGING') for prev, r in zip(rows, rows[1:]): dodo = (r[2] - prev[2]) if r[2] is not None and prev[2] is not None else 0 dgps = _gps_m((prev[4], prev[5]), (r[4], r[5])) or 0 charged = r[1] is not None and prev[1] is not None and r[1] - prev[1] >= 2 if charged: # the battery rose: a charge sits between these readings, never a ride cur = None continue moved = (dodo >= 0.5 or dgps >= 300) and r[3] not in CHARGING_STATES if not moved: continue if cur and prev[0] - cur['end_ms'] <= TRIP_GAP_MS: cur['steps'].append((prev, r, dodo, dgps)) cur['end_ms'] = r[0] else: cur = dict(start_ms=prev[0], end_ms=r[0], steps=[(prev, r, dodo, dgps)]) trips.append(cur) Two guards matter more than they look:
- Either signal counts. The odometer is whole kilometres, so a short ride may show 0 km. The position may be stale. Requiring both would miss real rides.
- A battery rise of 2% or more breaks the chain. If the percentage went up between two readings, the scooter was charging in between, even if no reading said so. That gap is never part of a ride.
_gps_m is the usual haversine distance. It returns None when either point is missing or is a 0,0 placeholder, so a missing fix never looks like a 5,000 km jump.
Rule 2: distance from the odometer, unless it is too coarse
odo_km = sum(st[2] for st in t['steps'] if st[2] > 0) gps_km = sum(st[3] for st in t['steps']) / 1000.0 km = round(odo_km if odo_km >= 2 else max(odo_km, gps_km), 1) if km <= 0.2 or km > 400: continue For anything over 2 km the odometer wins, because it is what the scooter measured. Under 2 km, one whole-kilometre tick is too coarse, so the larger of the two is used. Trips under 0.2 km (GPS jitter while parked) and over 400 km (a corrupt reading) are dropped.
Rule 3: the time window is only as exact as the readings around it
Here is the catch. The trip window runs from the last reading before the movement to the first reading after it. If the scooter reported nothing for 40 minutes and the ride took 10, the window is 40 minutes long, and "average speed" would be a fiction.
So a trip is timed only if every step passes three checks:
MAX_KMH = 70 # a ride implying more than this was not timed by the readings STEP_MS = 15 * 60 * 1000 # a longer gap between two readings hides when the ride happened steps_ok = all( 0 < st[1][0] - st[0][0] <= STEP_MS and # short step max(st[2], st[3] / 1000.0) / ((st[1][0] - st[0][0]) / 3600000.0) <= MAX_KMH and # plausible speed (st[2] <= 0 or st[3] >= 300 * st[2]) # position confirms odometer for st in t['steps']) approx = dur_h <= 0 or km / dur_h > MAX_KMH or not steps_ok or 'late_ms' in t - Short steps. A gap over 15 minutes hides when within it the ride happened.
- Plausible speed. A step that implies more than 70 km/h means the window is too short. The movement happened partly outside it.
- The position confirms the odometer. Each odometer kilometre needs at least 300 m of position change in the same step. If the odometer ticked but the position didn't move, the tick is probably catching up on an earlier ride.
A trip that fails any check is flagged approx: its distance and battery use are shown, and no average or top speed is claimed. If even the widest window implies an impossible speed, the trip is also marked time_unknown and no duration is shown.
Rule 4: the late report
One pattern came up again and again. The scooter's cloud often reports a ride late: the first reading after the ride repeats the pre-ride values (same %, odometer and position), and the new distance lands seconds later. The result is a step of several kilometres in a few seconds.
When the first step is impossibly fast, the function walks backwards over readings that are identical to the step's start. It goes back to the last one that genuinely changed, without crossing the previous trip or a charge, and starts the window there:
a, b0, dodo, dgps = t['steps'][0] dt_h = (b0[0] - a[0]) / 3600000.0 if dt_h <= 0 or max(dodo, dgps / 1000.0) / dt_h > MAX_KMH: i = idx[a[0]] while (i > 0 and rows[i - 1][0] > prev_end and rows[i - 1][3] not in CHARGING_STATES and (rows[i - 1][1], rows[i - 1][2], rows[i - 1][4], rows[i - 1][5]) == (a[1], a[2], a[4], a[5])): i -= 1 if rows[i][0] < t['start_ms']: t['late_ms'], t['start_ms'] = t['start_ms'], rows[i][0] The widened window is honest about when the ride could have happened. But it was reconstructed, so the trip stays approx, and the moment the report arrived is kept as reported_ms for the trip card.
Rule 5: say when the map can't show the road
A trip card can draw the route from the position fixes. But if the position barely updated during the ride, a line between three points is not "the road taken". So:
def gps_stale(km, fixes): """Fewer distinct GPS fixes than one per 3 km (and never fewer than 3).""" return km >= 2 and fixes < max(3, int(-(-km // 3))) Distinct fixes are counted after rounding to 5 decimal places, so a stale position repeated 20 times counts once. When gps_stale is true, the card says the position didn't update often enough, instead of drawing a confident straight line.
What a trip looks like at the end (illustrative values)
dict(start_ms=..., end_ms=..., km=6.4, start_soc=81.0, end_soc=76.0, pct_used=5.0, approx=False, avg_kmh=27.3, max_kmh=41.0, km_per_pct=1.28, readings=9, gps_fixes=7, gps_stale=False) km_per_pct is only set when at least 1% was used, because battery percent moves in steps and 0.4 km per 0% means nothing.
Takeaways
- When the source sends no events, define a ride as a chain of moving steps and accept either signal as movement.
- Let a contrary signal, such as a battery rise, break the chain.
- Distance and time have different error bars. Show the distance you can trust, and claim a speed only when every step supports it.
- Model the source's known quirks, like late reports, explicitly. Then keep the result flagged as reconstructed.
- Count distinct observations, not rows, before drawing anything on a map.
ScooterLog is on Google Play. It only knows what the scooter's cloud reports, and readings can lag by a few minutes. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.
AI Disclosure: this article was drafted and published by an AI agent on the developer's behalf, from the app's source code.


