How to count buildings and people exposed to a hazard zone

On this page

Problem statement

"How many people are in the flood zone?" has no single answer, because population is published for areas and hazards are mapped as their own shapes, and the two rarely nest. Every method of splitting a partly affected census unit is defensible, and they give different numbers β€” on the 2024 Park Fire perimeter, between 1,640 and 2,761 residents from the same census blocks, and 1,469 once population is weighted onto built-up land.

The answer is not to pick one silently. It is to compute the plausible range, use the method that best matches where people actually live, and publish the rule alongside the number.

Quick answer

Compute three apportionments β€” touching, centroid-inside and area-weighted β€” and report them together:

import geopandas as gpd, numpy as np

blocks = gpd.read_parquet("blocks_ca_north.parquet").to_crs(3310)   # California Albers: equal area
fire = gpd.read_file("park_perimeter.geojson")
fire = fire[fire.poly_GISAcres > 1000].to_crs(blocks.crs)
zone = fire.union_all()

touching = blocks[blocks.intersects(zone)].copy()
touching["inside_share"] = touching.intersection(zone).area / touching.area
centroid_in = blocks[blocks.representative_point().within(zone)]

print(f"{len(touching):,} blocks touch the zone, {len(centroid_in):,} have their centre inside")
print(f"population: touching {touching.POP20.sum():,}, "
      f"centroid {centroid_in.POP20.sum():,}, "
      f"area-weighted {(touching.POP20 * touching.inside_share).sum():,.0f}")
print(f"housing:    touching {touching.HOUSING20.sum():,}, "
      f"centroid {centroid_in.HOUSING20.sum():,}, "
      f"area-weighted {(touching.HOUSING20 * touching.inside_share).sum():,.0f}")
320 blocks touch the zone, 224 have their centre inside
population: touching 2,761, centroid 1,677, area-weighted 1,640
housing:    touching 1,381, centroid 847, area-weighted 846

Touching is an upper bound, centroid is a coin flip per block, and area-weighted assumes people are spread evenly inside a block β€” which in rural blocks they are not.

Four ways to apportion population to a hazard zone and what each assumes. four rules, four answersruleassumesPark Fireany intersectiona touched block counts fully2,761centroid insideall or nothing per block1,677area-weightedpeople spread evenly1,640dasymetric (built-up)people live on built land1,469Only the last rule uses anything about where the houses actually are spatialworkflow.io
Four rules, four answers; the fourth is the only one that uses where the houses are.

Fine exposure, equal-area CRS, intersection shares, weights

1. Get exposure at the finest geography available

Census blocks in the US, output areas in the UK, 100 m grids from GHSL or WorldPop elsewhere. The finer the unit, the less the apportionment rule matters.

2. Project both layers to an equal-area CRS

Area-weighting compares areas, so the CRS has to preserve them.

3. Compute the intersection share

blocks.intersection(zone).area / blocks.area is the fraction of each block inside the hazard.

4. Weight by where people are, if you can

Built-up area, building footprints or night lights redistribute population inside a block far better than area does. That is dasymetric mapping.

5. Count structures separately from people

Housing units, buildings and critical facilities each answer different questions, and a building count is not proportional to population in mixed-use areas.

6. Report the range and the rule

"1,640–2,761 residents, area-weighted estimate 1,640" says more than any single figure, and it is honest about what the data supports.

Population exposed to the Park Fire perimeter under four apportionment rules. residents exposed, 2020 census blocksany intersection2,761 β€” upper boundcentroid inside1,677area-weighted1,640dasymetric1,4690150030001,381 housing units by the loosest rule, 846 by area weighting spatialworkflow.io
One perimeter, one census, four rules: the range is 70% of the smallest estimate.

Exposure to the Park Fire with dasymetric weights

The data used on this page

Three real inputs for the 2024 Park Fire in northern California. The perimeter from the WFIGS interagency fire perimeters service (park_perimeter.geojson). The 2020 Census blocks of California with their POP20 and HOUSING20 counts, from the TIGER/Line block file (382 MB), kept for the bounding box of the main perimeter plus a 0.25Β° margin (blocks_ca_north.parquet) β€” only blocks touching the perimeter enter any result, so the margin does not change a number. And the burn severity from How to map burn severity with dNBR: its two Sentinel-2 scenes (2 July and 10 September 2024) on its 20 m window, saved as the uncorrected dNBR (dnbr.npy) and the pre-fire NBR (pre_nbr.npy).

import os

import geopandas as gpd
import numpy as np
import pystac_client
import rasterio
import requests
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds
from shapely.geometry import box, shape

WFIGS = ("https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
         "WFIGS_Interagency_Perimeters/FeatureServer/0/query")
