AWS Builder Center

The Complaint Wasn't the Hard Part: Building an Explainable Urban Incident Engine

When citizens report urban environmental hazards, municipal teams are flooded with noise. Here is how we built CityPulse—an open-source incident intelligence engine featuring 6-factor deterministic scoring, spatial 2D grid clustering, evidence provenance verification, and an honest look at build-time SPA deployment traps.

Title: The Complaint Wasn't the Hard Part: Building an Explainable Urban Incident Engine
Author: Manikanta Oruganti
Executive Summary
When urban environmental incidents strike—whether monsoon waterlogging in Begumpet or chemical fumes in Patancheru—municipalities aren't starved for complaints. They are flooded by them. The real engineering bottleneck isn't collecting citizen feedback; it is triage, spatial clustering, and explainable prioritization.
This article walks through the architecture, design decisions, mathematical algorithms, and real-world debugging stories behind CityPulse: an open-source environmental incident intelligence platform. We explore how we built a 6-factor deterministic scoring engine (0 to 100 scale), an O(N) 2D spatial grid hotspot detector without heavy GIS database overhead, an evidence provenance engine, and how we resolved a subtle build-time environment variable bug when deploying a React 18 / Node.js stack across cloud environments.
1. The Real Bottleneck in Civic Incident Response
Imagine sitting in a municipal emergency dispatch center during a heavy monsoon downpour in Hyderabad or Bengaluru. Within a two-hour window, 500 incident reports land in the queue:
- Water pooling near subway entrance
- Stormwater drain blocked by plastic trash
- Chemical odor detected near industrial outlet
- Deep water accumulation under flyover
- Streetlight not working on main road
If every report is logged as "High Urgency" by concerned citizens, how does a municipal team decide which three crews to dispatch first?
The Municipal Triage Dilemma:
- The First-Come, First-Served Queue: Minor litter reports filed at 8:00 AM get serviced before a catastrophic water main rupture reported at 8:15 AM.
- The Black-Box Machine Learning Model: Predictive AI outputs an arbitrary score (such as 0.892) without explaining why. When field operations engineers don't understand the rationale, they ignore the score entirely.
We designed CityPulse to eliminate both failures by introducing explainable deterministic scoring, multi-source evidence corroboration, and automated 2D grid hotspot detection.
2. System Architecture & Component Topology

CityPulse is architected as a lightweight client-server platform. It pairs a React 18 / TypeScript single-page application with a Node.js Express ES-module backend service.
System Flow:
Citizen / Reviewer -> React 18 SPA (Netlify) -> Express API Gateway (Render) -> Domain Validation & Scoring Engines -> Atomic File-Backed Persistence Store (issues.json)
Key Components:
- Frontend Tier (/frontend): Built with React 18, TypeScript, Vite, and Tailwind CSS. Features five interactive views: Overview Dashboard, Issues Explorer, Hotspot Hub, Analytics Center, and Report Incident Interface.
- Backend Service (/backend): Node.js Express ES-module server with dynamic CORS configuration, input sanitization, and structured route handlers (/api/issues and /api/environmental).
- Persistence Engine (backend/services/persistence.js): A file-backed JSON storage layer using an atomic write-and-rename mechanism (issues.json.tmp -> issues.json) to eliminate file corruption risks during process interrupts.
3. The Math Behind Explainable Incident Prioritization

Instead of opaque neural networks, CityPulse evaluates every report against a deterministic 100-point scoring budget. The total score is computed from six distinct factors, followed by a status lifecycle multiplier.
Scoring Formula Overview:
Priority Score = (Base Severity + Category Impact + Recurrence Boost + Waiting Time + Evidence Confidence + Community Upvotes) * Status Multiplier
Factor Weighting & Formula Breakdown:
- Base Severity (Max 30 pts): critical = 30 pts, high = 20 pts, medium = 10 pts, low = 5 pts. (Source: issue.severity)
- Category Environmental Impact (Max 20 pts): industrial-waste = 20 pts, air-pollution = 18 pts, waterlogging = 15 pts, waste-accumulation = 15 pts, drainage-blockage = 12 pts, water-leak = 10 pts. (Source: issue.category)
- Geographic Recurrence (Max 15 pts): Count of active nearby reports within 0.005 degrees (~550m) over 30 days. 5 or more reports = 15 pts, 3 to 4 reports = 10 pts, 1 to 2 reports = 5 pts. (Source: assessRecurrence())
- Awaiting Resolution Time (Max 15 pts): min(Math.round(hoursElapsed * 0.25), 15). (Source: Date.now() - createdAt)
- Evidence Confidence (Max 10 pts): verified = 10 pts, corroborated = 7 pts, single_report = 3 pts, unconfirmed = 0 pts. (Source: calculateConfidence())
- Community Support (Max 10 pts): min(votes * 1, 10). (Source: issue.votes)
Status Multiplier Adjustment:
Once the raw factor sum is calculated, CityPulse adjusts the priority score based on municipal lifecycle progress:
- reported (1.0x multiplier): Full score preserved (active queue).
- in-progress (0.7x multiplier): 30% priority reduction reflecting active municipal dispatch.
- resolved (0.0x multiplier): Score forced to 0 (incident resolved).
Transparent Factor Breakdowns:
Every API response returns a structured explanation array alongside the numeric score. For example, a GET /api/environmental/issues/:id/explanation query returns JSON data with explicit factor names, point scores, max points, and reason strings.
This transparency ensures city engineers can audit why an incident scored 87 and verify the individual factors driving its priority.
4. Spatial Hotspot Detection: O(N) Grid Partitioning

