
Coal plants report their own pollution. So we built an eye in orbit.
How two of us built PanoptiCoal on AWS in about 62 hours, and the eleven things that fought back along the way.
Krish Jain and Suhani Jain · Team 404 Not Found · WeMakeDevs × AWS Environmental Hacks 2026, Air track
In May 2026, the air above Talcher, a coal town in Odisha, held 91 units of nitrogen dioxide.
Going by how hard Talcher's plants said they were running that month, and by that month's wind and weather, it should have held about 58.
Nobody flagged it. That's not necessarily because anyone was hiding something. The only sensors pointed at those chimneys belong to the plants: they install them, maintain them and calibrate them. We only noticed the gap because we were looking from about 800 km up.
That's PanoptiCoal. This post covers what it does, why it has that slightly odd name, how it runs on AWS for basically nothing, and an honest list of everything that broke while we built it.
Live site: main.ddbebutv5mmwf.amplifyapp.com
Code: github.com/Krish-0210/PanoptiCoal
Demo video: youtu.be/O-tL-XRi43A

The home page. The orange points on the globe are the 11 coal clusters we analyse.
The idea in one paragraph
We take two numbers that a coal plant can't easily fiddle with:
- the electricity it reports to the national grid every day;
- the NO₂ a European satellite sees in the air above it.
A model learns how those two move together once wind, weather and season are taken out. If the air keeps coming out dirtier than the reported activity can explain, month after month, that cluster's Audit Risk Score goes up. Regulators get a ranked list of where to inspect first. The people who live next door get a way to ask for the records.
Why "PanoptiCoal"?
The name started as a pun. It turned out to fit better than we expected.
In 1791, the philosopher Jeremy Bentham designed a prison he called the Panopticon, from the Greek for "all-seeing". There is one watchtower in the middle that can see into every cell. The prisoners can never tell whether they are being watched, so they behave as if they always are. Michel Foucault later turned the Panopticon into the go-to image of surveillance power: a few people watching many.
We wanted to flip that. In our version:
- the tower is a satellite;
- the ones being watched are some of the biggest polluters in the country;
- the ones watching are the public: regulators, journalists, and the families living downwind.
Sentinel-5P passes over every coal plant in India at around 1:30 every afternoon. Nobody can switch it off, recalibrate it, or ask it to look somewhere else.
The logo

Each part of the mark stands for a piece of the method.
We wanted the logo to explain the idea without any words. So it's an eye, and every part of it stands for something:
- The lids are the view from above, looking down at the ground.
- The teal outer ring, with a dot riding on it, is a satellite in orbit. On the site it really does circle, once every nine seconds.
- The dashed inner ring is the 20 km ring we measure around each cluster (more on that below). It turns the other way, like a second instrument scanning.
- The orange pupil is the coal plant itself. It's what the whole eye is fixed on.
We called the colour scheme "Orbit & Ember": a cold satellite checking up on hot coal plants. Teal means the instrument, orange means NO₂ and things worth your attention. We kept to that across the whole site, so colour always means something.
The problem isn't a lack of data. It's who produces it.
India's Central Pollution Control Board requires thermal power plants to run OCEMS, short for Online Continuous Emission Monitoring Systems. These are sensors on the chimney that stream SO₂, NOₓ and dust readings to the regulator.
The catch is that the plant owns the sensor. It installs it, maintains it and calibrates it. Self-reported monitoring can go wrong, by accident or on purpose:
- a reading flatlines at a value that looks safe;
- the sensor goes offline exactly when the plant is running hardest;
- calibration slowly drifts;
- extra air gets pushed in to dilute the reading.
Regulators have few inspectors and nothing independent to check these streams against. Meanwhile about 7.7 million people live within 20 km of the 11 clusters we looked at, more than 7 lakh of them around Chandrapur alone.
What was missing was a cross-check the plant can't touch.
Two numbers nobody at the plant controls
1. How hard did the plant actually run? The Central Electricity Authority publishes a daily, unit-by-unit generation report (DGR2) for every plant in India. The grid runs on it and payments depend on it. That makes it a completely different channel from pollution compliance. More coal burned means more electricity, and that shows up here.
2. What did the air above it look like? ESA's Sentinel-5P satellite, through its TROPOMI instrument, measures nitrogen dioxide every day at roughly 5.5 × 3.5 km resolution. NO₂ only lasts a few hours, so it stays near where it was made. It's the best fingerprint of nearby burning you can get from orbit.
If a cluster makes more electricity, the NO₂ above it should rise too, once you allow for the weather. If the air stays dirtier than the electricity explains, for months, an inspector should go and look.
We did this for 11 clusters of large coal plants: Singrauli, Korba, Marwa, Jharsuguda, Talcher, Chandrapur, Mundra, Ramagundam, Kahalgaon, Neyveli and Tuticorin.
We work with clusters, not single plants, on purpose. A satellite pixel is several kilometres wide. In Singrauli, six big plants sit so close together that from orbit they're one blob. Saying "this NO₂ came from plant A and not plant B" would be pretending to know something we don't.
How we measure: ring minus background

