How to Calculate Coverage and Floor Area Ratio in Python

On this page

Problem statement

Two districts with the same number of homes per hectare can look completely different: low-rise terraces covering most of the ground, or towers standing in parkland. Planners separate the two with a small family of ratios — ground coverage, floor area ratio, open space ratio and average number of storeys — that together place any urban area on a two-dimensional map of density. Computing them needs footprints, heights and, above all, a denominator: the land the buildings are compared with.

The denominator is where results diverge. Per tessellation cell, per street block, per grid square and for a whole district, the same buildings give different ratios, because each unit includes a different share of streets and open space.

Quick answer

The Spacemate family, from footprint area, floor area and land area:

GSI (coverage)            = footprint area / land area
FSI (floor area ratio)    = gross floor area / land area
L   (mean storeys)        = FSI / GSI
OSR (open space ratio)    = (1 − GSI) / FSI
import numpy as np, geopandas as gpd, momepy
from shapely.geometry import box

b = gpd.read_parquet("denhaag_bag.parquet")                    # footprints guide, EPSG:28992
cx, cy = 81000, 455000
aoi = box(cx - 500, cy - 500, cx + 500, cy + 500)
b = b[b.representative_point().within(aoi) & b.is_valid & (b.area > 10)].reset_index(drop=True)
b["height"] = (b.b3_h_70p - b.b3_h_maaiveld).clip(lower=2.5)
b["floors"] = np.maximum(1, np.round(b.height / 3.2))          # assumed 3.2 m floor-to-floor
b["gfa"] = b.area * b.floors

def spacemate(footprint, gfa, land):
    gsi, fsi = footprint / land, gfa / land
    return {"GSI": gsi, "FSI": fsi, "L": fsi / gsi, "OSR": (1 - gsi) / fsi}

whole = spacemate(b.area.sum(), b.gfa.sum(), aoi.area)
print(f"{len(b):,} buildings in 1 km²")
print("whole square: " + ", ".join(f"{k} {v:.2f}" for k, v in whole.items()))
1,990 buildings in 1 km²
whole square: GSI 0.48, FSI 2.34, L 4.89, OSR 0.22

Two ratios place the district; the other two are derived and make it readable — average storeys, and how much unbuilt space there is per square metre of floor.

Footprints with heights, storeys, a land unit, ratios

1. Get footprints with heights

Floor area needs a height. Measured heights (LiDAR-derived, as in the 3D BAG) are best; OSM building:levels is a patchier alternative.

2. Convert height to storeys explicitly

Divide by an assumed floor-to-floor height — 3.0–3.5 m for housing and offices, more for older buildings with tall ground floors. The assumption changes FSI directly; state it and test another value.

3. Choose the land unit

