PlumeTrace: 72-Hour Source-Attributed PM2.5 Forecast and Action Engine on AWS (Amazon Bedrock, Step Functions, Lambda)
PlumeTrace traces Delhi-NCR air pollution to source crop fires using a serverless forecast engine and an Amazon Bedrock Copilot that drafts district reports, farmer alerts, and gig-rider plans. Deep dive into Step Functions, GRIB decoding, and OR-Tools. | Amazon Bedrock | AWS Step Functions | AWS Lambda | Amazon DynamoDB | Amazon S3 | Amazon EventBridge | Amazon API Gateway | Amazon Cognito | Amazon Translate | Amazon Polly | Amazon Location Service | AWS CDK |
Building PlumeTrace: 72-Hour Source-Attributed PM2.5 Forecast and Action Engine on AWS
Every autumn, Delhi-NCR's air quality collapses, and the public conversation collapses with it into the same argument: how much of this is crop-residue burning in Punjab and Haryana, and how much is local? Most dashboards show you what the PM2.5 is. We wanted to build something that answers where it came from — with an uncertainty band, not a verdict — and then does something useful with that answer.
PlumeTrace is one forecasting engine with two products on top of it, built for the WeMakeDevs × AWS Environmental Hacks (Air track) over four days. Every few hours it combines satellite fire detections, weather-model winds and ground sensors, traces the air backwards from each monitoring station, and estimates how much of the next 72 hours of PM2.5 is coming from crop fires and which districts they're in. On top of that sits an Amazon Bedrock-powered Copilot that turns forecasts into targeted actions: district evidence reports, supportive farmer alerts with audio, and gig-rider shift plans that cap pollution exposure.
This is a technical write-up of the engineering that powers it — the distributed orchestration, data decoding, generative AI workflows with Bedrock, and the architectural decisions we made along the way.
The shape of the system
The whole thing is contract-first, event-driven, and serverless. Modules never call each other's internals; they talk through frozen schemas and EventBridge events.

