Computational Epidemiology Est. 2011 · 28 countries
← All writing

What a heat risk assessment usually costs, and why we built an API instead

What normally takes a graduate student the better part of a year, now built in twenty to thirty-five minutes, with a field that reports how much to trust the result.

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.

14
external data sources
29
active projects
10,777
polygons monitored daily
20–35
minutes to first metrics

As of July 2026.

One heat-risk assessment: by hand versus the APIBY HANDone graduate student or consultant4 to 9 monthslearning and wiring a dozen data products$20k to $60kof specialized labor, at a conservative loaded rateWITH THE APIevery source, handled once20 to 35 minto set up a new project (queries return in seconds)$34 / monthto run everything, $0.19 of it the compute per request
A rough comparison. Done by hand, a first assessment is the better part of a year of a graduate student or consultant's time. The $20,000 to $60,000 range is four to nine months at a conservative fully-loaded rate, roughly $70,000 to $100,000 a year. The API sets up a new project in twenty to thirty-five minutes and runs everything for about $34 a month.

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.

Where the monthly cost goes (July 2026)$34.38 / monthstack-attributable AWS spend, 30-day reconciliationLambda · $0.19the compute that runs the entire APINAT Gateway$15.35Database (RDS)$13.31VPC$4.82S3 + Secrets + ECR · $0.71
Thirty days of AWS billing, reconciled: $34.38 a month of stack-attributable spend. Lambda, the compute that runs the entire API, is $0.19 of that. The largest item, a NAT gateway needed for internet access to external data sources, is almost entirely a flat per-hour charge, not one driven by traffic.

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.

A set of polygons in, three tiers of metrics outyour polygonsan area of interest,many polygonsHeat-Risk APIpulls the raw sources,aggregates each sourceto every polygonpullsRAW INPUTS14 sourcesWeather & reanalysisLand surface & populationForecastVector & statisticalAir qualityreturnsreturned for every polygonthree tiers of metrics, computed for each polygon in the set1 · AGGREGATEDeach raw source summarized to the polygondaily temperature & heat aggregates · air qualitymean canopy height & land-cover fractions · night-time lightspopulation & age structure · subnational wealthhealth-facility counts2 · PROCESSEDcomputed from the aggregated inputsheat index · WBGT · UTCI  thermal-comfort indices3 · DERIVEDcomposites & comparisonswarm-season LST compositepolygon_coveragedaily anomaly tiersvulnerability composites
For every polygon in your area of interest, the API aggregates each raw source to the polygon, computes thermal indices from those aggregates, and derives composites on top; the methodological work a careful analyst would otherwise do by hand.

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.

Where the projects are, by polygon count10,777 polygons under daily monitoring29 projects on 4 continentsequatorMexico CityChicagoNew YorkPhoenixParisMiamiDelhiDhakaBangaloreKanoLagosMaputoSevilledot area = polygons per city, compressed scale:205002,000flagship project
The 29 active projects by location. Dot size is polygon count per city on a compressed scale, so a 21-polygon project and a 1,331-polygon one are both legible; New York's two projects combine to roughly 2,300 polygons, the largest coverage. 10,777 polygons under daily monitoring across four continents; Mexico City, the flagship, is highlighted.

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.

Example polygon_coverage output for one coloniapolygon_coverageone colonia, Mexico City (id 01000)lstvalid 50%, scene 2026-05-16partial cloud-free coverageviirsvalid 100%worldcovervalid 100%ghsl_smodvalid 100%canopyvalid 100%populationvalid 100%, not apportionedhealthsites4 facilities, nearest 0.0 kmrwi1 data pointgdlmatched: Distrito Federalera5resolution_adequate: false1.1 km2 polygon vs 821 km2 grid cellcamsresolution_adequate: false1.1 km2 polygon vs 2,101 km2 grid cell
One polygon's 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.

A median colonia inside one ERA5 grid cell, to scaleone ERA5 grid cell0.25° × 0.25°, about 28 km on a sidea median Mexico City coloniaabout 785 m across, roughly 1/35the width of the grid cellmagnifiedone value covers all of itresolution_adequate: false≈ 28 km
A median Mexico City colonia, 785 m across, drawn to scale inside one 0.25° ERA5 grid cell (about 28 km per side); the inset magnifies the speck. One reanalysis value covers the whole cell, so the API flags the estimate with 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.

Each project's real warm season, derived from its own temperature historyJFMAMJJASONDLagosMexico CityOaxacaDelhiDhakaPhoenixSevilleBostonNew York CityMiami
Ten projects' system-derived warm seasons, each pulled from that location's own temperature history rather than a fixed calendar. Six of the ten peak outside the conventional June-to-August window.
Usable satellite scenes: Lagos's real season vs. a fixed calendar guessreal season (Dec–Feb)40fixed guess (Jun–Aug)2usable, mostly cloud-free scenes, three years each
Usable, mostly cloud-free satellite scenes over three years, Lagos: 40 in the real Dec–Feb warm season versus 2 in a fixed Jun–Aug guess. A 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.

