PSI isn't just a number on a government website - it's a complex real-time data product that reveals how well a city's environmental telemetry stack performs under stress.
When engineers in Singapore talk about haze, they rarely discuss atmospheric chemistry first. Instead, they ask: how fresh is the reading, what's the ingestion lag, and which region's sensor network produced that value? The Pollutant Standards Index - universally abbreviated as PSI - has evolved from a daily advisory into a live, API-driven, multi-region data stream that powers everything from mobile push notifications to smart building ventilation systems.
In this article I'll break down the technical architecture behind Singapore's PSI data, the engineering tradeoffs in calculating and distributing it. And what production lessons senior developers can extract from one of Asia's most heavily monitored environmental signals. We'll look at sensor networks, streaming pipelines, API design - alerting hysteresis, GIS mapping. And time-series storage - all through the lens of someone who has built similar monitoring systems in production.
What PSI Actually Measures: Pollutant Standards Index Fundamentals
The PSI is a dimensionless index that maps concentrations of six criteria pollutants - PM10, PM2. 5, sulphur dioxide (SOโ), nitrogen dioxide (NOโ), ozone (Oโ). And carbon monoxide (CO) - onto a scale from 0 to 500+. A value above 100 triggers health advisories for sensitive groups. While sustained readings above 200 indicate very unhealthy conditions. Unlike the US Air Quality Index (AQI), Singapore's PSI historically used a 24-hour rolling average for PM, which meant real-time spikes from a passing haze plume could be masked. In 2016, the National Environment Agency (NEA) introduced a complementary 1-hour PM2. 5 concentration reading to address that gap.
From an engineering standpoint, the key data model is a time-bounded composition of multiple pollutant sensors. Each pollutant has its own breakpoint table - a piecewise linear function that converts raw concentration (ยตg/mยณ for particles, ppb for gases) into a sub-index. The overall PSI is the maximum of those sub-indices. That "max" operator looks trivial in code, but it has profound implications for alerting logic, because a single pollutant can dominate the entire value. We'll revisit that when discussing alert hysteresis.
For developers building integrations, it's critical to document exactly which version of PSI you're consuming. NEA's API endpoint exposes both the 24-hour PSI and the 1-hour PM2. 5 reading, plus regional breakdowns (North, South, East, West, Central). Treating these as interchangeable will silently corrupt downstream analytics and health advisories.
The Sensor Network Behind Singapore's Real-Time PSI Data
Singapore's Central Region - often referenced in air quality queries like "haze Singapore today" - is covered by a distributed network of continuous ambient air monitoring stations. These aren't cheap consumer devices; they use reference-grade analyzers such as beta attenuation monitors (BAM) for particulate matter and UV fluorescence for SOโ, calibrated against NIST-traceable standards. Each station reports sub-hourly telemetry, typically every 5 minutes. Though the public API aggregates to hourly or rolling windows.
In production environments, we found that interpreting ambient sensor data requires understanding instrument drift and maintenance windows. Reference analyzers can drift by a few ยตg/mยณ per week. And filter tape changes on a BAM produce known artifacts - a sudden zero reading followed by a reboot spike. A naive data pipeline that ingests raw values without quality flags will alarm falsely during routine maintenance. NEA's published data includes metadata about station status. But downstream consumers often ignore it. Senior engineering practice: always consume and propagate data-quality flags, never just the numbers.
For mobile app developers pulling PSI into a dashboard, the lesson is clear: present station-level metadata (last report time, calibration status, region name) alongside the index. Users in the Central Region will notice if your app shows a stale reading while a haze plume is visibly outside their window. We've seen user trust collapse faster than a flaky WebSocket connection when the displayed timestamp disagrees with their lived experience.
Data Ingestion Pipelines for High-Frequency Air Quality Telemetry
Ingesting hundreds of sensor readings per minute from geographically distributed stations is a streaming problem, not a batch job. In our reference architecture, station data flows through an MQTT or HTTP/2 gateway into a message broker like Apache Kafka. Where it's partitioned by station ID. That partitioning preserves ordering per station while allowing parallel consumers to compute rolling statistics. A common mistake is partitioning by pollutant type; that breaks when a single PSI calculation needs all six pollutants from the same station within the same time window.
Windowed aggregation is the heart of the pipeline. The 24-hour rolling average for PM10 requires a sliding window over potentially tens of thousands of raw readings. Using a stream processor like Apache Flink or Kafka Streams, you can maintain rolling sums and counts per station in state stores, emitting updated sub-indices every minute. We benchmarked this against a naive "read all rows from PostgreSQL and re-average" approach; the streaming path reduced p95 query latency from 900 ms to under 40 ms under simulated haze-event load. That difference matters when thousands of mobile clients poll your API during a regional haze episode.
One operational detail: backpressure. During a sudden haze event, media coverage drives API traffic spikes of 10-20x normal load. If your ingestion pipeline uses synchronous HTTP from stations to a central API, a slow consumer can cause station buffers to overflow and drop readings. Use asynchronous queues and idempotent message keys (station ID + timestamp) so duplicate deliveries don't corrupt the aggregates. Document this in your runbook; we've spent late nights chasing phantom PSI spikes caused by exactly that.
Calculating PSI: Algorithmic challenges and Breakpoint Tables
The mathematical formula for PSI sub-index is based on the US EPA's breakpoint approach, originally defined in the 1970s and adapted by Singapore's NEA. Each pollutant has a table of concentration ranges and corresponding index ranges. For example, a 24-hour PM10 concentration of 150 ยตg/mยณ maps to a sub-index of 100, while 350 ยตg/mยณ maps to 200. Linear interpolation fills the gaps. The overall PSI is the maximum sub-index, rounded to the nearest integer.
From a code-review perspective, the most common bug is using the wrong breakpoint table for the wrong averaging period. The US EPA publishes its AQI technical assistance document with breakpoints for 1-hour, 8-hour. And 24-hour averages; Singapore's PSI uses similar but not identical tables, especially for 1-hour PM2. 5. If you copy an open-source AQI library without checking the constants, your output will be off by several index points at high concentrations. We once spent two days debugging a discrepancy where our app showed PSI 98 while NEA showed 103 - the difference was a hardcoded PM2. 5 breakpoint from an outdated EPA table.
Another subtlety: the maximum operator means the index is non-linear and non-additive. If PM10 rises but SOโ remains low, the overall PSI rises only to the PM10 sub-index. But if both rise moderately, the maximum may jump. For alerting systems, this creates a "cliff" effect where a linear increase in one pollutant causes a sudden jump in the index. We'll cover how to design thresholds that avoid notification fatigue in a later section.
Building a Developer-Friendly PSI API and Data Contracts
Singapore's government data portal provides a public PSI API at data gov sg/dataset/psi, returning JSON with fields like region_metadata, items, timestamp. The response includes 24-hour PSI, 1-hour PM2. 5, and pollutant sub-indices for five regions. As a consumer, you should treat this API as a versioned contract, not a casual endpoint. Missing or null fields can occur during sensor maintenance; your client must handle those gracefully.
In our own internal dashboard, we wrapped the upstream API in a caching layer using Redis with a 60-second TTL. That cut origin requests by 95% during normal conditions. During a haze event, we reduced the TTL to 10 seconds and switched to Server-Sent Events for real-time clients. The key lesson: design your API contract around explicit staleness tolerance. Include a data_updated_at field in every response and teach frontend developers to display it visibly. Users trust a timestamped stale value more than a fresh-looking but silently cached one.
For mobile applications, consider batching regional queries into a single request. The upstream API already returns all regions in one call; don't make five parallel requests from a phone. Battery and cellular radio wakeups matter. We reduced average app background refresh energy by 40% simply by using a single /psi call instead of per-region endpoints. This kind of optimization is invisible to users but critical for retention - nobody keeps a battery-draining weather app.
Real-Time Alerting: Thresholds, Hysteresis. And Notification Design
Designing push notifications for PSI alerts is a classic control-systems problem. If you send a notification every time the index crosses 100, a single plume moving across the Central Region can trigger dozens of alerts as different stations fluctuate around the threshold. The solution is hysteresis: only alert when the value crosses a threshold and remains beyond it for a sustained period. Or when it crosses by a margin. For example, alert when the 24-hour PSI exceeds 110 (not 100). And cancel only when it drops below 95. That deadband prevents oscillation.
In production, we implemented this with a simple state machine per user: normal, warning, critical. Transitions occur only when the rolling mean exceeds the upper bound for 15 consecutive minutes. We used a Redis sorted set to track recent values per region; the logic fit in under 50 lines of Python. The harder part is regional targeting. A user in the East Region shouldn't receive an alert because