if not os.path.exists("park_perimeter.geojson"):
    reply = requests.get(WFIGS, params={"where": "poly_IncidentName='PARK' AND attr_POOState='US-CA'",
                                        "outFields": "*", "f": "geojson", "outSR": 4326}, timeout=300)
    reply.raise_for_status()
    open("park_perimeter.geojson", "w").write(reply.text)

if not os.path.exists("blocks_ca_north.parquet"):
    fire = gpd.read_file("park_perimeter.geojson")
    x0, y0, x1, y1 = fire[fire.poly_GISAcres > 1000].total_bounds       # WFIGS has two small PARK fires too
    blocks = gpd.read_file("https://www2.census.gov/geo/tiger/TIGER2020/TABBLOCK20/tl_2020_06_tabblock20.zip",
                           bbox=(x0 - 0.25, y0 - 0.25, x1 + 0.25, y1 + 0.25))
    blocks[["GEOID20", "POP20", "HOUSING20", "ALAND20", "geometry"]].to_parquet("blocks_ca_north.parquet")

if not os.path.exists("dnbr.npy"):
    BBOX = [-122.00, 40.00, -121.74, 40.22]
    left, bottom, right, top = transform_bounds(4326, "EPSG:32610", *BBOX)
    GRID = {"crs": "EPSG:32610", "transform": from_origin(left, top, 20, 20),
            "width": int((right - left) // 20), "height": int((top - bottom) // 20)}
    es = pystac_client.Client.open("https://earth-search.aws.element84.com/v1")

    def scene(dates):
        items = [i for i in es.search(collections=["sentinel-2-c1-l2a"], bbox=BBOX, datetime=dates,
                                      query={"eo:cloud_cover": {"lt": 5}}).item_collection()
                 if shape(i.geometry).contains(box(*BBOX))]
        return sorted(items, key=lambda i: i.datetime)[0]

    def band(item, name):
        with rasterio.open(item.assets[name].href) as src, WarpedVRT(
                src, crs=GRID["crs"], transform=GRID["transform"], width=GRID["width"],
                height=GRID["height"], resampling=Resampling.bilinear) as vrt:
            a = vrt.read(1).astype("float32")
        return np.where(a > 0, a * 0.0001, np.nan)

    def nbr(item):
        return (band(item, "nir08") - band(item, "swir22")) / (band(item, "nir08") + band(item, "swir22"))

    pre, post = scene("2024-07-01/2024-07-10"), scene("2024-09-08/2024-09-15")
    np.save("pre_nbr.npy", nbr(pre))
    np.save("dnbr.npy", (nbr(pre) - nbr(post)) * 1000)
print(len(gpd.read_parquet("blocks_ca_north.parquet")), "blocks;", np.load("dnbr.npy").shape)
8735 blocks; (1235, 1123)

Example 1 β€” where the difference comes from

partial = touching[(touching.inside_share > 0.05) & (touching.inside_share < 0.95)]
print(f"{len(partial)} blocks are partly inside; they hold {partial.POP20.sum():,} residents")
print(f"blocks fully inside hold {touching[touching.inside_share >= 0.95].POP20.sum():,}")
print("share of population in blocks that are partly inside: "
      f"{partial.POP20.sum() / touching.POP20.sum():.0%}")
print(f"median touching block area: {touching.area.median() / 1e6:.2f} kmΒ², "
      f"largest touching block: {touching.area.max() / 1e6:.0f} kmΒ²")
74 blocks are partly inside; they hold 510 residents
blocks fully inside hold 1,271
share of population in blocks that are partly inside: 18%
median touching block area: 2.34 kmΒ², largest touching block: 111 kmΒ²

Example 2 β€” a dasymetric weight from land cover

import pystac_client, planetary_computer, rasterio
from rasterio.enums import Resampling
from rasterio.features import geometry_mask
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT

bounds = touching.total_bounds
res = 100
grid = {"crs": touching.crs, "transform": from_origin(bounds[0], bounds[3], res, res),
        "width": int((bounds[2] - bounds[0]) // res), "height": int((bounds[3] - bounds[1]) // res)}
cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
                                modifier=planetary_computer.sign_inplace)
wc_item = next(cat.search(collections=["esa-worldcover"],
                          bbox=list(touching.to_crs(4326).total_bounds)).items())
with rasterio.open(wc_item.assets["map"].href) as src, WarpedVRT(
        src, crs=grid["crs"], transform=grid["transform"], width=grid["width"],
        height=grid["height"], resampling=Resampling.nearest) as vrt:
    wc = vrt.read(1)
built = wc == 50

zone_mask = geometry_mask([zone], out_shape=(grid["height"], grid["width"]),
                          transform=grid["transform"], invert=True)
rows = []
for _, b in touching.iterrows():
    m = geometry_mask([b.geometry], out_shape=(grid["height"], grid["width"]),
                      transform=grid["transform"], invert=True)
    total_built = (built & m).sum()
    if total_built:
        rows.append(b.POP20 * (built & m & zone_mask).sum() / total_built)
    else:
        rows.append(b.POP20 * b.inside_share)
print(f"dasymetric estimate (population on built-up land): {sum(rows):,.0f} residents")
dasymetric estimate (population on built-up land): 1,469 residents

Weighting by built-up land puts the estimate at 1,469 β€” below the area-weighted 1,640, because the built-up pixels in these blocks sit mostly outside the perimeter. That is the correction doing its job.

Example 3 β€” exposure by hazard intensity, not just inside or outside

dnbr = np.load("dnbr.npy")                                  # Park Fire severity, 20 m
from rasterio.transform import from_origin as fo
from rasterio.warp import transform_bounds
bbox = [-122.00, 40.00, -121.74, 40.22]
l, bt, r, t = transform_bounds(4326, "EPSG:32610", *bbox)
tr = fo(l, t, 20, 20)
from shapely.geometry import box as _box
window = gpd.GeoSeries([_box(*bbox)], crs=4326).to_crs("EPSG:32610").iloc[0]
sev_blocks = blocks.to_crs("EPSG:32610")
sev_blocks = sev_blocks[sev_blocks.intersects(window)]
for lo, hi, name in [(99, 269, "low"), (269, 439, "moderate-low"), (439, 2000, "moderate-high+")]:
    m = (dnbr >= lo) & (dnbr < hi)
    people = 0
    for _, b in sev_blocks.iterrows():
        bm = geometry_mask([b.geometry], out_shape=dnbr.shape, transform=tr, invert=True)
        if bm.sum():
            people += b.POP20 * (m & bm).sum() / bm.sum()
    print(f"{name:16} {people:6.0f} residents in the severity window")
low                  20 residents in the severity window
moderate-low          2 residents in the severity window
moderate-high+        0 residents in the severity window

Twenty residents, in a window that contains 555 kmΒ² of burned forest. Most of this fire burned where nobody lived, and the exposure is concentrated at its edges β€” which is exactly why a single number for "people in the fire" hides more than it tells.

Why rural ranges are so wide

Why the range is so wide in rural areas

Census blocks in forested California are large β€” the biggest block touching this perimeter is tens of square kilometres β€” and a fire perimeter cuts through them at random with respect to where the houses are. A block that is 30% inside may have all its dwellings in the burned third or none of them, and the area-weighted rule assumes 30%.

Why dasymetric weighting helps

Population is not spread evenly: it sits on built-up land. Weighting each block's population by the built-up pixels inside the hazard rather than by area moves the estimate towards where people actually live. It is still a model, but a better one, and the land-cover layer is free.

Why exposure by intensity beats exposure by footprint

A fire perimeter, a floodplain or a shaking contour is a boundary drawn around a continuum. Counting people inside it throws away everything the hazard layer knows about severity. Reporting exposure per intensity class β€” people in moderate-high severity, dwellings in more than a metre of water β€” keeps it.

A hazard boundary crossing a large rural block whose houses are clustered in one corner. area weightinghouses30% inside β†’ 30% of peopledasymetric weightingnone inside the zonebuilt-up pixels decideThe largest block touching this fire is 111 kmΒ²; where its houses sit decides the count spatialworkflow.io
Area weighting spreads the block's people evenly; the houses are in one corner of it.

Daytime population, footprints and privacy

  • Daytime population differs from residential population; workplaces and schools are elsewhere.
  • Building footprints (Microsoft, Overture, OSM) give a better weight than land cover where available.
  • Critical facilities β€” hospitals, schools, care homes β€” should be counted individually, not through a weight.
  • Census vintage: 2020 blocks against a 2024 fire means four years of building.
  • Privacy: block-level counts are already rounded or suppressed in some countries.
  • Do not sum across hazards; the same house exposed to two hazards is one house.

FAQ

How do I count people in a hazard zone?

Intersect the zone with census units and apportion: by touching, by centroid, by area share, or by a dasymetric weight. Report the rule.

Which method should I use?

Dasymetric where a built-up layer exists; area-weighted otherwise. Touching and centroid are bounds, not estimates.

Why do published figures differ from mine?

Different census vintage, different apportionment, or daytime rather than residential population.

Can I count buildings instead?

Yes, and it is often better β€” but a building count is not a population count.

What about people outside the zone who are affected?

Smoke, evacuation orders and service disruption reach much further; exposure to the footprint is a lower bound on impact.

Should I report a single number?

Report the estimate and the range from the other rules.