A single citizen reporting water on a street is an observation. Five citizens reporting water within a 300-meter radius over two days signals a structural drainage failure.
To detect localized hotspots in real-time without introducing heavy GIS spatial database extensions (like PostGIS), CityPulse implements a 2D spatial grid partitioning algorithm (backend/services/hotspotDetector.js).
Hotspot Detection Flow:
Load Active Reports -> Filter Window <= 30 Days -> Assign Grid Cell Key -> Group Reports by Key -> Count >= 3 Reports? -> Form Hotspot Cluster -> Calculate Centroid & Priority Metrics
Mathematical Grid Mapping:
Coordinates are discretized into integer cell indices by dividing by cellSizeDegrees (0.005 degrees) and rounding down via Math.floor:
cellLat = Math.floor(latitude / 0.005)
cellLng = Math.floor(longitude / 0.005)
GridKey = cellLat + "_" + cellLng
Reports sharing the same GridKey are grouped into hash map buckets in O(N) execution time. If a bucket contains 3 or more active unresolved reports submitted within the last 30 days, it triggers a Spatial Hotspot Cluster.
Geographic Ground Distance Variation:
While 1 degree of latitude equals approximately 111 km everywhere on Earth (yielding a fixed north-south cell height of 0.005 * 111 km = 555 meters), 1 degree of longitude contracts with latitude by cos(latitude):
East-West Cell Width (km) = 111 * 0.005 * cos(latitude)
As a result, a 0.005-degree grid cell adapts across target urban zones:
- Equator (0 degrees): about 555 meters by 555 meters
- Hyderabad (17.44 degrees N): about 555 meters (N-S) by 530 meters (E-W)
- Bengaluru (12.97 degrees N): about 555 meters (N-S) by 540 meters (E-W)
- Mid-Latitudes (40.00 degrees N): about 555 meters (N-S) by 425 meters (E-W)
5. Evidence Provenance & Confidence Engine


Not all civic reports carry equal evidentiary weight. CityPulse evaluates metadata attached to incidents to assign a transparent confidence state:
- verified (Score: 100): Incident attached to an official or sensor record marked verificationState = 'verified'.
- corroborated (Score: 70): At least 3 independent non-simulated citizen evidence submissions attached.
- single_report (Score: 30): 1 to 2 non-simulated citizen reports attached.
- unconfirmed (Score: 0): No evidence attached, or only simulated demo records present.
Important Disclosure: Confidence states reflect how well-supported a report is by citizen observations or official notices. They do not represent laboratory chemical pollution measurements.
6. An Authentic Engineering Challenge:


What Fought Back: The Dashboard Worked, but the Data Never Arrived

We deployed the Express backend to Render and the React frontend to Netlify. The dashboard loaded cleanly, so at first glance, the deployment looked successful.
Then we checked the data.
Every dynamic widget showed zero records. The browser's developer tools revealed repeated failed API requests to the local development server's health, issues, and environmental hotspots endpoints. Each request returned  ERR_CONNECTION_REFUSED .
The problem wasn't the scoring algorithm, the hotspot detector, or the dashboard UI. It was the API URL.
During local development,  localhost:3001  referred to the machine running the Express server. But once the frontend was deployed,  localhost  referred to the visitor's own device. A visitor opening CityPulse on Netlify would therefore try to contact a backend running on their own computer.
There was no such server, so the requests failed.
The fix was to configure the production frontend to use the deployed Render API instead of the local development address. We also needed to ensure the production build actually included that configuration; changing an environment variable alone would not repair an already-built static bundle.
This was a useful reminder that a successful frontend deployment does not prove that the application works end to end. The interface can render perfectly while every data-dependent feature is disconnected.
For CityPulse, the lesson was simple: test the deployed application from the browser's network requests, not just from how the page looks.

