Most heat risk assessments start the same way. Someone, usually a graduate student or a consultant, gets handed a set of neighborhood boundaries and asked to figure out how exposed each one is to extreme heat: which areas have less tree cover, which have older populations, which have thinner weather station coverage, which are already showing signs of stress. That person then spends the next several months, sometimes closer to a year, learning to work with satellite imagery, global population rasters, an obscure European climate reanalysis service, and half a dozen other data products that were never designed to talk to each other. They write extraction code, debug projection mismatches, and, if they have time left over, try to figure out how much to trust the numbers they just produced. Usually there isn't time left over, so that last part gets skipped.
We built an API that does the whole thing automatically. Upload a set of polygons and get back heat metrics, air quality, vulnerability indicators, and, importantly, a per-source estimate of how well the data covers each polygon. What normally takes months of specialized labor now takes about 20 to 35 minutes to set up a new project (queries against an existing one return in seconds). Full documentation, including access and licensing terms, is on the API reference page.
As of July 2026.
Reconciled against 30 days of AWS billing, running the whole system costs about $34 a month, nineteen cents of which is the compute that actually answers each request. The rest of that $34 is a NAT gateway's flat per-hour charge, not something that scales with how many polygons get submitted.
What one polygon upload hides
Behind a single API call sits data from roughly fourteen distinct external sources: ERA5 reanalysis for temperature and humidity, satellite-derived tree canopy height, global population rasters, land cover classification, settlement density, night-time lights as an economic activity proxy, thermal imagery from Landsat, and ground-station air quality readings, among others. Each of these has its own file formats, its own access quirks, its own rate limits, and, in more than one case, its own undocumented behavior that only shows up in actual use.
None of that is visible to someone calling the API. They submit their polygons and get numbers back.
For every polygon, the API aggregates each raw source to that polygon: mean canopy height and land-cover fractions, night-time lights, wealth, population and age, temperature and heat, and air quality. On top of those it returns processed metrics, the heat index and the WBGT and UTCI thermal-comfort indices, and derived composites a careful analyst would otherwise build by hand: a warm-season composite, per-source coverage, daily anomaly tiers, and vulnerability composites. The raw sources are the input; the aggregation, processing, and derivation on top of them are what the response carries.
Where this is running today
Twenty-nine active projects are running on the API right now, spanning four continents: Mexico City's 1,182 colonias are the flagship, the deepest of the set, with a dedicated write-up of its own. Several large US metros came online recently. Chicago is the largest single project at 1,331 polygons, while New York City, split across two projects to keep each under a per-project size limit of about 1,500 polygons, is the largest coverage overall at roughly 2,300. Phoenix, Miami, and Boston came online in the same stretch, and Kano brings Nigeria onto the map alongside Lagos. Paris, Delhi, Bangalore, the Twin Cities, Fresno, Mozambique, San Bernardino County, Seville, Dhaka, San Diego, Oaxaca, Atlanta, and Washington DC round out the roster, several of them with more than one project registered as scope or boundaries changed over time.
Every one of them gets a daily update automatically. The rest of this post is about two problems that sit behind that routine: getting each measurement right, and then delivering it every day without overrunning the shared, free data services the whole system leans on.
The part that usually gets skipped
The hardest part of this kind of analysis is knowing how much to trust the result once the data is in. A raster source can have gaps. A polygon can be too small relative to a weather model's grid resolution for a single sample point to mean much. Most one-off research projects either never build a coverage estimate at all, or build one informally and never revisit it.
We built coverage accounting into the API from the start. It returns a field called polygon_coverage, reporting for twelve of its data sources how much of a
polygon's area had valid data, whether the weather data resolution is even fine enough to be
meaningful for a given polygon's size, and, for sparser sources, how many points or facilities
actually fell inside the polygon. When a source's coverage is thin, the field reports the thin
coverage explicitly. Building it surfaced a real gap: a
settlement-classification layer was missing entirely for a large share of small, dense
polygons on two projects, up to 88% of rows in the worst case. Root cause, confirmed against
the actual source rasters: the underlying rasterization only counts a pixel if its center
falls inside the polygon, so a polygon smaller than the source's native pixel size matched
nothing at all, silently. Fixed and re-verified live: coverage on both affected projects went
from partial to complete.
polygon_coverage, from a real response. Most sources cover this colonia fully; the thermal (lst) composite is half-covered by cloud-free scenes, and both ERA5 and CAMS report resolution_adequate: false because the 1.1 square kilometer colonia is far smaller than their grid cells.The same root cause turned up again in population data, where the fix had to work differently. For the settlement layer, the repair was to count every pixel the polygon touches, not only the ones whose center it contains. Population is a raw count of people, so summing every touched pixel at full value would instead overcount anyone in a pixel the polygon only partly overlaps. The fix scales a partial pixel's value by how much of it actually falls inside the polygon, and marks every row built this way so an apportioned estimate is never mistaken for a full measurement.
resolution_adequate: false rather than presenting it as reliable at that scale.That flag described a limitation without a fix for it at the time. It has one now: a statistical model corrects the grid temperature toward each polygon's own neighborhood, validated per climate zone, with the failures disclosed the same way the successes are. A separate post covers how it works and where it does and doesn't hold up.
Picking the right season to compare
Measuring how much hotter some neighborhoods run than others, from satellite thermal images, only works when the comparison is against a real warm season, when the months being compared actually behave alike. A careful analyst would find that season from the data before comparing anything. The API does it automatically, for every project, from that location's own temperature history.
The warm season is not the same from place to place, and a fixed calendar gets it wrong. Taking each project's actual warmest three-month stretch, wrapping across the year boundary where needed, puts Lagos in December through February, Mexico City and Oaxaca in March through May, Delhi and Dhaka in April through June before the monsoon, and Miami in July through September, a month off from the June-to-August window that fits Phoenix, Boston, and New York.
The difference is not cosmetic. When we counted how many usable, mostly cloud-free satellite images fell inside Lagos's real December-to-February warm season versus a fixed June-to-August window, across three years of each, the real season had 40 and the wrong one had 2. A citywide heat map built on 2 images would have been close to empty.
What counts as an unusual day
Deciding whether today counts as unusually hot for a given place means comparing it against that place's own history. Getting that comparison right turned out to be harder than it sounds, and we had it wrong for a while.
The original version compared each day's peak temperature against a distribution built from every hour of every day across ten years of history. A day's own maximum is, by definition, close to the top of that day's cycle. Compared against a pool that includes every ordinary overnight hour alongside every peak, an entirely average day already looks unusual. When we checked, close to every day in the record cleared the threshold we were calling moderately extreme.
The fix compares peaks to peaks: today's daily maximum against a distribution built only from other days' maximums, drawn from a two-week window around the same time of year across ten years, rather than the single exact calendar date, which does not have enough samples on its own to support a reliable percentile.
The corrected math still had to be verified before it went out. We built an independent, from-scratch version of the same calculation in a different part of the codebase and ran both versions side by side against synthetic data in a disposable test database. They agreed on every one of 2,562 compared values. Only then did we reprocess ten years of reference history for every project and check the outcome against real, current data. Across a recent 30-day stretch for one project, the corrected distribution put about half the days below even the lowest unusual-heat tier, most of the rest in the moderate tier, and only a single day reaching the higher ones, which is roughly what a real spread of ordinary and unusual days should look like.
Validating one station's reading at a time
Air quality data comes from two places: ground stations and a satellite-and-model estimate called CAMS. OpenAQ, the network that aggregates ground station readings, does not check any individual station's reading against anything else. CAMS blends many stations and satellite retrievals into one area-wide estimate, but that process doesn't validate any single station's specific reading on a specific day either. A sensor stuck reporting the same value for a week, or a spike from a fault rather than real pollution, can sit in the data with nothing flagging it.
We built the layer that validates each station's own readings. Each day, for each station, the system checks a reading against its neighboring stations first. If there aren't enough nearby stations for that comparison to mean anything, it falls back to comparing the reading against the satellite estimate instead. A reading that looks suspect once gets flagged; a station that looks suspect for several days running gets treated differently than one having a single bad day, because sensor faults persist and real pollution events don't. The first version compared a station only against the satellite estimate, and review caught a real flaw in it before it ever misfired: a genuine wildfire-smoke event would look exactly like a broken sensor to that comparison, since a plume at street level looks nothing like a 45-kilometer model grid cell either way.
The hundred-gigabyte detour
Some of the fourteen sources are not tidy API responses. Population and age structure come from WorldPop's global grids at 100-meter resolution, and settlement, tree cover, and night-time lights are rasters of similar size. The United States population grid alone is about 27 billion pixels. Nothing about a neighborhood analysis needs all 27 billion, but the neighborhood's few thousand have to come out of that grid quickly, for every polygon, every day.
Two things make that possible: how the rasters are stored, and how they are read.
The first is storage. We mirror each one into a cloud-optimized form on our own storage. The first time we ran that conversion, we realized a single band of the US grid came to about 101 gigabytes, independent of how much of the country is empty ocean or desert. That is not something we can work with easily. The fix was to stream the conversion in a single pass instead. That took the working space from about a hundred gigabytes down to three, and ran faster besides. The output is bit-for-bit identical; the hundred gigabytes was overhead we had been paying without seeing it. We also do not process the whole world up front. Countries are processed when a project requests them, and projects share them as a resource. Any country containing one of the cities listed above is ready to use, and others are onboarded automatically on request.
The second is reading. When a project needs population for a polygon, the system does not download the raster. It asks storage for only the pixels that fall inside that polygon, over a range request, the way a browser fetches only the visible part of a large image. A whole continent's grid sits untouched while a single colonia's worth of pixels comes back in a fraction of a second. The extraction is resumable and time-budgeted, so a project with thousands of polygons builds its coverage across several runs instead of blowing a single compute window, which is why a very large new project fills in gradually rather than all at once.
Range request, and extraction is resumable and time-budgeted.Pacing a shared resource
The daily-update pipeline and every new-project onboarding lean on the same free, shared upstream services, each with its own rate limits. The system paces itself so that no single project, and no single busy day, can overrun them. Several controls do that work at different points.
cds_request_lock, never more than one outstanding CDS request across the whole account; unpaced retries once produced 1,394 submissions in a single hour, and within minutes of deploying the lock the rate fell to approximately zero. When the primary is busy behind the lock, the system fails over to Open-Meteo, the same underlying reanalysis, and data still ships on schedule.The Copernicus Climate Data Store, the pipeline's first stop for temperature data, allows exactly one request in flight per account at a time, a reasonable policy for a free resource used by climate scientists everywhere. A request lock enforces that account-wide, so however many jobs are running, only one CDS request is ever outstanding. When the lock means the primary source is busy on the first try, the pipeline fails over automatically to a second provider serving the same underlying reanalysis and keeps delivering on schedule, so the caller never waits. We learned we needed the lock the hard way: an early, unpaced version of the pipeline submitted 1,394 requests to CDS in a single hour during a backfill and was rejected at scale. Within minutes of deploying the lock, that rate fell to about zero.
A second control came out of a slowdown we had filed under the wrong cause. Daily updates for large projects were running slow, some finishing just under the 900-second ceiling our compute platform puts on a single run, and the working theory was that those projects simply had too many polygons. The logs said the cause was request count. One project spread across 153 widely separated grid cells was making roughly 450 separate requests per run to Open-Meteo, the free service that supplies our recent-days weather, and the service was rate limiting us for it: 184 rejected requests in a single run that finished at 852 seconds. The fix batches many grid cells into each request. A sweep over the 153 cells now takes about 6 batched requests instead of 153 sequential ones, cutting roughly 450 requests per run down to around 18. The same project that had finished at 852 seconds finished at 328, with no rejections. Review of the fix caught a rare path in the first version that could have left some thermal-comfort values permanently missing when a run hit the time limit; the current version repairs them on a later run.
429 rejections to 328 seconds and none.Onboarding a brand-new project has its own version of the same problem. A new project needs its full history built from scratch, which means hundreds of requests to those same rate-limited services in a short window. For a while we let that work fire as fast as it would go, until the day a user started eight large projects at once. Each asked the free tier of a weather archive for a decade of daily history, at the same time, from the same address. The service began refusing us and the jobs stalled partway, all of them retrying into the same wall. The fix was the daily-update lock applied one level up: a queue that releases onboarding work at a pace each upstream service can absorb, one project's climate history at a time rather than eight. Restarting the eight stalled projects through that queue, they completed one after another with no refused requests, and nothing was lost, since the half-built histories resumed where they had stopped.
The pattern repeats at every layer where the system meets a shared resource it does not own: hold to one request where only one is allowed, batch where the count is what bites, and queue the bursts so consumption stays smooth. It is what lets twenty-nine projects, and the next one after them, share the same free services without any of them starving the rest.
Getting your own data in
Early on, adding a data layer to a project meant asking us to run it, which was fine for a handful of projects and untenable past that. A project now reports exactly which layers it holds and which it does not, and for anything missing it says whether the layer was never requested or was requested and failed, so a gap is traceable to the right side from the response alone. Enabling a missing layer is a single call that joins the same paced queue, so one large onboarding cannot starve everyone else. Access is organized the way the work is: each account belongs to an organization and carries a role, a new colleague joins through a single-use invite that already encodes their organization and role, and a key can be rotated the moment it is suspected leaked, with the old one dead as soon as the replacement is issued. This is unglamorous plumbing, and it is what decides whether anyone other than us can use the rest of the system.
What's next
We're preparing a methods paper on the underlying approach, and I'll be registering the work with Harvard Dataverse so it has a proper citation once that's in place. This post is the informal version of the story; the API documentation is the reference version. The neighborhood-resolution correction mentioned above has its own informal write-up, too: Correcting the grid, one neighborhood at a time.
The API is part of CrisisReady, a research-response platform for disasters, and the work was enabled by the Harvard Data Science Initiative. Andrew Schroeder and Satchit Balsari, who direct CrisisReady, shaped what it needed to do and put real projects on it early, which is why most of the problems described here surfaced in production rather than in a design document.
If you work in emergency management, public health, or urban resilience and want to try this on your own jurisdiction, reach out at [email protected], we're onboarding new projects directly.
The people this is built for are the ones who would otherwise be spending the next six months doing this by hand.