Why the old anomaly method flagged ordinary days: a daily maximum compared against an all-hours poolcoolertemperature →hottertoday's daily maxevery hour, 10 yearsincludes every cool overnight hourtoday's max lands near the top, ≈ 95th pctother days' maximalike-for-like comparisonsame day lands mid-pack, ≈ 50th pct
Why the original method flagged ordinary days. A day's maximum compared against every hour across ten years (wide, cooler, since it includes every overnight low) lands near the 95th percentile; against the like-for-like pool of other days' maxima (narrow, hotter) the same value sits near the middle. In a review of ordinary days, the old comparison flagged nearly all of them as unusual; the corrected one leaves about half below any tier.

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.

Per-station validation: neighbors first, CAMS as fallback, persistent faults flaggedparkCAMS model grid19.5°N19.4°N99.2°W99.1°W99.0°W≈ 45 kmtoo few neighbors nearbyfalls back to the CAMS estimatecross-checked against neighborsreading agreesflaggedsame reading, day after daydaily PM2.5 readings, one weekneighbor stationstuck sensor, flaggedwhy neighbors come firstA street-level smoke plume looks nothing like a45 km cell average; checked against CAMS alone,real wildfire smoke would read as a broken sensor.Neighbors on the same streets see the same plume.
Neither OpenAQ nor CAMS validates an individual station's daily reading, so the API does it per station, per day: against neighboring stations where coverage is dense, against the CAMS model cell where it is sparse. One bad day marks a reading suspect; several in a row mark a likely sensor fault, since faults persist and real pollution events do not.

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.

From 27 billion pixels to the few thousand a polygon actually needs1 · source rastermirrored onceper country, shared2 · cloud-optimized mirrorrange request3 · range-request slice≈ 27,000,000,000 px≈ 101 GB per band, uncompressed≈ 3 GB per bandsingle pass, ≈ 25% fasterbit-for-bit identical outputa few thousand pxper polygon, in a fraction of a secondthe continent is never read in full
Each WorldPop grid is mirrored once into a cloud-optimized form; the single-pass conversion replaced a 101 GB uncompressed intermediate with about 3 GB of working space, ran about 25% faster, and produced bit-for-bit identical output. After that the API never downloads the raster again: each polygon's pixels arrive by 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.

Every request path passes a pacing control before it reaches a shared upstreamper-source rate caps29 projectsdaily + onboardingonboarding queueone history build at a timeCDS request lockone request in flight, account-wide1,394 submissions/hour, unpaced≈ 0 within minutes of the lockrequest batchingmany grid cells per requestCDS / ERA5reanalysis archiveOpen-Meteoweather + archivehistory buildsdaily updatesrecent-days weatherreleased one at a timeif CDS is busy:automatic failover
Every path from a project to a shared upstream passes a pacing control: an onboarding queue that releases one history build at a time, a batching layer that packs many grid cells into each request, and a rate cap per source. The strictest is the 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.

153 grid cells, gathered into about 6 batched requestsone sweep, 153 widely separated grid cellsbefore: 153 cells, one request eachafter: the same 153 cells in ≈ 6 batched requestsrequests per sweep153 → ≈ 6requests per run≈ 450 → ≈ 18runtime, same project852s → 328srejections (429) per run184 → 0
What batching changes: a sweep over one project's 153 widely separated grid cells gathers from 153 single-cell requests into about 6 batched ones. Per run, roughly 450 sequential requests became about 18; the same project went from 852 seconds and 184 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.

Two streams, one shared pacing layer, a steady releasedaily updatesone per project, at 06:00 UTCnew-project onboardinga burst of history buildsshared pacing layerCDS one-in-flight lockper-source rate capsWMO one project at a timeupstream rate ceilinga steady, even releaseto CDS and Open-Meteo, at a pace they absorbbursty arrivals
Two work streams, daily updates and new-project onboarding, share one pacing layer rather than one queue: the account-wide CDS one-in-flight lock, per-source rate caps, and WMO dispatched one project's chain at a time. Bursty arrivals from either stream are metered into a steady, even release, so neither starves the other and the free upstream services are never overrun.

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.

The access model: organization, users, roles, keys, projectsORGANIZATIONthe top containerUsers, each with a roleAdministratormanages peopleand projectskey rotatableMemberruns their ownprojectskey rotatableRead-onlycan look,not changekey rotatableProjects the organization ownsprojectan area of interest,many polygonsprojectan area of interest,many polygonsprojectan area of interest,many polygonsEach account authenticates with an API key; rotating it killsthe old one the moment the replacement is issued.new colleaguesingle-use invite,org + role presetjoins
The access model, the unglamorous plumbing that decides whether anyone other than the builders can use the system. 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 an API key can be rotated the moment it is suspected leaked.

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.