The stack :
Area | Stack |
|---|---|
Engine / science | Python 3.12 · xarray/cfgrib · NumPy/SciPy · LightGBM · h3 |
API + Copilot | Bun + Express 5 on Lambda (Lambda Web Adapter, response streaming) · Amazon Bedrock (Claude) ConverseStream + Guardrails · SSE |
Fleet solver | OR-Tools CP-SAT (+ greedy fallback) · Amazon Location route matrix |
Data / async | DynamoDB (on-demand) · S3 · EventBridge · Step Functions |
Web | React 19 + Vite · MapLibre + deck.gl · Cognito · Amplify Hosting |
IaC | AWS CDK (TypeScript) |
Decision 1: Contracts frozen in hour two, so nobody blocks anyone
Five people, four days, one pipeline where every stage depends on the previous one's output. The thing that saved us was freezing the data contracts before anyone wrote real logic.
zod is the source of truth; it's exported to JSON Schema and mirrored by hand in Pydantic, and contract tests check both against 11 realistic mock JSON files.The payoff is that everything has a mock mode. The API serves
contracts/mocks under MOCK_MODE=1 ; the Python engine runs offline against ./.local-s3 under PT_LOCAL=1 . The frontend built against a deployed mock API URL on day 1 and never waited for the real engine. When the real data landed, it dropped into the same shapes.
Decision 2: The agent runs on live data and produces the deliverables
The Copilot is an intelligent tool-use loop (max 10 turns) on Amazon Bedrock, running inside the API rather than as a chatbot bolted on the side. It isn't answering from training data — it executes a real tool loop against live state: it calls the engine and the REST APIs for the current forecast, source attribution and fleet status, and from that live data it builds finished deliverables — the district evidence report, the Punjabi farmer alert, and the gig-rider shift plan.
Those tools return real engine/API payloads, which the model works on turn after turn until the report is complete. The one step the agent leaves to a person is the final send: a human reviews the generated report/alert/plan and approves it, and that approval publishes the
action.approved event that delivers it. That keeps a human accountable for anything that reaches a real phone, and doubles as the prompt-injection boundary.JavaScript
// api/src/agent/loop.js — each turn runs tools against live engine/API data,// feeds the results back, and continues until the deliverable is built.const msg = await stream.finalMessage();messages.push({ role: 'assistant', content: msg.content });if (msg.stop_reason !== 'tool_use') {onEvent({ type: 'done', stop_reason: msg.stop_reason });return;}const toolUses = msg.content.filter((b) => b.type === 'tool_use');for (const tu of toolUses) {onEvent({ type: 'tool_call', tool_use_id: tu.id, tool_name: tu.name, input: tu.input });const { output, actionType, actionId } = await runTool(tu.name, tu.input, { user });// runTool hits the real engine/API; its result feeds the next turn.}
Guardrails and streaming
The agent runs on Amazon Bedrock (Claude) with ConverseStream and Bedrock Guardrails, so the deny topics — individual-farmer PII, certainty beyond the model, blaming language — along with the grounding and supportive-tone rules are enforced on every turn. The model transport is kept pluggable behind the loop, so the same zod-typed tools and every
ChatStreamEvent stay identical no matter what sits underneath. SSE still streams through Lambda because the container runs the Lambda Web Adapter with AWS_LWA_INVOKE_MODE=response_stream .The lesson: Keep the model transport pluggable behind the agent loop, and the loop, its tools and its events stay stable and testable.
The engine: Orchestrating a scientific pipeline on Step Functions
EngineRun is a Standard workflow. Each state is a Lambda container image (the scientific stack — cfgrib/eccodes, LightGBM, h3 — packaged cleanly in ECR), with a 15-minute per-function timeout and a 30-minute workflow timeout.text
ResolveRun→ Parallel(IngestFIRMS (catch → resilient fallback, pipeline continues),Map IngestGFSHour over f000..f072,IngestOpenAQ)→ Trajectories → Forecast → Gridding → Summarize → Publish
The handlers are deliberately thin — one per state, no heavy science logic inside handlers. They enforce a single execution envelope,
{run_id, degraded[]} , threading through the whole run:Python
# engine/plumetrace_engine/handlers.py@handler("ingest_openaq")def ingest_openaq(payload: dict) -> dict:payload = _env(payload)# OpenAQ supplies observations for verification, not the forecast itself.# Transient API rate-limits must NOT crash the pipeline — flag and continue.try:payload["openaq"] = openaq.ingest(_secret("plumetrace/openaq_key", "OPENAQ_API_KEY"))except Exception as exc:payload.setdefault("degraded", []).append("openaq")payload["openaq"] = {"degraded": True}return payload
Resilience and data transparency
This envelope architecture ensures high reliability and data provenance. If an upstream external sensor provider is temporarily rate-limited, the pipeline doesn't crash or hide gaps — it completes successfully, transparently flags which input feeds were impacted, and conveys that metadata directly to the UI banner and published summary. For an environmental intelligence system, honest data provenance and transparency build lasting trust.
The science, briefly
Back-trajectories
We build a
WindField from the GFS run — an xarray stack sampled bilinearly in space and linearly in time. The transport wind is the mean of the 925 hPa and 10 m winds, dropping to 10 m only where the boundary layer is shallow ( hpbl below threshold), because a shallow layer decouples 925 hPa from the surface:Python
u = 0.5 * (s["u925"] + s["u10"])v = 0.5 * (s["v925"] + s["v10"])shallow = s["hpbl"] < self.cfg.hpbl_shallow_mu = np.where(shallow, s["u10"], u) # surface-only transport in a shallow PBLv = np.where(shallow, s["v10"], v)
The forecast, with a model you can swap
The forecast layer is model-agnostic. At each cold start the engine loads a trained LightGBM p10/p50/p90 quantile model if its artifact is present in the model dir, and otherwise falls back to a persistence baseline — the same
predict_quantiles(df) → (p10, p50, p90) interface and the same output contract either way. Promoting the trained model to the live path is dropping q10/q50/q90.txt + metadata.json into the model dir; nothing in the pipeline changes.Python
# engine/.../model/infer.py — one interface, trained model or safe fallbackdef load_model(model_dir=None, cfg=CONFIG):if model_dir and Path(model_dir).exists():try:boosters = {a: lgb.Booster(model_file=f"{model_dir}/{n}.txt")for a, n in zip(QUANTILES, ("q10", "q50", "q90"))}return LightGBMQuantileModel(boosters, meta) # log-target, expm1, de-crossedexcept Exception:pass # a bad artifact never blocks a runreturn PersistenceModel() # persistence-v0 baseline
The LightGBM path trains on a log target, inverts with
expm1 , and fixes quantile crossing by sorting each row. The training side is a full offline pipeline — build_training_set.py → train.py → backtest.py over ERA5/FIRMS/OpenAQ backfills, with a SageMaker job option — and the Forecast-Skill page scores live skill separately from backtest, so the two are never conflated. Running the persistence baseline while the trained model is promoted through that skill gate is a deliberate staging choice: the counterfactual attribution, the live fire and wind inputs, and the p10–p90 bands are real regardless of which model sits behind the interface.Attribution is a counterfactual, not a label
For each station/lead we predict PM2.5 with the real fire load, then re-predict with fire features zeroed, and take the fire share as the clipped ratio of the difference — for every quantile:
Python
share = np.clip((full - nofire) / np.maximum(full, EPS), 0.0, 1.0)share.sort(axis=1) # keep p10 <= p50 <= p90 after the ratio
District shares then split that fire share by each district's fire load $FL_d / \sum FL_d$. We always publish the p10–p90 band. Reports never name individuals.
Gridding
Gridding is IDW (power 2, up to 6 nearest stations, cut off at 25 km, null beyond) onto an H3 res-7 grid over the NCR box — the shape
deck.gl renders directly.
Scaling compute and distributed execution
Orchestrating dozens of forecast hours and scientific libraries in a serverless environment required deliberate concurrency and resource profiling:
- Step Functions Map concurrency tuning: The GFS ingest maps over 73 forecast hours (f000–f072). We tune
maxConcurrencydirectly in CDK to throttle parallel downloads cleanly, matching network throughput without overloading downstream workers.
- Optimized memory profiling: Scientific containers handling atmospheric datasets were profiled to operate efficiently within allocated serverless memory envelopes, balancing cold-start speed and numerical performance.
- Resilient retry strategies: Upstream telemetry APIs have rate limits. We implement exponential backoff and jitter directly in Step Functions task definitions rather than relying on brittle client loops.
TypeScript
// infra/lib/engine-stack.ts — Step Functions Map with calibrated concurrencyconst gfsMap = new sfn.Map(this, "IngestGFS", {maxConcurrency: 3, // calibrated for optimal parallel throughputitemSelector: {"run_id.$": "$.run_id","fff.$": "$$.Map.Item.Value","degraded.$": "$.degraded"},});// Resilient retry with exponential backoff on throttlingt.addRetry({ /* exponential backoff on Lambda.TooManyRequestsException */ });
Other sharp edges worth naming
- GRIB decoding in Lambda: GFS GRIB2 files carry conflicting level coordinates that break an xarray merge. The fix was to drop the conflicting level-coords before merging and to store
valid_timeexplicitly asnumpy.datetime64. Cryptic to diagnose, one line to fix.
- Partial GFS downloads: Full GFS files are ~500 MB. We fetch byte ranges using the
.idxsidecar and crop to the AOI + 1°, which keeps each S3 object around 10 MB.
- DynamoDB GSI sort keys are single attributes: We needed to query "the map for this run at this valid hour," i.e. sort on
valid_hour#h3. GSIs don't take composite sort keys, so each Forecast item carries a dedicatedgsi1sk = "<valid_hour>#<h3>"and the API readsrun_id = :r AND begins_with(gsi1sk, :valid_hour).
- Multilingual audio delivery: Farmer alerts go out as Punjabi text (via Amazon Translate) paired with regional audio delivery via Amazon Polly. Correctness and accessibility over assumptions.
- Cloud builds, no local Docker dependency: The scientific image builds in CodeBuild and is pushed to ECR; CDK points the Lambdas at the new tag. Nobody's laptop RAM is on the critical path, and iteration loops remain smooth.
Limitations we state up front
The inputs are real — live satellite fires and live weather-model winds — and we'd rather name the limits than oversell. Two matter most scientifically:
- Satellite overpass bias: VIIRS passes are ~13:30 and ~01:30 local, and cloud/haze hides fires, so detected fire radiative power is a lower bound and a relative index, never a fire count.
- Fire-persistence assumption: for hours beyond the latest detection we assume detected fires persist (scaled down per day). This is the single biggest forecast uncertainty, and it's printed on every report.
The trajectory model is deliberately kinematic — not WRF-Chem or HYSPLIT-grade dispersion.
Scope notes: ground observations feed verification and the forecast-skill scoring (live vs backtest) and are gap-filled where sensors are sparse, rather than driving the forecast directly; fleet data is synthetic and labelled SIMULATED, with rider health confined to a KMS-encrypted table that's never exposed over the API.
The fleet solver: Capping exposure with CP-SAT
The gig-rider product re-plans delivery shifts to cap each rider's inhaled pollution dose. It's an OR-Tools CP-SAT model over 30-minute slots: boolean decision variables per
(order, rider, slot) , dose coefficients precomputed from the forecast grid, a soft 100% budget constraint with a large penalty, and an objective that minimizes the worst rider's exposure first:Python
model.Minimize(1000 * max_pct + total_dose + 2 * total_extra+ OVER_PENALTY * sum(over_terms))
A greedy solver is built first and always returns a feasible plan, so CP-SAT infeasibility or a timeout never breaks the demo. The target was 50 riders solved end-to-end in under 60 seconds; a 30-second solve limit across 8 workers hits it comfortably.
What we'd take to the next project
- Contract-first with real mocks is worth the two hours. It's what let five people build a tightly-coupled pipeline in parallel without stepping on each other.
- Build for graceful degradation. An envelope that tracks upstream data availability turns a fragile pipeline into a resilient system with full data lineage.
- Separate the swappable parts. Because the agent logic didn't know or care which transport sat behind it, the model layer stayed clean and modular.
- Calibrate concurrency and resources early. Building explicit concurrency tuning and retry policies into Step Functions ensures your system stays rock-solid under high volume.
PlumeTrace won't settle the stubble-burning debate. But it shows a path: real public data, an estimate with its uncertainty shown honestly, and an intelligent Bedrock Copilot with human approval before anything reaches a real person.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article