The Root Cause

In Vite applications,  import.meta.env.VITE_API_URL  is replaced at build time when  npm run build  runs.
When Netlify built the frontend without an explicit  VITE_API_URL  build variable, Vite evaluated the value as  undefined . The fallback API URL,  api , was then compiled into the JavaScript bundle.
Consequently, visitors opening CityPulse on Netlify had their browsers send API requests to port 3001 on their own devices instead of contacting the deployed Express backend on Render. Because no backend was running on those devices, the requests failed with  ERR_CONNECTION_REFUSED .

The Solution

We updated  frontend/src/services/api.ts  with a multi-stage API URL resolution function. It handles trailing slashes, duplicate  /api  segments, and production hostname fallbacks. When a build-time API URL is unavailable, it checks  window.location.hostname !== 'localhost'  and falls back to the deployed Render backend URL.
We also created  frontend/.env.production  with the production API URL and rebuilt the frontend using:
 npm --prefix frontend run build 
Finally, we inspected the compiled production bundles and verified that they targeted the live Render service instead of the local development server.
7. Synthetic Seeding at Scale:

1,200 Reports in Hyderabad & Bengaluru
To test the system under realistic load, we built a dedicated CLI seeder (backend/scripts/seedDemo.js).
Seeder Command:
npm run seed:demo -- --clean --count 1200
The dataset distributes 1,200 synthetic records (isSimulated = true) across 16 geographic anchors in Hyderabad and Bengaluru while preserving all 11 original user reports (1,211 total dataset size).
8. Cloud Architecture & AWS Migration Strategy
While CityPulse currently operates on Netlify and Render to maintain a zero-cloud-cost footprint for hackathon evaluation, the entire platform is containerized and architected for effortless migration to Amazon Web Services (AWS).
AWS Target Architecture:
Amazon CloudFront CDN -> Amazon S3 Static Bucket (React 18 SPA) -> AWS Application Load Balancer -> AWS App Runner / Amazon ECS Fargate (Express API Container) -> Amazon Aurora PostgreSQL + PostGIS & Amazon S3 (Evidence Media Storage)
AWS Cloud Migration Roadmap:
1. Container Hosting (AWS App Runner / Amazon ECS Fargate): The existing frontend/Dockerfile and backend/Dockerfile specs are fully production-ready. Migrating the Express API from Render to AWS App Runner or Amazon ECS with Fargate requires zero code changes.
2. Database Migration (Amazon Aurora PostgreSQL + PostGIS): The current file-backed JSON persistence layer (issues.json) can be upgraded to Amazon Aurora PostgreSQL with the PostGIS extension. This unlocks native spatial indexing (ST_DWithin, ST_ClusterDBSCAN) for multi-region scale.
3. Evidence Binary Storage (Amazon S3): Current evidence records store descriptive metadata only. Integrating Amazon S3 presigned URLs will enable citizens to upload high-resolution photos and video evidence directly to S3 buckets.
9. Limitations & System Assessment
An honest engineering assessment requires acknowledging current design constraints:
1. File-Backed Persistence Concurrency: backend/services/persistence.js uses single-process synchronous file reads and writes (fs.renameSync). Multi-instance container clusters require migrating to PostgreSQL to prevent write lock conflicts.
2. Public Hackathon Demo Mode: Endpoints operate without JWT/OAuth authentication or role-based access control (RBAC).
3. Grid Boundary Artifacts: Fixed 0.005-degree 2D grid partitioning means two reports located 10 meters apart across an exact grid boundary line fall into adjacent distinct grid cells.
10. Key Takeaways
Building CityPulse reinforced three core software engineering principles:
  1. Deterministic Math Builds User Trust: Complex machine learning models often fail in civic tech because domain experts cannot audit their reasoning. A weighted, factor-based scoring engine with explicit mathematical breakdowns gives engineers 100% auditability.
  1. Simple Algorithms Beat Heavy Infrastructure Early On: Before reaching for PostGIS or spatial index clusters, a lightweight O(N) 2D grid partitioning formula (Math.floor(lat / 0.005)) provided instant spatial hotspot detection with zero external database dependencies.
  1. Defend Against Build-Time SPA Environment Collapses: Never assume static environment variables (VITE_*) will be present in third-party CI/CD pipelines. Always engineer fallback URL resolution logic that inspects window.location.hostname at runtime.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article