Haze in Singapore by year and part of the island
Pick a part of Singapore to see how many hours of unhealthy air it had in each year since 2014. NEA's 24-hour PSI, one reading an hour for five parts, to 13 Oct 2026, 12pm.
Part of Singapore
An hour counts as unhealthy here when NEA's 24-hour PSI for a part is 101 or higher. NEA calls 101 to 200 unhealthy, 201 to 300 very unhealthy and over 300 hazardous.
How much unhealthy air was there?
At least one part of Singapore, in 2026 so far (to 13 Oct)
The most in this view was 856 in 2015. The year is not over.
Year by year
Each row is a year, the most first. A longer bar means more. Hover or tap a row for details, and click to look at that year.
At least one part of Singapore: 2015 had the most (856 hours). 4 of the 13 years had none.
Which part of Singapore, in 2026 so far (to 13 Oct)?
Five circles, one for each part, sized by the count. They sit near the middle of each part; NEA names five regions but gives no boundaries, so the positions are approximate. Click a circle or a row to pick a part.
In 2026 so far (to 13 Oct), Central had the most (447 hours), then West (313), East (301), South (219) and North (193).
Every year, every part
The darker the cell, the higher the count. The outlined cell is the part with the most in that year. Click a cell to look at it.
| Year | AnywhereAny | NorthN | SouthS | EastE | WestW | CentralC | All fiveAll 5 |
|---|---|---|---|---|---|---|---|
Part with the most, by year (outlined above): 2014: West; 2015: West; 2016: North; 2017: North; 2019: South; 2021: North; 2023: East; 2025: North; 2026 so far: Central. Years not listed have none.
2026, day by day
The highest 24-hour PSI on each day from 1 Aug to 13 Oct 2026, for the part you picked. Dark bars are days that reached 101 or higher. The dotted line is 101.
At least one part of Singapore: 27 of the 74 days had a reading of 101 or more. The highest was 192 on 9 Oct.
Fun finds
The short version
Central had the most hours at PSI 101+ in 2026 so far (447); West in 2015 (836). By year and part of Singapore since 2014. NEA data, 13 Oct 2026.
What we did. We took NEA's 24-hour PSI for the five parts of Singapore, one reading an hour, from April 2014 to 13 Oct 2026, 12pm (109,884 hours). The history is the data.gov.sg file and the last weeks (from 1 Sep 2026) are NEA's real-time feed. For each year and each part we counted the hours with a PSI of 101 or higher, the days with such an hour, the highest reading and the longest stretch in a row. "Anywhere" is any hour when at least one part was at 101 or higher; "all five" is any hour when every part was.
What we found. In 2026 so far, Central had the most hours at 101 or higher (447), then West (313), East (301), South (219) and North (193). All five parts were at 101 or higher together for 179 hours on 12 days, the first time at 15 Sep 2026, 1am; before that it last happened at 23 Sep 2019, 3pm. The highest reading this year is 192 in Central, from 5pm to 8pm on 9 Oct 2026. In 2015 the same count was 836 hours in the West, 856 hours anywhere, and the highest reading was 322 in the South; in 2019 it was 147 hours in the South and 156 anywhere. The part with the most hours has not been the same each time: North: 2016, 2017, 2021 and 2025; West: 2014 and 2015; South: 2019; East: 2023; Central: 2026 so far.
What to keep in mind. The PSI is a rolling 24-hour reading, so an hour at 101 or more means the last 24 hours were at that level, not that one hour was. 2026 is not over, and haze can run past 13 Oct (in 2015, 269 of the 856 hours came after it); the "same dates" view compares each year only up to 13 Oct. NEA gives five regions, not exact places, so a street can differ from its region. We do not say what caused any haze, and we did not check whether NEA changed how the PSI is worked out over these years.
By Ben (opens in a new tab). Updated . Data: NEA 24-hour PSI on data.gov.sg and NEA's real-time PSI feed, to 13 Oct 2026, 12pm.
Questions people ask
How many hours of unhealthy air has Singapore had each year?
Counting an hour when at least one of NEA's five parts had a 24-hour PSI of 101 or higher: 2014 (from April) 114, 2015 856, 2016 55, 2017 5, 2018 0, 2019 156, 2020 0, 2021 6, 2022 0, 2023 30, 2024 0, 2025 4, 2026 (so far) 447. Data to 13 Oct 2026, 12pm.
Which part of Singapore had the most unhealthy hours in 2026?
Central, with 447 hours at 101 or higher up to 13 Oct 2026. Then West (313), East (301), South (219) and North (193).
Is the haze in 2026 worse than in 2019 or 2015?
By these counts, 2026 so far is higher than 2019 and lower than 2015. Hours anywhere at 101 or higher: 447 in 2026 so far, 156 in 2019, 856 in 2015. All five parts at once: 179, 75 and 596 hours. Highest reading: 192, 154 and 322. Hours at 201 or higher: 0, 0 and 81. 2026 is not over, so its counts can still grow.
Which part had the most unhealthy hours in earlier hazy years?
2015: West (836), South (781), North (741), East (683) and Central (603). 2019: South (147), West (108), East (100), North (79) and Central (78). 2014: West (83), South (76), East (31), North (29) and Central (24).
Is it always the same part of Singapore?
No. The part with the most hours was North in 2016, 2017, 2021 and 2025; West in 2014 and 2015; South in 2019; East in 2023; Central in 2026 so far. Central had the fewest of the five in 2014, 2015 and 2019, and the most in 2026 so far.
When were all five parts unhealthy at the same time?
In 2026 so far for 179 hours on 12 days, first at 15 Sep 2026, 1am and still the case at the last reading on 13 Oct 2026. Before this year the last time was 23 Sep 2019, 3pm. By year: 2014 19, 2015 596, 2019 75, 2026 179 hours.
What was the highest PSI reading?
In this data the highest reading was 322, in the South on 25 Sep 2015, 8am. In 2026 it is 192, in Central, from 5pm to 8pm on 9 Oct 2026. In 2019 it was 154 (South, 19 Sep 2019, 2am).
What is the PSI and what do the numbers mean?
The Pollutant Standards Index is NEA's air quality number. NEA works out a sub-index for each pollutant and takes the highest as the PSI. The 24-hour PSI looks back over the last 24 hours. NEA's bands: 0 to 50 good, 51 to 100 moderate, 101 to 200 unhealthy, 201 to 300 very unhealthy, over 300 hazardous.
What does an hour at 101 or higher mean here?
NEA publishes the 24-hour PSI every hour for each of five regions. We count one hour for each hourly reading of 101 or higher. Because the PSI looks back 24 hours, a stretch of hours at 101 or higher shows how long the 24-hour reading stayed at that level, not how many hours the air itself was bad.
Why is 2026 compared with whole years?
It is not, unless you choose it. The default view shows each year as a whole, and 2026 is only 6,853 hours old. Choose "same dates each year" to compare every year only from 1 Jan to 13 Oct. In 2015, 269 of the 856 unhealthy hours came after 13 Oct.
Does this say what caused the haze or what to do?
No. It counts NEA's readings. For health advice, see NEA's own advisories.
Where does the data come from?
NEA's 24-hour PSI on data.gov.sg: the historical hourly file for April 2014 to 31 Aug 2026, and NEA's real-time PSI feed for 1 Sep 2026 to 13 Oct 2026, 12pm. Details are in the boring bit below.
The numbers behind it
| Year | Hours of data | Anywhere | North | South | East | West | Central | All five | Anywhere, 201 or higher |
|---|---|---|---|---|---|---|---|---|---|
| 2014 (from 1 Apr) | 6,599 | 114 | 29 | 76 | 31 | 83 | 24 | 19 | 0 |
| 2015 | 8,760 | 856 | 741 | 781 | 683 | 836 | 603 | 596 | 81 |
| 2016 | 8,784 | 55 | 54 | 13 | 0 | 26 | 13 | 0 | 0 |
| 2017 | 8,760 | 5 | 5 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2018 | 8,760 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2019 | 8,760 | 156 | 79 | 147 | 100 | 108 | 78 | 75 | 0 |
| 2020 | 8,784 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2021 | 8,760 | 6 | 6 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2022 | 8,760 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2023 | 8,760 | 30 | 0 | 2 | 30 | 0 | 26 | 0 | 0 |
| 2024 | 8,784 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2025 | 8,760 | 4 | 4 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2026 so far | 6,853 | 447 | 193 | 219 | 301 | 313 | 447 | 179 | 0 |
| Year | Hours of data | Anywhere | North | South | East | West | Central | All five | Anywhere, 201 or higher |
|---|---|---|---|---|---|---|---|---|---|
| 2014 | 4,703 | 76 | 18 | 56 | 24 | 47 | 16 | 12 | 0 |
| 2015 | 6,864 | 587 | 517 | 526 | 470 | 568 | 420 | 413 | 64 |
| 2016 | 6,888 | 55 | 54 | 13 | 0 | 26 | 13 | 0 | 0 |
| 2017 | 6,864 | 5 | 5 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2018 | 6,864 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2019 | 6,864 | 144 | 79 | 138 | 96 | 108 | 78 | 75 | 0 |
| 2020 | 6,888 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2021 | 6,864 | 6 | 6 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2022 | 6,864 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2023 | 6,864 | 30 | 0 | 2 | 30 | 0 | 26 | 0 | 0 |
| 2024 | 6,888 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2025 | 6,864 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2026 so far | 6,853 | 447 | 193 | 219 | 301 | 313 | 447 | 179 | 0 |
| Year | Anywhere | North | South | East | West | Central | All five |
|---|---|---|---|---|---|---|---|
| 2014 (from 1 Apr) | 9 | 3 | 7 | 4 | 7 | 2 | 2 |
| 2015 | 46 | 43 | 42 | 39 | 45 | 38 | 38 |
| 2016 | 4 | 4 | 2 | 0 | 2 | 2 | 0 |
| 2017 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
| 2018 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2019 | 11 | 5 | 11 | 8 | 8 | 5 | 5 |
| 2020 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2021 | 2 | 2 | 0 | 0 | 0 | 0 | 0 |
| 2022 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2023 | 2 | 0 | 1 | 2 | 0 | 2 | 0 |
| 2024 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2025 | 1 | 1 | 0 | 0 | 0 | 0 | 0 |
| 2026 so far | 27 | 12 | 14 | 17 | 17 | 27 | 12 |
| Year | Anywhere | North | South | East | West | Central | All five | When the highest reading anywhere was first reached |
|---|---|---|---|---|---|---|---|---|
| 2014 | 125 | 108 | 124 | 116 | 125 | 113 | 107 | 7 Oct 2014, 9am, West |
| 2015 | 322 | 302 | 322 | 285 | 321 | 268 | 268 | 25 Sep 2015, 8am, South |
| 2016 | 143 | 143 | 105 | 91 | 143 | 108 | 91 | 27 Aug 2016, 7am, North |
| 2017 | 143 | 143 | 96 | 89 | 83 | 80 | 73 | 15 Mar 2017, 9pm, North |
| 2018 | 91 | 75 | 83 | 77 | 91 | 84 | 69 | 15 Mar 2018, 7pm, West |
| 2019 | 154 | 131 | 154 | 145 | 135 | 131 | 131 | 19 Sep 2019, 2am, South |
| 2020 | 84 | 84 | 69 | 71 | 66 | 76 | 65 | 19 Oct 2020, 6pm, North |
| 2021 | 124 | 124 | 66 | 74 | 66 | 75 | 63 | 28 Mar 2021, 7pm, North |
| 2022 | 75 | 71 | 66 | 73 | 66 | 75 | 64 | 13 Apr 2022, 6am, Central |
| 2023 | 123 | 96 | 101 | 123 | 67 | 117 | 67 | 7 Oct 2023, 8pm, East |
| 2024 | 77 | 70 | 67 | 73 | 76 | 77 | 61 | 26 Jul 2024, 2pm, Central |
| 2025 | 114 | 114 | 66 | 73 | 71 | 77 | 65 | 30 Nov 2025, 6pm, North |
| 2026 so far | 192 | 136 | 168 | 170 | 187 | 192 | 136 | 9 Oct 2026, 5pm, Central |
2026 day by day: the highest reading on each of 74 days
| Day | Anywhere | North | South | East | West | Central | All five |
|---|---|---|---|---|---|---|---|
| 1 Aug 2026 | 62 | 56 | 56 | 57 | 56 | 62 | 55 |
| 2 Aug 2026 | 61 | 56 | 55 | 58 | 59 | 61 | 54 |
| 3 Aug 2026 | 67 | 56 | 56 | 59 | 62 | 67 | 56 |
| 4 Aug 2026 | 67 | 56 | 56 | 60 | 62 | 67 | 56 |
| 5 Aug 2026 | 62 | 55 | 55 | 59 | 59 | 62 | 54 |
| 6 Aug 2026 | 74 | 59 | 62 | 67 | 66 | 74 | 59 |
| 7 Aug 2026 | 74 | 58 | 62 | 67 | 66 | 74 | 58 |
| 8 Aug 2026 | 71 | 60 | 59 | 63 | 61 | 71 | 59 |
| 9 Aug 2026 | 81 | 65 | 64 | 71 | 71 | 81 | 64 |
| 10 Aug 2026 | 80 | 64 | 61 | 72 | 74 | 80 | 61 |
| 11 Aug 2026 | 80 | 65 | 62 | 72 | 74 | 80 | 62 |
| 12 Aug 2026 | 78 | 62 | 62 | 69 | 71 | 78 | 61 |
| 13 Aug 2026 | 77 | 63 | 63 | 68 | 69 | 77 | 63 |
| 14 Aug 2026 | 77 | 63 | 66 | 70 | 67 | 77 | 63 |
| 15 Aug 2026 | 81 | 63 | 68 | 69 | 70 | 81 | 63 |
| 16 Aug 2026 | 78 | 61 | 63 | 67 | 68 | 78 | 61 |
| 17 Aug 2026 | 73 | 60 | 61 | 65 | 66 | 73 | 59 |
| 18 Aug 2026 | 72 | 59 | 60 | 65 | 63 | 72 | 59 |
| 19 Aug 2026 | 75 | 61 | 62 | 67 | 65 | 75 | 59 |
| 20 Aug 2026 | 76 | 63 | 60 | 68 | 65 | 76 | 60 |
| 21 Aug 2026 | 74 | 60 | 63 | 64 | 64 | 74 | 60 |
| 22 Aug 2026 | 80 | 62 | 67 | 70 | 70 | 80 | 62 |
| 23 Aug 2026 | 82 | 64 | 66 | 71 | 71 | 82 | 64 |
| 24 Aug 2026 | 98 | 72 | 75 | 87 | 79 | 98 | 72 |
| 25 Aug 2026 | 99 | 71 | 74 | 87 | 80 | 99 | 71 |
| 26 Aug 2026 | 89 | 65 | 73 | 74 | 73 | 89 | 65 |
| 27 Aug 2026 | 75 | 63 | 63 | 66 | 68 | 75 | 63 |
| 28 Aug 2026 | 79 | 63 | 67 | 67 | 70 | 79 | 62 |
| 29 Aug 2026 | 84 | 63 | 71 | 71 | 72 | 84 | 63 |
| 30 Aug 2026 | 78 | 63 | 67 | 68 | 68 | 78 | 63 |
| 31 Aug 2026 | 69 | 58 | 62 | 64 | 64 | 69 | 58 |
| 1 Sep 2026 | 76 | 62 | 62 | 66 | 68 | 76 | 62 |
| 2 Sep 2026 | 77 | 62 | 65 | 68 | 69 | 77 | 62 |
| 3 Sep 2026 | 88 | 69 | 72 | 76 | 80 | 88 | 69 |
| 4 Sep 2026 | 123 | 88 | 87 | 104 | 99 | 123 | 87 |
| 5 Sep 2026 | 123 | 87 | 88 | 104 | 103 | 123 | 87 |
| 6 Sep 2026 | 93 | 75 | 79 | 91 | 86 | 93 | 73 |
| 7 Sep 2026 | 98 | 74 | 81 | 93 | 90 | 98 | 74 |
| 8 Sep 2026 | 83 | 66 | 69 | 75 | 76 | 83 | 66 |
| 9 Sep 2026 | 102 | 76 | 81 | 83 | 87 | 102 | 76 |
| 10 Sep 2026 | 106 | 77 | 80 | 87 | 87 | 106 | 77 |
| 11 Sep 2026 | 99 | 76 | 77 | 86 | 85 | 99 | 75 |
| 12 Sep 2026 | 82 | 70 | 72 | 80 | 79 | 82 | 70 |
| 13 Sep 2026 | 84 | 69 | 71 | 80 | 75 | 84 | 69 |
| 14 Sep 2026 | 141 | 97 | 106 | 121 | 117 | 141 | 97 |
| 15 Sep 2026 | 154 | 104 | 111 | 128 | 128 | 154 | 104 |
| 16 Sep 2026 | 112 | 83 | 80 | 93 | 96 | 112 | 80 |
| 17 Sep 2026 | 103 | 75 | 70 | 81 | 95 | 103 | 70 |
| 18 Sep 2026 | 102 | 74 | 70 | 80 | 93 | 102 | 70 |
| 19 Sep 2026 | 113 | 84 | 82 | 97 | 96 | 113 | 82 |
| 20 Sep 2026 | 123 | 85 | 89 | 102 | 106 | 123 | 85 |
| 21 Sep 2026 | 93 | 68 | 70 | 73 | 90 | 93 | 68 |
| 22 Sep 2026 | 84 | 65 | 69 | 74 | 77 | 84 | 65 |
| 23 Sep 2026 | 73 | 62 | 62 | 70 | 73 | 67 | 62 |
| 24 Sep 2026 | 84 | 68 | 69 | 79 | 84 | 78 | 68 |
| 25 Sep 2026 | 81 | 64 | 63 | 72 | 77 | 81 | 63 |
| 26 Sep 2026 | 115 | 80 | 84 | 99 | 97 | 115 | 80 |
| 27 Sep 2026 | 132 | 92 | 97 | 119 | 106 | 132 | 92 |
| 28 Sep 2026 | 117 | 79 | 111 | 98 | 107 | 117 | 79 |
| 29 Sep 2026 | 167 | 109 | 133 | 139 | 150 | 167 | 109 |
| 30 Sep 2026 | 138 | 104 | 112 | 128 | 135 | 138 | 104 |
| 1 Oct 2026 | 96 | 79 | 89 | 95 | 96 | 96 | 79 |
| 2 Oct 2026 | 91 | 71 | 77 | 80 | 80 | 91 | 71 |
| 3 Oct 2026 | 105 | 79 | 87 | 96 | 94 | 105 | 79 |
| 4 Oct 2026 | 103 | 79 | 83 | 94 | 89 | 103 | 78 |
| 5 Oct 2026 | 134 | 106 | 104 | 112 | 127 | 134 | 104 |
| 6 Oct 2026 | 136 | 111 | 103 | 119 | 125 | 136 | 103 |
| 7 Oct 2026 | 150 | 109 | 120 | 132 | 150 | 144 | 109 |
| 8 Oct 2026 | 162 | 114 | 124 | 133 | 162 | 148 | 114 |
| 9 Oct 2026 | 192 | 136 | 168 | 170 | 187 | 192 | 136 |
| 10 Oct 2026 | 183 | 133 | 155 | 162 | 178 | 183 | 133 |
| 11 Oct 2026 | 162 | 120 | 123 | 144 | 155 | 162 | 120 |
| 12 Oct 2026 | 166 | 125 | 123 | 144 | 166 | 159 | 123 |
| 13 Oct 2026 (to 12:00) | 174 | 129 | 134 | 156 | 174 | 170 | 129 |
The boring bit
Where the data comes from
NEA's 24-hour PSI for five regions (north, south, east, west and central), one reading an hour. History: the data.gov.sg file of the hourly 24-hour PSI, 108,864 rows from 1 Apr 2014 01:00 to 1 Sep 2026 00:00. From 1 Sep 2026 00:00 we use NEA's real-time PSI feed (api-open.data.gov.sg), fetched on 13 Oct 2026, which runs to 12pm that day. The two overlap for 169 hours (25 Aug to 1 Sep 2026): they agree on every reading except 2 West readings on 26 Aug 2026, one point or two apart (69 and 68, 69 and 67), all far below 101. The PSI bands (good, moderate, unhealthy, very unhealthy, hazardous) are NEA's, from its air pollution FAQ.
What is not claimed
- We do not say what caused any haze, whether 2026 will end worse or better than another year, or what anyone should do. The year is not over: the data stops at 13 Oct 2026, 12pm.
- The 24-hour PSI looks back over the last 24 hours. Hours at 101 or higher show how long that reading stayed at that level. They are not a count of hours when the air itself was bad.
- "Anywhere" (the highest of the five readings at that hour) and "all five" (the lowest of the five) are our own measures. We did not check how NEA works out a national PSI, so we do not call "anywhere" that.
- NEA gives five regions without boundaries. The circles on the map are placed by eye and mean no more than that.
- We did not check whether NEA changed how the PSI is worked out, or where it measures, between 2014 and 2026. Years far apart may not compare exactly.
- The 1-hour PM2.5 readings, other pollutants and the 3-hour PSI are not used.
- Counts are of readings as the files give them. NEA could revise past readings.
- Whole years and part years are different lengths: 6,599 hours in 2014 (from 1 Apr) and 6,853 in 2026. The "same dates" view compares 1 Jan to 13 Oct in every year.
Coverage
109,884 hours, from 1 Apr 2014 01:00 to 13 Oct 2026 12pm, with one reading for every hour and no hour missing. Every region has a value in every hour. Four rows in the history file carried the wrong timestamp (one written "2016-04-04 010:00:00", and three hours written twice: 7 Mar 2016 0:00, 24 Apr 2018 0:00, 16 Aug 2018 2:00). The file is in time order, so we gave each one the single hour missing at its place in the file: 4 Apr 2016 00:00, 7 Mar 2016 12:00, 25 Apr 2018 00:00 and 16 Aug 2018 03:00. All four sets of readings are below 70, so no count or highest reading changes whichever way they are read.
Report an error. Corrections are logged publicly with a date.
How we did it
- Read the history file and repaired the four timestamps above.
- Read NEA's real-time files, one for each day from 25 Aug to 13 Oct 2026, and compared the overlap.
- Joined them: history up to 31 Aug 2026 23:00 and real-time from 1 Sep 2026 00:00, one row for every hour.
- Made two more series for each hour: the highest of the five readings ("anywhere") and the lowest ("all five").
- Counted for each year, each part and each of the two ways of comparing years: hours at 101 or higher and at 201 or higher, days with such an hour, the highest reading and when it was first reached, and the longest stretch of hours in a row.
Show the Python code (step 1 to 3)
"""combine_psi.py - joins the two NEA 24-hour PSI sources into one hourly file for the haze page.
Run (inside the build folder): python3 -I prep/combine_psi.py <out.csv>
reads data/raw/historical_24hr_psi.csv and data/raw/realtime/*.json
1. Historical file (data.gov.sg, NEA 24-hour PSI, one row an hour, 1 Apr 2014 01:00 to 1 Sep 2026 00:00, 108,864 rows).
Four rows have a timestamp that does not fit their place in the file (see FIXES). Each is set to the one hour that is
missing between its neighbours. All four readings are below 70, so no count of hours at 101 or higher changes.
2. Real-time files (api-open.data.gov.sg /v2/real-time/api/psi?date=YYYY-MM-DD, one file for each day, fetched 13 Oct 2026).
Used from 1 Sep 2026 00:00 on. For 25 to 31 Aug, where the two sources overlap, the differences are counted and printed.
Standard library only."""
import csv, glob, json, os, sys
from datetime import datetime, timedelta
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
R = ['north', 'south', 'east', 'west', 'central']
out = sys.argv[1]
# _id of the row -> the hour it belongs to (the file is in time order, so the hour is the one missing at that place)
FIXES = {17616: '2016-04-04 00:00', # written "2016-04-04 010:00:00"
16956: '2016-03-07 12:00', # written 7/3/2016 0:00 twice; 12 rows after the first
35640: '2018-04-25 00:00', # written 24/4/2018 0:00 twice; 24 rows after the first
38355: '2018-08-16 03:00'} # written 16/8/2018 2:00 twice
FMT = '%Y-%m-%d %H:%M'
hist = {}
rows_n = 0
for row in csv.DictReader(open(os.path.join(HERE, 'data/raw/historical_24hr_psi.csv'), newline='')):
rows_n += 1
i = int(row['_id'])
if i in FIXES:
ts = datetime.strptime(FIXES[i], FMT)
else:
ts = datetime.strptime(row['timestamp'], '%d/%m/%Y %H:%M')
assert ts not in hist, ('two rows for one hour', ts)
hist[ts] = [int(row[r]) for r in R]
ts_sorted = sorted(hist)
assert all(b - a == timedelta(hours=1) for a, b in zip(ts_sorted, ts_sorted[1:])), 'history is not one row per hour'
rt = {}
for fn in sorted(glob.glob(os.path.join(HERE, 'data/raw/realtime/*.json'))):
for it in json.load(open(fn))['data']['items']:
p = it['readings']['psi_twenty_four_hourly']
rt[datetime.strptime(it['timestamp'][:16].replace('T', ' '), FMT)] = [p[r] for r in R]
overlap = [t for t in rt if t in hist]
diff = {r: sum(1 for t in overlap if hist[t][k] != rt[t][k]) for k, r in enumerate(R)}
print('overlap rows', len(overlap), 'differences', diff)
cut = datetime(2026, 9, 1, 0, 0)
rows = [(t, hist[t], 'historical') for t in ts_sorted if t < cut] + [(t, rt[t], 'realtime') for t in sorted(rt) if t >= cut]
assert all(b[0] - a[0] == timedelta(hours=1) for a, b in zip(rows, rows[1:])), 'a gap between the two sources'
with open(out, 'w', newline='') as f:
f.write('ts,' + ','.join(R) + ',src\n')
for t, v, s in rows:
f.write(t.strftime(FMT) + ':00,' + ','.join(str(x) for x in v) + ',' + s + '\n')
print('rows', len(rows), rows[0][0], rows[-1][0], 'rows from the history', sum(1 for r in rows if r[2] == 'historical'), 'rows from the real-time feed', sum(1 for r in rows if r[2] == 'realtime'))
Show the Python code (step 4 and 5)
"""prep_haze.py - builds data/data.json for the CompareSG haze page.
Run (inside the build folder): python3 -I prep/prep_haze.py <hourly.csv> <out.json>
Input : the hourly file made by prep/combine_psi.py (NEA 24-hour PSI, one row an hour, five regions: the data.gov.sg
historical file to 31 Aug 2026 23:00, then NEA's real-time feed).
A 'view' is one series with one value an hour: a region, 'any' (the highest of the five readings that hour) or
'all5' (the lowest of the five, so 101 or more means every part was at 101 or more).
For each year, window (the whole year, or 1 Jan to the last day's date) and view it counts the hours at 101 or higher
(and at 201 or higher), the days with such an hour, the highest reading (and the first hour it was reached) and the longest
unbroken run of hours at 101 or higher. Standard library only."""
import csv, json, sys
from datetime import datetime
src, out = sys.argv[1], sys.argv[2]
R = ['north', 'south', 'east', 'west', 'central']
VIEWS = ['any'] + R + ['all5']
rows = []
for r in csv.DictReader(open(src, newline='')):
t = datetime.strptime(r['ts'], '%Y-%m-%d %H:%M:%S')
v = {k: int(r[k]) for k in R}
v['any'] = max(v[k] for k in R)
v['all5'] = min(v[k] for k in R)
rows.append({'ts': t, 'year': t.year, 'date': t.strftime('%Y-%m-%d'), 'md': t.month * 100 + t.day, **v})
last = rows[-1]['ts']
assert all(a['ts'] < b['ts'] for a, b in zip(rows, rows[1:]))
CUT = last.month * 100 + last.day
stamp = lambda t: t.strftime('%Y-%m-%d %H:%M')
def one(g, v):
hi = [x[v] >= 101 for x in g]
pk = max(x[v] for x in g)
first = next(x for x in g if x[v] == pk)
o = {'h': sum(hi), 'v': sum(1 for x in g if x[v] >= 201), 'd': len({x['date'] for x in g if x[v] >= 101}),
'p': pk, 't': stamp(first['ts'])}
if v == 'any':
o['pr'] = [r for r in R if first[r] == pk][0]
longest = cur = 0
for f in hi:
cur = cur + 1 if f else 0
longest = max(longest, cur)
o['r'] = longest
o['open'] = bool(cur > 0 and cur == longest and g[-1]['ts'] == last)
return o
res = {'last': stamp(last), 'cut': '%02d-%02d' % (CUT // 100, CUT % 100), 'views': VIEWS, 'Y': {}}
for y in sorted({x['year'] for x in rows}):
g = [x for x in rows if x['year'] == y]
res['Y'][y] = {'hrs': len(g), 'first': stamp(g[0]['ts']), 'full': {v: one(g, v) for v in VIEWS}}
gw = [x for x in g if x['md'] <= CUT]
res['Y'][y]['same'] = {v: one(gw, v) for v in VIEWS}
res['Y'][y]['hrs_same'] = len(gw)
# the current year day by day, 1 Aug to the last day: the highest reading each day in every view
cal = []
dates = sorted({x['date'] for x in rows if x['year'] == last.year and x['ts'] >= datetime(last.year, 8, 1)})
for dt in dates:
gg = [x for x in rows if x['date'] == dt]
cal.append({'d': dt, 'n': len(gg), **{v: max(x[v] for x in gg) for v in VIEWS}})
res['cal'] = cal
# facts for the sentences
cur_y = last.year
a = [x for x in rows if x['year'] == cur_y and x['all5'] >= 101]
b = [x for x in rows if x['year'] < cur_y and x['all5'] >= 101]
res['all5_first_2026'] = stamp(a[0]['ts'])
res['all5_prev_last'] = stamp(b[-1]['ts'])
c = [x for x in rows if x['year'] == cur_y and x['central'] == 192]
res['c192'] = [stamp(c[0]['ts']), stamp(c[-1]['ts']), len(c)]
res['gaps'] = sum(1 for x, y2 in zip(rows, rows[1:]) if (y2['ts'] - x['ts']).total_seconds() != 3600)
json.dump(res, open(out, 'w'), separators=(',', ':'))
print('ok', last, 'gaps in hourly steps:', res['gaps'])
What could make it wrong: a past reading NEA has since changed, a timestamp error in the files that we did not catch, or a stretch where a station fed no data. As a check, the hour counts by year add up to the 109,884 hours in the joined file, and a second count written separately gives the same figures.
No machine learning was needed. The question was "how many hours, and where", which counting answers directly.
Sources: NEA 24-hour PSI on data.gov.sg (opens in a new tab), NEA real-time PSI feed (opens in a new tab) and NEA air pollution FAQ (opens in a new tab), read 13 Oct 2026. Contains information from the NEA 24-hour PSI data sets (the historical file and the real-time feed), accessed on 13 Oct 2026 from NEA via data.gov.sg, made available under the Singapore Open Data Licence version 1.0 (opens in a new tab).