Clear Hour: I Committed an Air-Quality Forecast to Git Before the Day Started, Then Scored It in Public A developer built Clear Hour, an open-source tool that ranks all 24 hours of the next day by air quality in Islamabad/Rawalpindi using TabPFN v2, an open-weight tabular foundation model running locally on a laptop CPU, to correct a free government CAMS forecast against a year of real sensor readings. Forecasts are committed to git before each day begins and scored afterward against actual measurements; on the first scored day (2026-10-11), all three models ranked the hours backwards (Spearman −0.512 for raw CAMS, −0.369 for TabPFN v2, −0.409 for a CAMS+hour-bias baseline), with none selecting a window inside the day's actual cleanest hours of 17:00–19:00 at 20.9 µg/m³. This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass https://dev.to/challenges/hacktoberfest-week1-2026-10-05 🌫️ TL;DR: Clear Hour ranks tomorrow's 24 hours by how breathable the air will be in Islamabad/Rawalpindi, using TabPFN v2 , an open-weight tabular foundation model running locally on my laptop CPU, to correct a free government air-quality forecast against a year of real sensor readings. Each day's forecast is committed to git before that day starts , then scored against what a sensor actually measured. I scored the first one this morning. All three models, TabPFN included, ranked the hours backwards. I'm publishing that result anyway, because being able to lose in public is the entire reason to pre-register a forecast instead of picking a flattering day afterwards. "Touch grass" is good advice most places. Where I live it comes with an asterisk for about four months a year. I'm in Islamabad. Every October the smog settles in, and "go for a walk" stops being a simple decision. The air at 9am is nothing like the air at 5pm, and nothing tells you which is which until you're already outside with a sore throat. The apps here give you today's number : one figure, already stale, that flattens a day with a 4× swing in it into a single color-coded badge. Clear Hour ranks all 24 hours of tomorrow instead , cleanest to dirtiest. It picks the two-hour window worth going outside for, and the one to avoid. And it commits to that answer before the day happens . That last part is the whole discipline. It would be trivial to run a forecaster retrospectively, on a day that happened to go well, and write a post about how well it did. So the rule I set on day one was: the forecast for a day gets committed to git before that day begins, and I never touch that file again. After the day ends, a separate script pulls real measurements and scores it, flattering or not. This morning I ran that scoring script on a real day for the first time. I'm leading with the result, because it's the most honest thing I can tell you about this project. Live: https://abeera81.github.io/clear-hour/ https://abeera81.github.io/clear-hour/ Repo: https://github.com/Abeera81/clear-hour https://github.com/Abeera81/clear-hour One static page, no login, no build step. Scroll past the headline and you land on the pre-registered forecast for 2026-10-11, now with what the sensor actually measured drawn over it: The black line is reality. Every colored curve sitting above it after midday is a model saying "dirtier than it actually got." The air was genuinely cleanest from 5pm to 11pm; all three models put their cleanest window earlier than that, and the two corrected models never saw the evening drop coming. Run it yourself. The default model needs no API key at all: python -m clearhour today, instant, no key needed python -m clearhour --date 2026-10-13 any of the next few days python -m clearhour --model tabpfn the real TabPFN v2 correction free OpenAQ key, ~30-90 min first fit on CPU Actual day mean at the sensor: 45.4 µg/m³ . The real cleanest two hours: 17:00–19:00, at 20.9 µg/m³ . The real worst: midnight, at 79.2 . Every value in the Spearman column is negative. Negative means the ranking ran opposite to reality: hours the model called cleaner tended to be dirtier. | Model | MAE | Bias | Spearman 24h | Chosen cleanest 2h | Actual there | |---|---|---|---|---|---| | B0 raw CAMS | 21.9 | −3.5 | −0.512 | 05:00–07:00 | 73.7 | | M1 TabPFN v2 | 21.9 | +14.6 | −0.369 | 15:00–17:00 | 29.8 | | B2 CAMS+hour bias | 25.3 | +18.8 | −0.409 | 15:00–17:00 | 29.8 | Not one of them picked a window inside the day's actual cleanest hours. Raw CAMS did worst in the way that matters most to a human: it sent you outside at 5am, into 73.7 µg/m³, the second-dirtiest two-hour window of the entire day . TabPFN and B2 at least picked an hour below the day's mean, but both missed the real window by two hours and badly underestimated how clean the evening would get. So: on the single day this project has been scored against reality, it ranked the hours backwards. That's a true statement about one day, and it's in the same post as the project rather than in a follow-up nobody clicks. What it is not is a verdict on TabPFN. One day is one sample. A good day tomorrow wouldn't prove the model works either. The 30-day held-out evaluation below is where the actual evidence lives, and there TabPFN earns its place. Three ingredients, all free, all open: | Piece | Source | What it contributes | |---|---|---| | Air-quality forecast | Open-Meteo CAMS global model , CC BY 4.0 | hourly PM2.5 + 5 other pollutants, up to 7 days ahead | | Weather forecast | Open-Meteo archive + forecast API | boundary-layer height, wind, humidity, temperature, precipitation: the features that explain why CAMS is wrong | | Ground truth | OpenAQ v3 , sensor 4554236 AirGradient, operated by The Urban Unit, CC BY 4.0 | real hourly PM2.5 5.2 km from the CAMS grid point, used both to train the correction and to score the forecast | CAMS is a serious atmospheric model, but at this specific point it is predictably wrong. Measured against the US Embassy reference monitor over ~28,000 overlapping hours, CAMS under-reads by a mean of 18.9 µg/m³ , and the error is strongly hour- and season-dependent: in Oct–Dec it misses the monitor's 9am peak by up to 90 µg/m³ and its evening peak by up to 70. That structured, repeating error is exactly the kind of thing a tabular model can learn. Every forecast card displays all three side by side. The weak baseline doesn't get quietly dropped when the fancy model wins: src/forecast.py: TabPFN v2, local CPU, no tuning m = TabPFNRegressor.create default for version ModelVersion.V2, device="cpu", fit mode="fit with cache", ignore pretraining limits=True, random state=0 m.fit tr FEATURES , tr "y" .to numpy pred = pd.Series m.predict live FEATURES , index=live.index ignore pretraining limits=True is the load-bearing flag: TabPFN v2 refuses more than ~1,000 training rows without it, and pushing to 10,000 rows on CPU is also why a fit takes 30–90 minutes rather than seconds. The fitted model is cached to disk 6.3 GB at 10,000 rows so repeat runs reload in ~2 minutes instead of refitting. On this laptop the real first fit took 3,351 seconds, or 56 minutes . An hour-long fit does not fit neatly into "commit before the day starts." Rather than fudge it, the Oct 11 forecast shipped in two explicitly-labeled stages: 69c7d9e 802344a The card says in plain words: hours before 06:51 had already occurred and are not pre-registered for TabPFN. The scoring script then reports the full day and a separate ≥07:00 view, re-deriving each model's window from only the hours that were genuinely still in the future. An ugly two-stage commit with an honest caveat beats a tidy story that quietly implies a 56-minute model finished at midnight. scripts/score forecast.py: reads the frozen forecast, never writes to it fc = json.loads ROOT / "results" / f"forecast {day}.json" .read text encoding="utf-8" ... for name, m in fc "models" .items : p = pd.Series m "hourly" , index=idx .reindex sel both = pd.concat p, ys , axis=1, keys= "p", "y" .dropna err = both "p" - both "y" rho = both "p" .corr both "y" , method="spearman" if len both 2 else float "nan" Scoring writes two new files and never edits the committed forecast. The CLI enforces the same rule from the other direction: --save refuses to overwrite an existing results/forecast