Every winter, Delhi's air becomes so toxic that schools shut down and hospitals overflow. The city has 40+ sensors measuring pollution — but no tool to tell officials where it is coming from, what will happen next, or what to do about it.
I built that tool. Here is how.
The Architecture
The entire app runs from two things: a React frontend and a single Python file.
┌───────────────────────────┐ ┌───────────────────────────┐
│ React Dashboard (UI) │ │ FastAPI Backend (Python) │
│ │ JSON │ │
│ GIS Map · Charts · Chat │◄───────►│ Dispersion Math (NumPy) │
│ Sliders · Policy Toggles │ APIs │ Source Attribution │
│ │ │ Forecast Engine │
└───────────────────────────┘ └───────────────────────────┘
▲ Compiled into static files │
└────────────────────────────────────┘
Served by FastAPI
│
┌──────────┴──────────┐
│ Docker Container │
│ Port 7860 │
│ (Hugging Face Hub) │
└─────────────────────┘
- React handles the UI — map, charts, sliders, toggles, chat widget.
- FastAPI handles the logic — it runs physics equations using NumPy and returns JSON.
- Docker packages both into one container. React compiles into static files, which the Python server hosts alongside its API routes.
No database. No external APIs. No ML model. The math runs in under 5ms per request.
Why This Stack (And What I Considered Instead)
| Decision | I chose | I considered | Why I went this way |
|---|---|---|---|
| Frontend framework | React + Vite | Next.js, plain HTML/JS | I needed fast state management for 6 connected panels (map clicks update charts, sliders update forecasts). Plain JS would be spaghetti. Next.js adds SSR overhead I did not need — this is a single-page dashboard, not a content site. |
| CSS approach | Vanilla CSS with custom properties | Tailwind, MUI | Tailwind classes clutter JSX when you have complex glassmorphic styles. MUI enforces Material Design, which does not fit a dark control-room aesthetic. Vanilla CSS with HSL variables gave me full control. |
| Backend | FastAPI | Flask, Django, Express | Flask has no built-in request validation. Django is heavy for a single-file API. Express would mean running Node alongside Python (NumPy lives in Python). FastAPI gives me Pydantic validation, async support, and static file serving in one process. |
| Math engine | NumPy | TensorFlow, scikit-learn | I am not training a model. I am evaluating physics equations with array math. NumPy does this in microseconds. TensorFlow would add 500MB of dependencies to do the same multiplication. |
| Deployment | Docker on Hugging Face | Vercel, Railway, AWS | Hugging Face gives free Docker hosting with a public URL. Vercel is frontend-only. Railway and AWS need payment setup. One Dockerfile, one push, done. |
Connecting Frontend to Backend
┌──────────────┐ ┌──────────────┐
│ │ GET /api/stations │ │
│ │ ────────────────────────► │ │
│ │ │ Computes │
│ │ GET /api/attribution │ baseline │
│ React │ ────────────────────────► │ AQI using │
│ Dashboard │ │ dispersion │
│ │ POST /api/simulate │ equations │
│ │ ────────────────────────► │ │
│ │ │ FastAPI │
│ │ POST /api/chat │ + NumPy │
│ │ ────────────────────────► │ │
│ │ ◄──── JSON responses ──── │ │
└──────────────┘ └──────────────┘
▲ In production, both served from same origin
In development, Vite proxies /api/* requests to the Python server running on a different port. In production, FastAPI serves the compiled React bundle directly — so there is no CORS issue and no separate hosting:
dist_path = os.path.join(os.path.dirname(__file__), "frontend/dist")
if os.path.exists(dist_path):
app.mount("/", StaticFiles(directory=dist_path, html=True), name="static")
One uvicorn command runs the entire app — APIs and UI — on a single port.
The React side keeps all simulation parameters (wind speed, direction, mixing height, active fires, policy toggles) in component state. Whenever a slider moves or a policy is toggled, a debounced useEffect fires API calls to fetch updated attribution and forecast data. Every panel on the dashboard — the map, the charts, the advisory, the chat — reacts to the same shared state.
The Simulation Engine — Where the Real Solution Lives
The features I described above — the map, the charts, the chat — are just interfaces. The actual solution to the air quality problem lives in one Python function: compute_aqi_components().
Here is the core idea. Delhi's pollution comes from four sources: traffic, factories, farm fires, and construction dust. Each source behaves differently depending on the weather. The function models these physical relationships:
Traffic exhaust builds up when winds are slow
When winds stall, vehicle emissions have nowhere to go. They pile up at ground level.
traffic = base_traffic * rush_hour_curve * (8.0 / (wind_speed + 1.5))
The rush_hour_curve is a sine wave that peaks at 9 AM and 6 PM — matching real commute patterns. When wind speed drops toward zero, the denominator shrinks and the contribution spikes.
Factory smoke gets trapped by cold air layers
In winter, cold air settles over Delhi like a lid on a pot. Meteorologists call this the "mixing height" — the altitude below which pollutants are trapped. When it drops from 1000m to 200m, concentrations multiply.
industry = base_industry * (600.0 / mixing_height)
Farm fire smoke depends on wind direction
Crop fires happen north-west of Delhi, in Punjab and Haryana. The smoke only reaches Delhi when the wind blows from that direction (~310°). I modeled this using a bell curve — full impact at 310°, falling off sharply as the angle shifts:
alignment = np.exp(-(wind_direction - 310)**2 / (2 * 25**2))
biomass = base_biomass * (active_fires / 500) * alignment * (15 / (wind_speed + 2))
Chemical fingerprinting cross-validates the math
Different sources produce different sized particles. Farm smoke is ultra-fine (mostly PM2.5). Construction dust is coarse (mostly PM10). I used fixed ratios from published atmospheric research to split the total into PM2.5 and PM10:
pm25 = 0.70*traffic + 0.55*industry + 0.88*biomass + 0.15*dust
pm10 = 0.30*traffic + 0.45*industry + 0.12*biomass + 0.85*dust
If the computed PM2.5/PM10 ratio is above 0.80, it confirms that combustion (traffic or farm fires) is the dominant source. Below 0.35, it confirms coarse dust. This acts as a built-in sanity check on the source attribution.
Putting it all together
For the source attribution panel, the function runs once with current slider values and returns percentage breakdowns.
For the 72-hour forecast, it loops through each hour with evolving weather — winds slow, cold air traps pollution, then winds pick up and clear the air. The function runs twice per hour: once without policies (baseline) and once with them (mitigated). The gap between the two lines on the chart is the measurable impact of a policy decision.
For the chat assistant, the function runs with whatever the user is currently looking at on the dashboard, and the results get formatted into a natural-language response.
One function. Three features. Every number on every panel traces back to a physical mechanism an official can understand and defend.
Deploying in One Container
The Dockerfile is a two-stage build:
# Stage 1: Build React
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json .
RUN npm ci
COPY frontend/ .
RUN npm run build
# Stage 2: Run Python
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
EXPOSE 7860
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
Node compiles the React code, then the output gets copied into a Python image. The final container has no Node.js — just Python serving static files and API routes. I pushed this to Hugging Face Spaces, which pulls the Dockerfile and builds it automatically.
Try It
Code: GitHub
Live Demo: Hugging Face Space
Run it locally in two commands:
cd frontend && npm install && npm run build && cd ..
uvicorn main:app --port 7860
Built by Adrika Pandey.
Top comments (0)