The real geometry around Korba. The signal comes from the 20 km ring, and the background from the 50 to 80 km band. The hatched patches are 25 km holes cut around every other big coal plant.
For each cluster and each day, we compute three numbers in Google Earth Engine:
- ring_20km is the average NO₂ within 20 km of the cluster, where the plume spreads.
- background is the average NO₂ 50 to 80 km away. That's the regional baseline: season, weather, distant traffic.
- enhancement = ring_20km − background is what the cluster itself adds. This is what we call "observed".
We throw away any day where less than half the pixels are cloud-free.
Python
BACKGROUND_KM = (50, 80)EXCLUDE_KM = 25points = [point(p) for p in plants] + [ee.Geometry.Point([lon, lat]) for lon, lat in other_plants]exclusion = ee.FeatureCollection([ee.Feature(pt.buffer(EXCLUDE_KM * 1000)) for pt in points]).union(1).geometry()full = centroid.buffer(BACKGROUND_KM[1] * 1000).difference(centroid.buffer(BACKGROUND_KM[0] * 1000), 1)annulus = full.difference(exclusion, 1)
From
pipeline/src/ingest/satellite.py . That last line wasn't in the first version. Problem #4 below explains why it's there now.Then we run a regression over 19,183 cluster-days, from 2019 to September 2026:
Plain text
enhancement ~ generation + wind speed + boundary-layer height + seasonal terms
Python
CONTROLS = ["wind_speed", "blh", "sin1", "cos1", "sin2", "cos2"]REGRESSORS = ["generation_mu"] + CONTROLSdef nw_lags(n: int) -> int:return int(np.floor(4 * (n / 100) ** (2 / 9)))def fit(d, target, regressors, **cov):X = sm.add_constant(d[regressors].astype(float), has_constant="add")return sm.OLS(d[target].astype(float), X).fit(**cov)
From
pipeline/src/process/model.py . This is plain OLS with Newey-West (HAC) errors, because satellite data from one day is strongly tied to the day before.What came out: reported generation does push up the NO₂ above every cluster.
- In the pooled model, t = 12.1 and p ≈ 8 × 10⁻³⁴.
- The effect is positive in all 11 clusters, and significant at p < 0.01 in 9 of them.
We should be upfront about the limits, though. On any single day, generation explains only about 1.5% of the swings. Satellite noise and weather swamp everything else. So PanoptiCoal never flags anything based on one day. Risk comes from 90-day windows and multi-year trends.
Turning it into a score
Every cluster gets an Audit Risk Score from 0 to 100, built from three signals:
Signal | What it asks | Weight |
|---|---|---|
Recent excess | Over the last 90 days, how far above "expected" has NO₂ been, in standard deviations? | 50% |
Intensity trend | Is the cluster getting dirtier per unit of electricity, year on year? | 25% |
Peer comparison | Is it dirtier per unit than similar clusters? | 25% |
Each score comes with a confidence level and a one-line explanation in plain language. Here's Talcher's, currently top of the list at 92:
"Talcher is high risk: over the last 90 days NO₂ ran 3.0 SD above what reported generation and weather predict, with no clear trend in NO₂ per unit of electricity, and it is 2.0× the peer median per unit generated (high confidence)."
This is the chart that convinced us the idea worked:

