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 polls a connected electric scooter every few minutes and keeps the readings: battery percent, odometer, charging state. The number riders ask for most is "how far will a full charge actually take me?". This post is how that number is computed from the log, and the small decisions that stop it from being quietly wrong.
The raw material
Each stored reading is (source_ms, battery_soc, odometer, charging_state). The scooter's own timestamp is the primary key, so INSERT OR IGNORE makes polling idempotent: asking twice for the same reading writes one row.
A "cycle" is the ride between two charges: from the first reading after the charger is unplugged to the last reading before the next charge starts (or the newest reading, if no new charge has begun).
km = max(0.0, b.odometer - a.odometer) pct = max(0.0, a.soc - b.soc) if km >= 1 and pct >= 1: km_per_pct = km / pct full_range = round(km_per_pct * 100) Rule 1: do not divide tiny numbers
One kilometre that cost zero or one percent is noise: battery percent updates in steps. So a cycle only produces a ratio when it covers at least 1 km and used at least 1 %. Everything else still shows as a trip, but it does not feed the range figure.
Rule 2: sum first, divide once
With several cycles, the tempting average is the mean of each cycle's km/%. A 3 km errand that happened to drop 1 % reads as 3 km/%, and it counts as much as a 60 km day that dropped 30 % (2 km/%). The mean of ratios is dragged around by the short cycles.
The fix is to weight by distance: total kilometres divided by total percent.
cys = [s["cycle"] for s in sessions if s.get("cycle") and s["cycle"]["km"] >= 1 and s["cycle"]["pct_dropped"] >= 1] km = sum(x["km"] for x in cys) pct = sum(x["pct_dropped"] for x in cys) km_per_pct = km / pct Long rides dominate, which is what you want from a range estimate.
Rule 3: flag holes instead of hiding them
Polling fails. The phone is off, the server restarts, the scooter goes to sleep. If two readings are more than 40 minutes apart, the session between them is marked partial and the interface says so, rather than quietly presenting a duration or an "added percent" it cannot support. The same idea applies to trips: a ride seen only across a long gap is kept but flagged approx, and no average speed is claimed for it, because the time window is only a bracket.
Rule 4: say what the forecast reserves
The "is today's charge enough?" answer uses the current percent minus a fixed reserve (10 %), times the km-per-percent figure. Keeping the reserve as a named constant in the response means the number can be explained rather than argued with.
What this does not do
It only knows what the scooter reports, and readings can lag by a few minutes. It is not a theft or emergency tool and it does not control the scooter. It is a notebook.
If you build something similar for another vehicle, the transferable parts are the dedupe key, the minimum-signal thresholds, sum-then-divide, and explicit "partial" flags. The app is on Google Play if you want to see the output.