Urban Air Quality Intelligence Platform Development by akshat agarwalUrban Air Quality Intelligence Platform Development by akshat agarwal

Urban Air Quality Intelligence Platform Development

akshat agarwal

akshat agarwal

Urban Air Quality Intelligence Platform

A hackathon MVP that combines AQI monitoring station data, satellite-derived indicators, meteorological forecasts, traffic/land-use context, and geospatial layers into five focused intelligence modules: pollution source attribution, hyperlocal AQI forecasting, enforcement prioritization, multi-city comparison, and citizen health advisories.
This repository contains two independently runnable projects:

The frontend was generated separately (via Lovable) and treated as the source of truth for UI, routing, and component structure. The backend was built to match its data contracts exactly, field-for-field, and every mock array that used to live in the frontend now lives in the backend instead.

1. Project Overview

Module Route Backend endpoint Dashboard (city-wide overview) / GET /current-aqi, GET /source-attribution Geospatial Pollution Source Attribution /map GET /current-aqi, GET /source-attribution Hyperlocal AQI Forecasting (24–72h) /forecast GET /forecast Enforcement Intelligence /enforcement GET /enforcement Multi-City Comparative Intelligence /compare GET /multi-city Citizen Health Risk Advisory /advisory GET /citizen-advisory
All data is currently mock-generated on the backend (clearly labeled as such throughout), structured to be swapped for real data sources (OpenAQ, Sentinel-5P, OpenWeatherMap, OSM/Overpass) without changing the API contract or any frontend code.

2. Architecture


Today, every service function reads a cached, deterministic Indian dataset from app/data/. Tomorrow, the same function can call a live provider (CPCB, OpenAQ, IMD, Sentinel-5P, ERA5 — see §10) instead — the router and schema on either side of the service never change:

Frontend and backend are fully decoupled. The frontend's own TanStack Start SSR server (frontend/src/server.ts) has no relationship to the FastAPI backend — it only server-renders the React app. All data fetching happens client-side (and during SSR passes) via plain fetch calls to the FastAPI service.
No database. Both "layers" of mock data — the backend's Python dicts and (formerly) the frontend's TypeScript consts — hold the same shape of data. The frontend no longer holds any of its own mock data; it is 100% backend-driven except for two pure, stateless utility functions (aqiCategory, aqiColor) which stay client-side since they're deterministic and instant (no reason to round-trip to a server to pick a color).
Three-layer backend, by design. Every endpoint follows Router -> Service -> Cached Dataset. Routers stay thin (parse request, call one service function, return the schema); each app/services/*_service.py module is the seam where a live provider will be plugged in later without touching the router or the Pydantic schema. See Future Live Integrations below.
State management split:
Server state (all API data) → TanStack Query, one hook per endpoint in frontend/src/hooks/.
UI state (map layer toggles, filters, slider position) → local useState, unchanged from the original scaffold.
Type contract: every Pydantic response model's JSON field names (via Field(alias=...)) match the frontend's original TypeScript interfaces exactly, so no response reshaping happens anywhere in the frontend.

3. Folder Structure


4. Backend Setup

Requires Python 3.11+.

Verify it's running:

Interactive API docs (Swagger UI): http://localhost:8000/docs

5. Frontend Setup

Requires Node.js 20+ (or Bun, since the project ships a bun.lock; npm works equally well and is what these instructions use).

The dev server prints its URL on startup (typically http://localhost:5173).

6. Running Locally (both together)

Start the backend first (uvicorn app.main:app --reload --port 8000) — the frontend has nothing to render without it.
Start the frontend (npm run dev inside frontend/).
Open the printed frontend URL. Every page fetches live from the backend on load; each page shows a loading state on first paint and an error state with a retry button if the backend is unreachable.
If you change the backend's port or host, update VITE_API_BASE_URL in frontend/.env.local to match, and add the frontend's origin to CORS_ORIGINS in backend/.env.

7. API Documentation

Base URL: http://localhost:8000 (configurable). All endpoints are GET, unauthenticated, and return JSON.

GET /health

Liveness check. Returns service name, version, and environment.

GET /current-aqi

Live-style monitoring station telemetry, 24h city-wide trend, and pollutant mass breakdown.

GET /forecast

72-hour city-wide AQI series, plus per-ward hyperlocal projections, meteorological drivers, and model evidence.

GET /source-attribution

Individual pollution sources (with confidence, evidence, land-use classification, and a mock satellite indicator), the city-wide source mix, and per-ward attribution breakdowns.

GET /enforcement

Ranked enforcement case queue, each with a confidence score and supporting evidence.

GET /multi-city

City comparison table (AQI, pollutants, population, 7-day trend). No confidence score by design — deterministic aggregation, not a predictive output.

GET /citizen-advisory

Health advisory guidance for a given AQI. Resolution order: explicit aqi override → ward's live station reading → city-wide average.
Query params (all optional):
Param Type Description aqi int (0–500) Override AQI value ward string Ward/district name; resolves to that ward's live station reading

Returns 404 if ward doesn't match any known station.
Full interactive schemas and a live "try it out" console are available at /docs while the backend is running.

8. Tech Stack

Backend: Python 3.11+, FastAPI, Pydantic v2 (with Literal-typed enums matching the frontend's TS unions), Uvicorn, pydantic-settings. No database — static, versioned Python mock datasets.
Frontend: React 19, TanStack Start (SSR framework) + TanStack Router (file-based routing), TanStack Query (server state), Tailwind CSS v4, shadcn/ui + Radix primitives, Recharts (charts), Leaflet/react-leaflet (map), lucide-react (icons). No global client state store — local useState for UI state, TanStack Query for everything server-derived.

9. Assumptions and Limitations

All data is mock-generated, produced by deterministic formulas (documented in backend/app/data/*.py) rather than real sensor/satellite feeds. Every field that represents a satellite or meteorological reading is clearly labeled as such in code comments and docstrings — this is explicitly acceptable per the hackathon problem statement.
No persistence. Restarting the backend resets nothing (data is static per-process, not per-request-random), but there's no database — this was a deliberate scope decision appropriate for a 24-hour MVP with no user-generated or write-path data.
No authentication, by design — the problem statement explicitly excludes it.
Focus city: Delhi, ten monitoring stations/wards. Station names (Anand Vihar, ITO, Rohini, Mundka, etc.) and districts are genuine Delhi locations; the AQI/pollutant readings themselves are still deterministic mock values, not live sensor reads (see §10). /multi-city additionally covers Mumbai, Bengaluru, Chennai, Hyderabad, Kolkata, Pune, Ahmedabad, Jaipur, Lucknow, and Dehradun.
/citizen-advisory's ward parameter exists on the backend but isn't yet exposed as a UI control. The endpoint supports ward-specific advisories (?ward=East%20Delhi), tested and working, but the frontend's advisory page currently only exposes the AQI slider — adding a ward selector would be a small, additive follow-up, not a redesign.
wardForecasts and wardAttribution (hyperlocal, per-ward breakdowns for forecast and source attribution) are fully implemented and returned by the backend but not yet rendered in the frontend UI, which currently shows only the city-wide series/breakdown for those two pages. This was a deliberate scope decision made during incremental integration to avoid UI changes mid-build; the data is available on each hook's data object for a follow-up UI pass.
Debounced advisory queries. The Citizen Advisory page debounces slider input by 250ms before querying, to avoid firing a request on every drag tick; placeholderData keeps the previous advisory list visible during that window so there's no flash.
CORS is wide open on methods/headers (allow_methods=["*"], allow_headers=["*"]) but restricted to explicit origins via CORS_ORIGINS — appropriate for a local hackathon demo, would need tightening for any real deployment.
Real data source integration was out of scope for the 24-hour window; every app/services/*_service.py module is structured so it maps cleanly to a future live-provider client without touching schemas or routers — see below.

10. Future Live Integrations

Nothing in this section is implemented yet — no external API is called anywhere in this codebase. This section documents how the existing architecture already supports plugging live data in, so that work can happen later without a rewrite.
The three-layer design that makes this possible:

The frontend never changes. Every hook in frontend/src/hooks/ calls a fixed REST contract (GET /current-aqi, GET /forecast, etc.) and every Pydantic schema in backend/app/schemas/ defines that contract's exact shape, field names, and aliases. As long as a service function keeps returning that same shape, the frontend has no way to tell whether the data underneath came from a cached dataset or a live feed.
Only providers change. Each backend/app/services/*_service.py module is a documented seam: today its one function reads a static dataset from backend/app/data/; in the future, that same function's body is swapped for a call to a live provider client (e.g. a cpcb_client.fetch_live_stations()), while its return shape — and therefore its router and schema — stays untouched. The Future Integration: comment block at the top of every service file names the specific provider(s) that function is expected to call.
Current datasets are cached, not random. Everything under backend/app/data/ is a static, deterministic Python dataset (India-only: Delhi station/ward data plus the Mumbai/Bengaluru/Chennai/Hyderabad/Kolkata/Pune/Ahmedabad/Jaipur/Lucknow/Dehradun multi-city table) — it doesn't reshuffle between requests, and the app runs fully offline with zero network calls.
The architecture already supports production APIs. Adding a live source is additive work confined to one service file at a time: write a provider client, call it from that one *_service.py function, keep the same return dict shape. No router, schema, or frontend change is required, and every endpoint can be migrated independently and incrementally.
Mapped future providers, by module:
Service module Endpoint Future provider(s) current_aqi_service.py GET /current-aqi CPCB (live station network), OpenAQ (supplementary coverage) forecast_service.py GET /forecast IMD (weather forecast), ERA5 (reanalysis meteorology), Sentinel-5P (satellite trace-gas input), CPCB (ground-truth baseline) source_attribution_service.py GET /source-attribution Sentinel-5P (satellite NO₂/AOD), CPCB (calibration), OpenAQ (supplementary coverage) enforcement_service.py GET /enforcement CPCB (exceedance/inspection records), Sentinel-5P (supporting evidence) multi_city_service.py GET /multi-city CPCB (per-city aggregation), OpenAQ (supplementary coverage) citizen_advisory_service.py GET /citizen-advisory CPCB (live ward reading), IMD (weather-adjusted severity)

Problem Statement Compliance

See the compliance matrix provided alongside this README for a capability-by-capability breakdown (Fully Implemented / Partially Implemented / Missing).
Like this project

Posted Sep 2, 2026

Developed an MVP platform for air quality intelligence with decoupled frontend and backend.