Talcher, 2019 to 2026. Orange is what the satellite saw. The dashed teal line is what reported generation, weather and season predict. The red bands are periods where the two pulled apart for months.

The three flagged periods. The most recent one, January to July 2026, peaks at 3.9 SD above expected.
A high score does not mean a plant is cheating. It means the evidence is unusual enough that someone should go and check. There are innocent explanations: a new mine nearby, more truck traffic, a different coal supply. That's exactly why the right response is an inspection and not a headline.
What you actually see
For regulators: a triage list

The Risk Map. All 11 clusters are ranked, and each card shows how many people live within 20 km.
The Risk Map is basically a to-do list for an inspector: five clusters high, two medium, four low. Open any one and you get the evidence behind its number.

The Talcher page: the score, the reasoning, the people nearby, and links to the brief and the RTI draft.
One click gives you an inspection brief, a one-page summary an officer could print and carry into the field.

The Talcher brief. Every number in it comes straight out of the pipeline. There's no free-form text generation, on purpose (see #6 below).
For citizens: "what's near me?"
This is the part we personally care about most. Near Me lets you pick your state, or share your location, and see every coal plant of 500 MW or more around you. That's 166 plants across 36 states and union territories.

Pick Odisha and you get 11 plants with a combined 15,815 MW. The ones in clusters we've analysed show their risk; the rest say "not yet analysed by PanoptiCoal" instead of pretending. (Pick Ladakh and it tells you there aren't any, which is true.)

Wind-Trace shows where a cluster's smoke is probably heading today. It uses live wind from Open-Meteo, and falls back to the most recent ERA5 day if the live call takes longer than three seconds.

Talcher on the morning of 11 October: near calm at 1 km/h, so the plume is likely to stay close to the plants and drift north-west.
And if something looks wrong, PanoptiCoal drafts a Right to Information application under Section 6(1) of the RTI Act, 2005. It's addressed to the right State Pollution Control Board and asks for the plant's inspection reports, OCEMS calibration and downtime logs, and consent conditions, for the exact flagged periods. It never makes up an address. It tells you to check the officer's address on the board's own website, and it says clearly that it isn't legal advice.

A ready-to-edit RTI draft for Talcher. You can copy it or download it as a text file.
For us, this is the most useful thing on the site. It turns "the air here feels bad" into a legal request someone has to answer.
The AWS part

