This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass
What I Built
Every morning in Hyderabad I check four things before deciding whether to go outside: how hot it feels, the air quality, whether it will rain, and the UV index. That is four apps, and most days the effort wins and I stay in.
Window does the checking for me. At 05:30 it sends one Telegram message with a time window and a nearby spot. I don't have to open anything. If I want more, I message the bot: /today repeats the recommendation and /why gives the numbers behind it.
Best of a smoggy day: tomorrow 06:00-08:00 at Public Gardens, Nampally. Air is unhealthy for sensitive groups.
That is a real message from the night I finished the project. It is not the cheerful "great morning for a walk" I first built, and why it changed is the most useful thing I learned (see "Things that broke").
It is for anyone who would go outside more if the decision were made for them, and who lives somewhere the answer is not always "yes". It is a personal tool for one person in one city, built to be forked.
Open-Meteo (weather + CAMS air quality, 92 days of hourly history)
|
v
TabPFN: forecast PM2.5 for the next 1, 3 and 6 hours (local, CPU)
|
v
Score every hour 0-100 from heat, air quality, rain, UV, daylight (plain rules)
|
v
Python picks the best 2-hour window; Gemma 3 picks the spot and
writes the message (local, Ollama)
|
v
Telegram message at 05:30 (+ a small React page)
It runs on my laptop and costs nothing. After a one-time setup it needs the internet only for Open-Meteo and the Telegram send. With cached data the rest still runs, and if Ollama is down it falls back to a templated message.
Demo
Video demo (Telegram, recorded with Screen Studio): watch it here
Code
Syrthax
/
window
Window: one notification a day with the best outdoor hour in Hyderabad. TabPFN forecasts PM2.5, Gemma (Ollama) writes the nudge. Local, $0, DEV Hacktoberfest 'Touch Grass'.
Window
Predicts the best outdoor hour today in Hyderabad from heat, air quality and rain then sends ONE short notification with a time window and a nearby spot The screen is the shortest part of the experience.
Open-source AI is the core: TabPFN (local) nowcasts PM2.5, Gemma 3 via Ollama (local) picks the spot and writes the nudge. Runs at $0 on a laptop and degrades gracefully offline (cached data, templated nudge if Ollama is down).
Built for DEV Hacktoberfest Week 1 ("Touch Grass").
Pipeline
ingest (Open-Meteo) -> predict (TabPFN, +1/+3/+6h PM2.5) -> score (rule, 0-100/hour)
-> plan (Python picks the window, Gemma picks the place + wording)
-> notify (Telegram)
Quickstart
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # then fill it in (see below)
ollama pull gemma3:4b # needs a recent Ollama (0.40+)
python -m window.run --dry-run # everything except the…Run it yourself:
git clone https://github.com/Syrthax/window && cd window
python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
cp .env.example .env # Telegram bot token + chat id, TabPFN key
ollama pull gemma3:4b
python -m window.run --dry-run # everything except the send
python -m notify.listen # make the bot answer /today and /why
scripts/install_launchd.sh # macOS: daily at 05:30
How I Built It
I planned it on October 6 and wrote all the code on October 11, with Claude Code doing much of the typing. Between the first commit and the scheduled daily job there were about two and a half hours of wall-clock time. The code is about 1,600 lines with 92 tests. Every decision, number and failure is in docs/build-log.md.
Open-source AI at the core
- TabPFN (a pretrained transformer for tabular data, running locally on CPU) forecasts PM2.5 from history, using no tuning and about 1,800 hourly rows.
- Gemma 3 (4B) through Ollama, also local, chooses the spot and writes the message.
- No closed LLM API is in the runtime path.
One rule shaped everything: models write, code decides
The language model never decides anything that can be computed. Python picks the time window. Python decides whether to warn. Python writes the "why" text. Gemma only chooses a place from five candidates and phrases the message, and its output is validated against the facts before it is sent. I did not start with this rule. I arrived at it after a 4-billion-parameter model told me an air quality index of 129 was "good".
Data
Open-Meteo gives hourly weather and CAMS air-quality model output for any coordinates, with 92 days of history, for free. Two things in the data mattered:
- The weather endpoint only goes back about 74 days while air quality covers all 92, so the first 18 days have no weather.
-
precipitation_probabilityis 0 in every historical row. It is a placeholder, not data, so I only use it for the forecast and never for training.
Air quality here is model output, not ground-station readings. I say so in the README and on the page.
The forecast: TabPFN, and an honest result
My first version was only so-so. At +3h it lost to the simplest possible baseline, which is "assume nothing changes" (persistence). Then I improved it with standard techniques, and I kept the test set out of every decision:
- Split the data in time order: train, then 14 days of validation, then 14 days of test.
- Try four variants, scored on validation only: (v0) the original; (v1) predict the change from now instead of the level; (v2) add daily-cycle features such as the value at the same hour yesterday, trends, volatility, and the weather change; (v3) also keep the first 18 days with missing weather, since TabPFN accepts missing values.
- Pick the variant by validation score, and fit blend weights with the simple baselines on validation too.
- Only then refit and score the test fortnight once.
Mean absolute error in µg/m³ on the last 14 days (lower is better):
| Horizon | Window (final) | Original TabPFN | Persistence | Same hour yesterday |
|---|---|---|---|---|
| +1h | 1.94 | 2.55 | 2.46 | 7.68 |
| +3h | 5.88 | 7.49 | 6.47 | 7.68 |
| +6h | 9.40 | 9.44 | 10.78 | 7.68 |
Against the original model, error fell 24% at +1h and 21% at +3h. It now beats persistence at every horizon.
It does not win everywhere. At +6h, "same hour yesterday" is better on the test fortnight. On the validation fortnight it was the worst option at +6h (9.03 against 5.94 for the model), so the blend gave it zero weight. That is a shift between two two-week windows, and the honest fix is more weeks of data, not tuning on the test set. I also have no comparison with CAMS's own forecast: Open-Meteo returns the model's analysis for past hours, so a historical "what CAMS predicted yesterday" cannot be rebuilt, and using the analysis as a baseline would be leakage. The accuracy table on the demo page says all of this.
In production, the model's prediction for those three hours is averaged with the CAMS forecast, because I cannot show that either is better.
The score
The "is it a good hour" rule is deliberately not machine learning, so I can explain every number:
score = 100 - temperature - air quality - rain - UV penalties
capped at: night 30, AQI > 150 -> 15, heat index > 40 C -> 10, rain >= 0.5 mm/h -> 20
Temperature costs nothing between 18 and 26 °C, then 4 points per degree above. Air quality is converted from PM2.5 to US AQI with the EPA 2024 breakpoints, and costs 0.3 per AQI point above 50. Twenty-eight tests cover the edges (night, rain, AQI 150 vs 151, heat index 40 vs 41).
The planner: Gemma 3 through Ollama
Python picks the best two consecutive daylight hours. Gemma 3 picks one of the five nearest places and writes the message, as JSON that must pass validation:
- start and end must match the chosen window exactly;
- the place must be from the list;
- the message must be 140 characters or fewer;
- activity must be 1-4 words, with no times or place names;
- no field names like
max_aqianywhere in the message; - when conditions are poor, the message must start with "Best of a ..." and mention the air.
One retry with the validation errors fed back, then a templated fallback. Gemma 3 has no native tool calling in Ollama, so the planner runs the two tools itself and puts their results in the prompt. This stage takes about 4 to 18 seconds (the long end is a cold model load).
Delivery
Telegram has a 15-second timeout, retries on network errors, 429 (honouring retry_after) and 5xx with exponential backoff, and fails fast on other errors. Error messages never contain the URL, because the URL contains the bot token (there is a test for that). A macOS launchd job runs everything at 05:30 and starts Ollama if it is not running. A full run takes about two minutes, most of it three TabPFN fits on CPU.
The bot also answers me. A small listener replies to /today and /why, but only for my own chat, so a stranger who finds the bot cannot trigger anything. Until I tried to record this demo it did not reply at all, because I had only built the sending half.
Things that broke
These are the parts of the log I would most want to read in someone else's write-up.
1. TabPFN said my model was "gated" when the problem was SSL. The weights download failed with a Hugging Face "gated model" error, even though my account had access. Reading TabPFN's source showed that its license check uses urllib and turns any exception into the gated-model message. python.org's Python on macOS ships without CA certificates, so the real error was CERTIFICATE_VERIFY_FAILED. Pointing SSL_CERT_FILE at certifi fixed it. Then I needed a second, separate license acceptance on Prior Labs' site.
2. A wrong baseline made my model look worse than it was. In the smoke test I compared a 1-hour-ahead forecast with "the value from 1 hour ago", which is a 2-hour gap. I reported that TabPFN did not beat persistence. It did. I caught it when the real evaluation gave a persistence error of 2.49 instead of 4.65, then corrected the log entry rather than quietly editing it.
3. The 88-second fit was a download. My first TabPFN timing was 88.6 seconds, over my 60-second budget, so I trained on 30 days. The real fit on all history takes about 10 seconds. The rest was the one-time weights download.
4. A 4B model that sounds sure and gets facts wrong. On the demo page I found four separate problems, each fixed with a check in code, not with a better prompt:
- It called AQI 129 "good" and "low pollution". Fix: compute the EPA category in Python and give the model only that label.
- Its "reason" text said "lowest air quality (126)" for a window whose air was worse than the day's average. Fix: the reason is now computed in Python by comparing the window with the daylight average. It says plainly when the best window has worse air than average.
- It leaked field names ("lowest max_aqi") and put the whole message into the "activity" field. Fix: validators, plus examples in the prompt.
- Asking for "at most 140 characters" produced 231- and 144-character messages, and all three of my test runs fell back to the template. Asking for "about 100" fixed it.
5. The first real notification was wrong in tone. It said "visit Indira Park; air is unhealthy for sensitive groups". That was true and still sounded like an invitation. Now Python decides when a window is poor (AQI above 100, feels-like 35 °C or more, UV 8 or more, rain 50% or more), and the message must lead with the warning.
6. My place coordinates were from memory. I checked them against OpenStreetMap. Six of fourteen were more than a kilometre off, and one was 4.5 km off. They are now OSM coordinates, credited in the README.
7. A new model needs a new runtime. Pulling gemma3:4b failed with HTTP 412 because the installed Ollama was too old. A long-running server also kept serving old code after I changed the validator, which made a fix look broken for ten minutes.
Limits and what is next
- One user, one city, 92 days of history. The forecast result is promising and not conclusive.
- PM2.5 comes from a model, not sensors, and I could not compare against CAMS's own forecast. Saving the forecast every day would give a real baseline within weeks.
- The 06:00-08:00 window I get today still has poor air. The tool is honest about that, but it cannot fix it.
- The personal-comfort model (a TabPFN classifier trained on ratings after each outing) is designed but not built. It needs about 15 real outings.
Why Does Open Innovation Matter?
I could not have built this the way I did with a closed service, and four things made the difference.
I could read the code that was misleading me. The "gated model" error was wrong, and I found out why by opening the library's authentication module. With a closed API I would have had a support ticket and a guess.
The model is swappable. Gemma is one environment variable (WINDOW_MODEL). I ran the same pipeline with llama3.2 to test the live path before Gemma was installed. The validators do not care which model is behind them, and that is what let me find and fix failure modes by measuring them.
It is private and free to run. There is no closed LLM API in the runtime path and no per-call cost. At run time the only network traffic is Open-Meteo and the Telegram message. TabPFN needs the internet once, to accept its license and download the weights. I checked that after that it runs with the network call path broken. That is the difference between a tool I run once and a tool I can leave scheduled for a year.
The data is open too, and open data corrected me. CAMS model output through Open-Meteo, and OpenStreetMap coordinates that caught my bad ones.
"Open" is a spectrum, and I want to be accurate about it. TabPFN's weights need a license acceptance, and Gemma has its own terms of use. These are open weights with conditions, not public-domain code. They are still enough to inspect, run offline, swap and measure, which is what mattered here.
Prize Categories
- Best Use of TabPFN. The forecast is TabPFN on a CSV of hourly history: four variants, a time-ordered validation protocol, blend weights fit on validation, a leakage test for every variant and horizon, and an honest table that includes the horizon where it loses.
- Best Use of Gemma. Gemma 3 4B runs locally through Ollama. It picks the place and writes the message, inside validators that check it against the data, with a fallback when it fails.
Data: weather and air quality from Open-Meteo (CAMS), place coordinates from OpenStreetMap contributors (ODbL).

Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.