Predicting parking
Every parking app in Singapore shows how many lots are free now. After a thirty-minute drive that number is fiction. This one predicts whether a lot will be there when you arrive — and stays quiet about the carparks it can't predict well enough to be worth trusting. Live on Telegram as @sgcarparkpredict_bot.
The bot is live. Send it a place name or drop a location pin, and it answers with the carparks nearby and whether each one is likely to have a lot when you get there.
Singapore publishes carpark availability as open data, and several apps render it faithfully: here are the lots free right now. The trouble is that nobody parks right now. You look before leaving, drive for twenty to forty minutes, and arrive into a completely different distribution — one that the app never showed you, because by then it is showing a fresh number to somebody else.
So the product question isn't "how many lots are there?" but "will there be one when I arrive?", which is a forecasting problem with a badly asymmetric cost function. That asymmetry ended up shaping almost every decision in the system.
Three processes, one database. The logger never stops, the calibrator runs on a schedule, and the bot only ever reads.
The whole thing runs in Docker on one small VM, sharing a Postgres
instance with another project of mine rather than paying for a second
one. Deploys go out through GitHub Actions on a push to a prod branch, with migrations run as a one-shot container
before either service starts — a lesson from a release that added a
column the bot read and the logger created, where whichever container
won the race decided whether the bot crashed.
A wrong "no" costs the user one tap: they scroll to the next carpark. A wrong "yes" sends them across the island to a full carpark, and that is the error that makes someone delete the thing. The two mistakes are not worth the same, so the model does not treat them the same.
The prediction is a time-of-week profile blended with today's deviation from that profile, decaying as the horizon lengthens — today's information is worth a lot at fifteen minutes and very little at an hour. The expected number of lots is computed from the profile's median, but the banding — the green/amber/red call the user actually reads — runs off the lower quartile instead. The headline number is the honest estimate; the traffic light is deliberately pessimistic.
That wasn't enough on its own. The static threshold for calling something "likely" was a flat rule — twenty lots, or eight percent of capacity. On one 51-lot carpark in the backtest the model's own mean absolute error was about thirteen lots, which sits under that threshold: it produced a confident yes that was wrong four times in five. Now every carpark carries a threshold calibrated on held-out data, defined as the smallest value that would have produced no dangerous yes-calls on the validation week.
Carparks behave differently enough that one model is the wrong shape. A stable office carpark repeats its weekly pattern almost exactly, and the profile alone wins. Others swing week to week, and there simple persistence — "what it is right now is what it'll be" — beats any profile at short horizons. So three candidates compete per carpark per fold, chosen by error on a validation week that sits inside the training window and is never test data.
The more useful decision was admitting that some carparks lose anyway. Each one is backtested walk-forward and has to clear a bar — at least 85% band accuracy with fewer than 5% dangerous calls, across the fifteen- to sixty-minute horizons — before it is allowed to answer. One that fails is switched off at serving time and returns "not enough signal" instead of a guess, and the card says so rather than hiding it.
It costs coverage, which felt wrong to build and right to ship. A product that is quiet where it is weak can be trusted where it speaks; one that always answers teaches people to check it against reality, and then they stop checking.
The first version of the bot asked for a destination, then asked when you would arrive, then answered. Reasonable, and wrong: it put a question between the user and any value, and that question was where conversations went quiet.
Now a resolved destination answers immediately for "leaving now", and the arrival times are a row of buttons on the card itself — re-timing is a correction to an answer you already have, not a toll you pay before seeing one. Stale and foreign button taps got the same treatment: they used to dismiss silently, which reads as a dead bot, and now say what happened.
The temptation was to chase the drop-off with a reminder ping. I didn't, because silence after a card is usually success — the user tapped Navigate and drove off — and a prediction that decays in minutes makes for a useless nudge ten minutes later. Instead each query records how far it got, including destinations that never geocoded, which turned the guesswork into a number: the searches that fail are now visible before a user has to complain about them.
Capacity is the honest limitation. The open feeds publish total lots for public housing carparks but not for most private ones, and without capacity there is no denominator, so malls and much of the CBD currently fall into the "not enough signal" bucket by construction rather than by judgement. The fix — inferring capacity from the observed ceiling of months of readings — is the next thing on the list, and until it ships the product's honest pitch is heartland, not malls.
Rainfall is ingested and not yet used: the hypothesis that rain fills carparks faster is plausible, cheap to test, and will only ship if it improves held-out accuracy rather than because it sounds good. The thumbs-up/thumbs-down asked after each predicted arrival is quietly the most valuable part of the system — it is the only labelled outcome data anyone has for this problem, and no amount of open data substitutes for it.
TypeScript · Node · Postgres · grammY · Docker · GitHub Actions · LTA DataMall · data.gov.sg · OneMap