The heavy lifting (satellite processing, parsing government reports, the statistics) runs offline in our pipeline. What it produces is small: 37 JSON files, about 309 KB. Once we saw that, the AWS design mostly decided itself.
Job | Service | Why |
|---|---|---|
Store the results | Amazon S3 (private bucket) | The files only change when the pipeline reruns. Object storage is the simplest and cheapest place for them. The bucket is private, so the API is the only way in. |
Serve the API | AWS Lambda (Python 3.12, arm64, 256 MB) | Traffic is spiky: judges, a demo, a curious person at midnight. No idle servers, near-zero cost, and the same language as the pipeline. |
Public front door | Amazon API Gateway (HTTP API) | 9 routes with CORS and throttling (10 requests per second, 1 per second for briefs), without us running a web server. |
Host the website | AWS Amplify Hosting | Every git push rebuilds and redeploys the React site with HTTPS and a CDN. Some days we pushed 40 commits. |
Define all of it | AWS CDK (Python) | PanoptiCoalStack is 37 resources in one file we can read and review. Nothing gets clicked together by hand and forgotten. |
Guardrails | IAM, CloudWatch Logs, AWS Budgets | The Lambda can only read its own bucket. Logs exist for debugging. Budgets alert us at $10 and $50. |
Everything runs in Mumbai (ap-south-1). The data is about India and the users are in India, so it made sense to keep it here.
Why no live satellite processing? Earth Engine jobs take minutes to hours. Precomputing keeps the site instant, cheap and hard to break. Why no database? Thirty-seven read-only files really don't need one.
As for cost, AWS Cost Explorer shows $0.00 month-to-date for October. That's an estimate, and the latest deploy might not have shown up yet, but you get the idea.
What fought back
This is the part we learned the most from, roughly in order of how much it hurt.
1. The government spreadsheets change shape every year
Depending on the year, the CEA daily report has 15, 16 or 18 columns. Mundra shows up under six different spellings. Long outage remarks wrap onto a second row that repeats the numbers, which quietly double-counts a plant if you aren't watching.
We stopped trusting column positions altogether:
- every column is found by its header text;
- the wrapped rows get merged back together;
- tests check that every plant appears exactly once and that its unit capacities add up to the plant total.
In the end we parsed 2,763 reports, 478,502 rows.
2. The two months we needed most don't exist
There are no daily reports for April and May 2020. That was the COVID lockdown, the one real-world experiment where generation actually dropped. It turned out the 1 June 2020 report carries each plant's running total since 1 April, so we could recover April and May together as one combined number. That small find kept our backtest alive.
3. The obvious analysis pointed the wrong way
This one scared us. Our first quick check, a simple monthly correlation between generation and NO₂, came out negative for Singrauli (−0.22) and Marwa (−0.17). For an evening it honestly looked like the whole idea was dead. A naive tool would have flagged exactly the wrong plants.
The reason turned out to be weather:
- power demand peaks in summer, when NO₂ breaks down fastest and mixes into a deeper layer of air;
- winter traps it near the ground.
So the raw correlation was mostly measuring the seasons. Once we moved to a daily model with wind, boundary-layer height and seasonal terms, the effect turned positive in all 11 clusters.
Cluster | Simple monthly correlation | Daily model with weather and season |
|---|---|---|
Singrauli | −0.22 | positive, t = 2.46 |
Marwa | −0.17 | positive, t = 2.30 |
Chandrapur | 0.19 | positive, t = 7.95 |
Mundra | 0.41 | positive, t = 14.46 |
4. Our "clean" background wasn't clean
The 50 to 80 km background ring around Korba had other coal plants inside it. When we compared runs, 23% of Korba's "background" turned out to be someone else's pollution, and Korba's own contribution had been understated by about 15 µmol/m².
The fix was to cut a 25 km hole around every large coal plant in India (all 166) before computing any background. That's the
exclusion line in the code above.5. Our own test of the method didn't fully pass, and we published it anyway
We refitted the model without March to June 2020, then asked it to predict lockdown NO₂ using only generation and weather.