Tessellation cell (the building's own land), enclosure or block (bounded by streets), grid cell, or district. Each includes a different share of streets and open space.

4. Compute the ratios per unit

Sum footprint and floor area per unit, divide by the unit's land area. Units at the edge of the study area are incomplete and should be flagged.

5. Read GSI and FSI together

FSI alone cannot distinguish low-rise high-coverage from high-rise low-coverage. Plot GSI against FSI, or report L and OSR alongside.

The four Spacemate density measures with their formulas and what each distinguishes. two measured ratios, two derived onesmeasureformulatells youGSIfootprint ÷ landhow much ground is coveredFSI (FAR)floor area ÷ landhow much floor is builtLFSI ÷ GSIaverage storeysOSR(1 − GSI) ÷ FSIopen space per m² of floorthe land unit — cell, block, grid, district — is part of every definitionFSI alone cannot tell a terrace from a tower spatialworkflow.io
Two measured ratios, two derived ones.

The same buildings over four denominators

The data used on this page

Building footprints with LiDAR heights from the 3D BAG (the Dutch building register with heights from AHN), fetched from its WFS for central Den Haag — 4.285–4.330° E, 52.065–52.095° N — exactly as in How to download building footprints from OSM and Overture, and saved as denhaag_bag.parquet in the Dutch national grid (EPSG:28992). The OpenStreetMap answers are read as the Overpass mirror gave them on 9 October 2026.

import io
import os
import zipfile

import geopandas as gpd
import requests
from pyproj import Transformer

# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-building-coverage-far-python-2026-10-09.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

if not os.path.exists("denhaag_bag.parquet"):
    W, S, E, N = 4.285, 52.065, 4.330, 52.095
    to_rd = Transformer.from_crs(4326, 28992, always_xy=True)
    (x0, y0), (x1, y1) = to_rd.transform(W, S), to_rd.transform(E, N)
    feats, start = [], 0
    while True:
        fc = requests.get("https://data.3dbag.nl/api/BAG3D/wfs", params={
            "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "BAG3D:lod12",
            "count": 5000, "startIndex": start, "outputFormat": "application/json",
            "bbox": f"{x0},{y0},{x1},{y1},EPSG:28992"}, timeout=300).json()
        feats += fc["features"]
        if len(fc["features"]) < 5000:
            break
        start += 5000
    bag = gpd.GeoDataFrame.from_features(feats, crs=28992)
    bag["height"] = bag.b3_h_70p - bag.b3_h_maaiveld
    bag[["identificatie", "b3_h_70p", "b3_h_maaiveld", "height", "b3_opp_grond", "geometry"]] \
        .to_parquet("denhaag_bag.parquet")
print(len(gpd.read_parquet("denhaag_bag.parquet")), "buildings")
23929 buildings

Example 1 — the same buildings, four denominators

import osmnx as ox
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"   # mirror; overpass-api.de refused
ox.settings.overpass_rate_limit = False
streets = ox.graph_to_gdfs(ox.graph_from_point((52.0799, 4.3113), dist=1200,
                                                network_type="drive"), nodes=False).to_crs(28992)
encl = momepy.enclosures(streets, limit=gpd.GeoSeries([aoi], crs=28992))
cells = momepy.morphological_tessellation(b, clip=momepy.buffered_limit(b, buffer=100))
cells = cells.join(b[["gfa"]]).assign(fp=b.area)

pts = gpd.GeoDataFrame(b[["gfa"]].assign(fp=b.area), geometry=b.representative_point(), crs=28992)
by_encl = gpd.sjoin(pts, encl[["geometry"]], predicate="within").groupby("index_right")[["fp", "gfa"]].sum()
by_encl["land"] = encl.area.loc[by_encl.index]

grid = gpd.GeoDataFrame(geometry=[box(x, y, x + 250, y + 250)
                                  for x in np.arange(cx - 500, cx + 500, 250)
                                  for y in np.arange(cy - 500, cy + 500, 250)], crs=28992)
by_grid = gpd.sjoin(pts, grid, predicate="within").groupby("index_right")[["fp", "gfa"]].sum()
by_grid["land"] = grid.area.loc[by_grid.index]

def median_fsi(fp, gfa, land):
    return np.median(gfa / land), np.median(fp / land)
for label, (fsi, gsi) in (("tessellation cells", median_fsi(cells.fp, cells.gfa, cells.area)),
                          ("street enclosures", median_fsi(by_encl.fp, by_encl.gfa, by_encl.land)),
                          ("250 m grid", median_fsi(by_grid.fp, by_grid.gfa, by_grid.land))):
    print(f"{label:<20} median FSI {fsi:.2f}, median GSI {gsi:.2f}")
print(f"{'whole 1 km square':<20}        FSI {whole['FSI']:.2f},        GSI {whole['GSI']:.2f}")
tessellation cells   median FSI 2.02, median GSI 0.55
street enclosures    median FSI 1.92, median GSI 0.49
250 m grid           median FSI 2.43, median GSI 0.48
whole 1 km square           FSI 2.34,        GSI 0.48

Coverage barely moves between units (0.48–0.55), but the floor area ratio runs from 1.92 to 2.43 — and not in the order the definitions suggest. A median over small units counts every unit once: the cell around a two-storey house weighs as much as the cell around an eight-storey block, and there are more small houses than big blocks. A 250 m square or the whole square kilometre weighs by land instead, and the big blocks hold most of it. Quoting a floor area ratio without its land unit and its statistic leaves out the half of the definition that decides the number.

Example 2 — the floor-height assumption

for fh in (3.0, 3.2, 3.5):
    floors = np.maximum(1, np.round(b.height / fh))
    print(f"{fh} m per floor: FSI {(b.area * floors).sum() / aoi.area:.2f}, "
          f"mean storeys {np.average(floors, weights=b.area):.1f}")
3.0 m per floor: FSI 2.48, mean storeys 5.2
3.2 m per floor: FSI 2.34, mean storeys 4.9
3.5 m per floor: FSI 2.13, mean storeys 4.5

Going from 3.0 to 3.5 m per floor, a 17% change in the assumption, moves FSI by 14%. Measured storey counts, where a register has them, remove the assumption.

Example 3 — where each 250 m square sits

by_grid["GSI"] = by_grid.fp / by_grid.land
by_grid["FSI"] = by_grid.gfa / by_grid.land
by_grid["L"] = by_grid.FSI / by_grid.GSI
print(by_grid[["GSI", "FSI", "L"]].describe().round(2).loc[["min", "50%", "max"]].to_string())
      GSI   FSI     L
min  0.27  1.30  3.60
50%  0.48  2.43  4.60
max  0.70  4.81  7.07

Within one square kilometre, 250 m squares range from a quarter built on to 70%, and in floor area ratio by almost a factor of four. The spread is the fabric; a single district figure averages it away.

Median floor area ratio of the same buildings measured on tessellation cells, street enclosures, 250 m grid squares and the whole square kilometre. floor area ratio (FSI) by land unitcells (median)2.02enclosures (median)1.92250 m grid (median)2.43whole 1 km square2.340123Coverage barely moves (0.48–0.55); floor area ratio does spatialworkflow.io
Same buildings, four land units, four floor area ratios.

Two ratios, and land units change everything

Why two ratios are needed

GSI says how much ground is covered; FSI how much floor is stacked on the land. Their ratio is the average height in storeys. A 0.5 FSI can be two-storey houses covering a quarter of the land or ten-storey slabs covering a twentieth — the same density with a completely different city.

Why the land unit changes everything

Streets and open space are part of any urban area but not of any building's plot. Tessellation cells and plots exclude them; blocks exclude streets but include courtyards; grids and districts include everything. The same fabric therefore gives a sequence of decreasing ratios as the unit grows, and comparisons across studies fail when the units differ.

Why the floor height is an assumption to report

LiDAR measures building height, not storeys. Converting needs a floor-to-floor height that varies by era and use — Victorian houses with 3.5 m ceilings, modern flats at 2.9 m, offices at 3.5–4 m. Using one value for a mixed area is a simplification whose effect should be shown.

The floor-count line of a floor area calculation, annotated with the assumption it carries. one assumption, carried into every FSIheight = b3_h_70p - b3_h_maaiveldfloors = max(1, round(height / 3.2))gfa = footprint * floorsfsi = gfa.sum() / land_area①②③①measured: LiDAR roof height②assumed: 3.0 → FSI 2.48, 3.5 → 2.13③the unit decides the numberA 17% change in assumed floor height moves FSI by 14% spatialworkflow.io
Measured height, assumed floor height, chosen land unit: two of the three are choices.

Basements, pitched roofs and mixed use

  • Basements add floor area invisible to height; underground car parks can dominate FSI in city centres.
  • Pitched roofs: the 70th-percentile height sits between eaves and ridge; attic floors may or may not count.
  • Edge cells of any unit are incomplete; drop or flag them.
  • Mixed use: gross floor area includes everything; for residential density, restrict to residential floor area.
  • Units in the literature: FSI is also called FAR (floor area ratio), GSI building coverage ratio or BCR.

FAQ

What is the floor area ratio?

Gross floor area divided by land area. Also called FSI.

What is ground coverage?

Building footprint area divided by land area. Also called GSI or building coverage ratio.

How do I get floor area from building heights?

Divide height by an assumed floor-to-floor height to estimate storeys, then multiply by the footprint.

Which land area should I divide by?

The unit your question is about — plot, block, grid cell or district — and say which. Each gives a different ratio.

What is the open space ratio?

(1 − GSI) / FSI: unbuilt ground per unit of floor area, a measure of how much space each square metre of floor has around it.

Why do published FAR values disagree?

Different land units, floor-height assumptions and building sources.