cd /news/machine-learning/clear-hour-i-committed-an-air-qualit… · home › topics › machine-learning › article
[ARTICLE · art-149374] src=dev.to ↗ pub= topic=machine-learning verified=true sentiment=· neutral

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³.

by read13 min views2 publishedOct 12, 2026

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass 🌫️

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/

Repo: 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:

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.

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_<date>.json without --force, because that file may be a pre-registration. The numbers can't be nudged after the fact, including by me.

Repo: https://github.com/Abeera81/clear-hour (MIT; data and model weights keep their own licenses)

Interesting files:

src/forecast.py: the engine (live feature fetch, the three models, window selection)src/clearhour/: the python -m clearhour CLI, with packaged hour-bias offsets so the default path needs no API keyeval/evaluate.py: the temporal split; results/eval_test.json existseval/learning_curve.py: the 6-fold rolling-origin "how much data does TabPFN need?" experimentscripts/score_forecast.py: pulls real measurements and scores a frozen forecast results/forecast_2026-10-11.json: the frozen pre-registration itself Every forecast run writes its exact 24 × 26 feature matrix to CSV and prints the sha256, so any forecast can be audited against the inputs that produced it.

Strictly temporal split: train through Aug 10, validate on the following month, and reserve a final 30-day TEST month (Sep 10 – Oct 9) that every model sees once. eval/evaluate.py refuses a second TEST run, so a disappointing result can't quietly be re-rolled.

Model MAE RMSE Bias Within-day ρ (median) Top-2h hit %
B0 raw CAMS 16.1 20.7 −3.4 0.564 30%
B2 CAMS+hour bias 22.1 28.7 +20.2 0.544 43%
B5 HistGBR 10.8 15.2 −4.1 0.644 40%
M1 TabPFN v2 10.3 14.6 −4.2 0.675 37%

TabPFN has the lowest error of anything I tried, and its MAE advantage over B2 is solidly real (95% CI of the difference −17.0 to −6.9 µg/m³). But its ranking edge over B2 is not statistically significant at this sample size (CI −0.04 to +0.29), and an untuned HistGBR actually beat it on the DEV month. Picking the exact best 2-hour window tops out at 43% for every model. I'd rather print those three caveats than a single bolded win.

This was the question I most wanted answered, and it produced the result I'd have been most tempted to leave out.

Six rolling-origin folds, training on the N most recent hours before each test window: recency, never random sampling. The whiskers are as wide as most of the gaps, which is itself the finding.

N (training hours) TabPFN ρ B2 ρ TabPFN MAE B2 MAE
100 0.317 0.439 19.7 19.2
500 0.431 0.555 17.9 21.3
1,000 0.544 0.535 17.2 24.1
2,500 0.616 0.543 16.0 30.2
5,000 0.590 0.476 16.9 44.5

TabPFN v2 needs roughly 1,000–2,500 training hours (about 6–15 weeks of hourly data) before it matches, then edges, a one-line hour-of-day baseline on hour ranking. Below that it is worse and erratic (at N=100 its ranking swings ±0.34 across folds). For a foundation model sold on few-shot strength, "you need six weeks of data before you beat one line of arithmetic" is a genuinely useful negative result, and it's the kind of thing a hackathon post usually buries.

One result I didn't expect: I trained the correction on sensor 4554236 and applied it, with no retraining whatsoever, to a different sensor also ~5 km from the CAMS point. It transferred: TabPFN MAE 16.8 / ρ 0.661 there, versus 27.5 / 0.570 for raw CAMS. A correction learned at one point in the city carries to another point without being re-fit. That matters for the next section.

This is not an abstract question for this project. It's the reason the project is possible at all.

There is no one to buy this from. Pakistan has no public hourly air-quality forecasting service. The only reference-grade monitor in Islamabad, the US Embassy BAM, stopped publishing in February 2026, and nothing replaced it. What's left is a scatter of uncalibrated low-cost sensors run by a local organization. No commercial forecasting vendor is going to build an hour-resolution smog model for Rawalpindi; the addressable market doesn't justify it. If this gets built, it gets built out of open components by someone who lives here.

Every single layer is open, and each one is load-bearing:

That last point deserves specifics. A 10,000-row fit takes 56 minutes on this laptop. If TabPFN were a closed, metered, hosted API, that cost would land on every city, every sensor, every refit, and the project would die on its unit economics before it helped anyone. Because the weights are open and inference is local, the marginal cost of correcting a new sensor is CPU time on hardware someone already owns. The cross-sensor transfer result above is what makes that concrete: the correction generalizes to a neighboring sensor without retraining, so someone in Lahore or Peshawar can clone this repo, point it at their own OpenAQ sensor, and get a working forecaster, with no key, no quota, and no permission from me or anyone else.

Open is also what makes the honesty enforceable. A pre-registered forecast is only meaningful if a stranger can verify it. Mine is a git commit with a timestamp, a public hash, and a feature matrix checksum. Anyone can confirm that the Oct 11 prediction existed at 06:10 on Oct 11 and hasn't been edited since. You cannot audit a closed forecaster's track record; you can only read its marketing. The negative result in this post is published because the architecture made hiding it harder than publishing it.

And the license cuts both ways, which I found out the hard way (see below).

requirements.txt wasn't installable by anyone but me.--extra-index-url plus a +cpu pin made uv reject PyPI versions of unrelated packages. I only found out by installing from a clean clone. Torch is now its own documented step. Clear Hour exists because CAMS has a structured, hour- and season-dependent bias, and TabPFN v2 is the component that corrects it: locally, on CPU, no fine-tuning, no hosted inference. It isn't a wrapper around an API call. The regressor is fit fresh on up to 10,000 rows of real sensor history, cached to disk, reloaded on repeat runs, and its predictions drive the product's actual output (which two hours to go outside).

It's also evaluated the way I'd want someone to evaluate a forecasting claim made against me:

License compliance (Prior Labs License 1.1): "Built with PriorLabs-TabPFN" appears in the README header, in every CLI run's footer, and on the live demo page. Full license copy: docs/licenses/PriorLabs-TabPFN-v2-LICENSE.txt. Hollmann et al., Nature 2025, doi:10.1038/s41586-024-08328-6.

Tinker was explored as an optional stretch (two LoRA training clients created as a capability probe), but nothing was trained or sampled with it, so it is not claimed as a result here.

Score more days as they arrive. One scored day is a start, not a track record. Retrain through a full winter once one exists. And keep the rule unchanged: commit the forecast before the day, publish the score regardless of what it says.

If you're somewhere with bad air and a nearby OpenAQ sensor, the repo should work for you with a one-line change, and if it doesn't, that's a bug I'd like to hear about.

Repo: github.com/Abeera81/clear-hour · Live demo: abeera81.github.io/clear-hour

Built solo over the challenge window, with GitHub Copilot CLI as co-author. The repo's AGENTS.md, MEMORY.md, and Conventional Commit history show the process, not just the result.

Built with PriorLabs-TabPFN. Weather and air-quality model data via Open-Meteo.com (CC BY 4.0) / Copernicus Atmosphere Monitoring Service. Air-quality measurements: AirGradient sensors operated via The Urban Unit (CC BY 4.0), retrieved from OpenAQ; uncalibrated, and not an endorsement by The Urban Unit.

── more in #machine-learning 4 stories · sorted by recency
── more on @clear hour 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/clear-hour-i-committ…] indexed:0 read:13min 2026-10-12 · —