The lockdown backtest on the How It Works page, sorted by how much reported generation changed.
Cluster | Generation vs 2019 | NO₂ vs 2019 |
|---|---|---|
Chandrapur | −27% | −26% |
Ramagundam | −25% | −33% |
Marwa | −20% | −11% |
Mundra | −34% | +16% |
Where generation fell sharply, NO₂ fell with it in three of the four clusters. Mundra did the opposite, and we show that on the site rather than hiding it.
In clusters where generation barely moved, NO₂ still dropped, because traffic and industry had stopped too. Pooled across all clusters, the model predicted 70.8 and the satellite saw 62.4. That taught us something important. The model can't pin absolute NO₂ levels on the plants alone, but it can follow changes driven by generation. So the score is built on sustained deviations and trends, never on absolute levels.
6. The generative AI we planned on wasn't available, and that turned out to be the better design
The inspection brief was originally going to be written by Amazon Bedrock. Model access on our account stayed stuck in verification, and the organisers confirmed generative AI wasn't required.
So we made the brief deterministic: a template filled only with numbers the pipeline computed (
BRIEF_MODE=template ). The Bedrock path still exists behind a flag.Looking back, this should have been the default from the start. A document that might end up in front of a regulator, or attached to an RTI, should never contain a number the analysis didn't produce. A template can't make things up, costs nothing and answers instantly.
7. CloudFormation rolled back our first deploy
The API stage had a throttle rule for
POST /brief/{id} , but CloudFormation created the stage before the route existed:Unable to find Route by key.
One explicit dependency (stage after routes) fixed it. Before that, the very first
cdk deploy sat for over ten minutes uploading a ~21 MB asset, the AWS CLI layer that CDK's BucketDeployment brings along, and we had to restart it.8. One missing letter broke the live site
The site was deployed and the API passed every smoke test. The live site still showed no data.
The Amplify domain in our CORS allow-list was missing a single letter:
ddebut… instead of ddbebut… . Every request from the real site was being turned away at the preflight check. We fixed the letter, and we also moved the allowed origin into infra/cdk.json , because a -c flag typed on the command line is exactly the kind of thing you forget on the next deploy:Python
extra = self.node.try_get_context("allowed_origins") or ""origins = LOCAL_ORIGINS + [o.strip().rstrip("/") for o in extra.split(",") if o.strip()]
9. Our globe drew India's border wrong
The default low-resolution world borders didn't match India's official map in Jammu & Kashmir and Ladakh, and they made Gujarat's coastline look like it was built from Lego. For a tool meant for Indian regulators, that isn't a cosmetic bug.
We redrew India from Natural Earth's 1:10m India point-of-view boundaries: 1,050 points instead of 136, used on the globe and in every map outline.
10. The sound needed two or three clicks to start
Browsers block audio until the user does something. Our unlock code ran in a timeout just after the click, which technically put it outside the click.
The fix:
- create one AudioContext early;
- call
resume()synchronously inside the click;
- only show the sound toggle as "on" once audio is actually running.
11. The globe dropped frames
Profiling showed the worst frames were limited by the GPU, not by JavaScript. So:
- the globe now only renders while it's moving;
- it uses adaptive resolution;
- bloom is replaced with a cheap halo;
- the home page went from 240 GPU layers to 62.
What we'd tell ourselves on day one
- Plot the naive version first. Those negative correlations were the most useful thing we found, because they showed exactly what the model had to correct for.
- Publish the test you might fail. Mundra's result made the method more believable, not less, and it changed how we score.
- Make AI optional and the numbers mandatory. For anything that could reach a regulator, being predictable matters more than sounding good.
- Put configuration in version control. Every bug that reached the live site came from something we'd set by hand.
How we checked our own work
- 373 automated tests: 352 for the API, plus infrastructure and pipeline tests.
- A contract test that reads the frontend's TypeScript types and fails if a single field the site expects is missing from what the API serves.
- 125 commits in about 62 hours, with a rollback tag before every risky change.
What PanoptiCoal doesn't claim
It doesn't prove any plant broke any rule. It doesn't measure what comes out of the chimney; it measures the air around a cluster. It can't directly see night-time emissions, or small factories whose signal is too weak for a satellite. Every flag on the site is worded the same way: an anomaly that warrants an audit.
What's next
- Risk scores for all 166 mapped plants, not just 11 clusters.
- SO₂ checks against the flue-gas desulphurisation (FGD) units plants say they've installed.
- An OCEMS tamper-detection module, for when regulators connect real chimney feeds: flatlines, copied data, outages during peak load.
- Automatic refresh with EventBridge.
- The citizen pages and RTI drafts in Hindi and other state languages.
One Last Thing
This project exists because of Suhani. I wasn't planning to enter this hackathon. She was the one who pushed me to sign up, and then stayed in it with me through the idea arguments, the region-locked account and the one-letter bug. Thank you, Suhani.
PanoptiCoal doesn't accuse anyone. It shows where to look, and gives the people breathing that air a way to ask questions.
We watch from space.
Try it: main.ddbebutv5mmwf.amplifyapp.com
Code: github.com/Krish-0210/PanoptiCoal
Demo: youtu.be/O-tL-XRi43A
Built by Team 404 Not Found (Krish Jain and Suhani Jain) for Environmental Hacks, event 02 of the WeMakeDevs × AWS Bharat Builds Tour, October 2026. Data from the Central Electricity Authority, Copernicus Sentinel-5P via Google Earth Engine, ECMWF ERA5, Global Energy Monitor, GHSL, GeoNames, Open-Meteo and Natural Earth. Contains modified Copernicus Sentinel data.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article