Height cascade¶
The building-height cascade: fill every footprint's height, and say how well.
Overture solves footprint coverage; it does not solve height. The answer here is a graded
cascade — per-building height, height_source and height_confidence — plus per-unit
height_completeness and the full tier distribution, so that "90% surveyed heights" and
"90% coarse raster fallback" are distinguishable in the output. They produce the same LCZ label
with very different trustworthiness.
This is what differentiates lczkit from an implementation running on OpenStreetMap alone.
Overture Maps solves footprint coverage; it does not solve height. Overture merges several sources
winner-takes-all per building, and only OpenStreetMap among them carries heights — so wherever a
machine-learning footprint source won the geometry, there is no height at all. That is much of the
Global South, and plenty of developed cities outside the centre: Cairo, Nairobi and Islamabad each
carry a directly measured height for about 1% of their building area.
The answer is a graded cascade — a series of sources tried in order, each filling only what the last left empty — plus honest reporting of which one answered. The tiers, in order:
- Overture
height, elsenum_floors × storey_height - Google Open Buildings 2.5D — retired from the default cascade, measured harmful
- WSF-3D, global ~90 m
- GHS-BUILT-H, global 100 m
Tiers 2–4 are areal products: a raster giving one value per cell, so every building inside a
cell is assigned the same neighbourhood average. That is a
categorically weaker measurement than tier 1 and the output says so, per building via
height_source and per unit via height_tier_fractions — because "90% real heights" and "90%
coarse raster fallback" produce the same label with very different trustworthiness.
Per-building accuracy is the wrong acceptance test for a height product
Open Buildings 2.5D has the lowest per-building error of the three and the only within-unit
skill, and still makes the map worse. Hr is a geometric mean, and dispersion depresses
it: GOB's within-unit spread is 0.441 against reality's 0.195, so over half is noise. Evaluate
any new tier on within-unit dispersion against reality, not on MAE.
lczkit.heights.cascade
¶
Running the height tiers in order, and recording what each one managed.
The report is the point. A cascade that fills every building from surveyed heights and one that fell all the way through to a 100 m raster produce the same labels, so the output has to tell them apart.
UNRESOLVED
module-attribute
¶
height_source tag for a building no tier could resolve. Its height stays null.
HeightTierResult
¶
Bases: BaseModel
What one tier contributed to one cascade run.
filled_by_source
class-attribute
instance-attribute
¶
Breakdown by height_source tag, for tiers that resolve rows by more than one route.
HeightFillReport
¶
Bases: BaseModel
The full record of one fill_heights() run, for the output manifest.
height_sources
class-attribute
instance-attribute
¶
Every tag the configured cascade could emit, in tier order, ending with "unresolved".
Downstream stages use this as the fixed column set for per-unit tier fractions, so the
output schema depends on the configured cascade rather than on which tiers happened to fire.
cascade_height_sources
¶
cascade_height_sources(tiers: Sequence[HeightSource]) -> list[str]
Every height_source tag tiers can emit, in order, plus "unresolved".
fill_heights
¶
fill_heights(buildings: GeoDataFrame, tiers: Sequence[HeightSource]) -> tuple[GeoDataFrame, HeightFillReport]
Run tiers in order over buildings, returning the filled layer and a report.
Every returned row carries height_source and height_confidence. Rows no tier resolved
are tagged "unresolved" with a null height rather than raising: with a tier's product
simply absent from disk — the normal state of tiers 2-4 — refusing to return would make the
package unusable where it should instead be honest. Callers that need completeness should
read it off the report or off height_completeness per unit.
buildings is not mutated.
Source code in src/lczkit/heights/cascade.py
Tiers¶
lczkit.heights.tiers
¶
The tiers of the height cascade.
Two HeightSource implementations cover all four tiers: OvertureAttributeTier is tier 1, and
ArealRasterTier is tiers 2, 3 and 4 — Google Open Buildings 2.5D, WSF-3D and
GHS-BUILT-H differ only in which file they read and how its values scale, which is why they are
three configured instances of one class rather than three classes. Adding a fifth areal product
is an entry in HeightConfig.areal_tiers, not a new implementation.
Tiers 2-4 are areal products: they assign a neighbourhood mean to individual buildings. That
is a categorically weaker measurement than tier 1, and the per-building height_source and
per-unit tier fractions exist so the output says so rather than presenting one number as if it
were the other.
Every tier claims only rows no earlier tier tagged, so the cascade order in
HeightConfig.areal_tiers is what makes the result correct. Run them through
lczkit.heights.cascade.fill_heights rather than calling fill by hand.
OVERTURE_HEIGHT
module-attribute
¶
height_source tag for a height taken from Overture's own height attribute.
OVERTURE_NUM_FLOORS
module-attribute
¶
height_source tag for a height derived from num_floors x storey_height_m.
OvertureAttributeTier
¶
OvertureAttributeTier(*, storey_height_m: float, height_confidence: float | None, num_floors_confidence: float | None)
Tier 1: Overture's height, else num_floors x storey_height_m.
The strongest tier available, and still not a guarantee of a surveyed measurement — a
quarter of the tier-1 heights in the Berlin test fixture are Microsoft ML values conflated
onto OSM footprints (see lczkit.heights.provenance). Where Overture attaches its own
per-building confidence to a height, that real number is written to height_confidence in
preference to the configured one, so the distinction survives into the output.
The num_floors path is a weaker measurement than the height path — storey height varies
regionally and is a real error source — which is why it gets its own height_source tag and
its own confidence rather than being folded into the first.
Configure the storey height and the two confidences this tier's routes carry.
Both confidences are required rather than defaulted: they are an ordinal ranking of
measurement quality with no published value behind them, and a default would write a
quality claim nobody chose into every manifest. _require_confidence says so if unset.
Source code in src/lczkit/heights/tiers.py
name
property
¶
The tier's name in the cascade. Its rows are tagged by height_sources, not by this.
height_sources
property
¶
The height_source tags this tier can write.
Two tags, not one — the height and num_floors routes are different measurements and
the output must keep them apart. See the class docstring.
fill
¶
Resolve heights from Overture's own attributes, height first and num_floors after.
A non-positive or non-finite height is cleared rather than kept: it is not a
measurement, and leaving it would hand the next tier a resolved row and the output a
zero-height building. Where Overture attaches its own per-building confidence to a
height, that real number is written in preference to the configured one.
Rows this tier cannot resolve are returned unchanged for the next tier in the cascade.
buildings is not mutated.
Source code in src/lczkit/heights/tiers.py
ArealRasterTier
¶
ArealRasterTier(*, name: str, path: Path, confidence: float | None, band: int = 1, scale: float = 1.0, nodata: float | None = None, min_height_m: float = 0.0)
Tiers 2-4: a neighbourhood mean read out of a local height raster.
One class for Google Open Buildings 2.5D, WSF-3D and GHS-BUILT-H, configured per product
(lczkit.config.ArealTierConfig). Nothing product-specific is hardcoded — band, unit scale,
nodata and minimum valid height are all config, because none of these products is present on
the system this was written against and guessing at one is exactly the failure mode that
produces a quietly wrong map.
Values at or below min_height_m are read as "no built volume in this cell" and left for the
next tier, rather than written out as a zero-height building.
Configure one areal product: where its raster is, and how to read a height out of it.
band, scale, nodata and min_height_m are per-product and come from the product's
own documentation via ArealTierConfig — nothing here is hardcoded, because guessing at
a unit scale or a nodata value is what produces a quietly wrong map. confidence is
required for the same reason the Overture tier's are.
Source code in src/lczkit/heights/tiers.py
height_sources
property
¶
The single height_source tag this tier writes — its own product name.
from_config
classmethod
¶
from_config(config: ArealTierConfig, path: Path) -> ArealRasterTier
Build a tier from its serialised config and an already-resolved raster path.
The split is deliberate: config carries everything reproducible into the manifest,
while path is placed per study area by lczkit.sources.height_products. A filename
baked into the config would be a filename for one city.
Source code in src/lczkit/heights/tiers.py
fill
¶
Give each unresolved building the mean raster height under its own footprint.
A categorically weaker measurement than tier 1 and tagged as such: the product's cell is
90-100 m, so what a building receives is its neighbourhood's mean height, not its own.
Values at or below min_height_m read as "no built volume in this cell" and are left for
the next tier rather than written out as a zero-height building.
Rows this tier cannot resolve are returned unchanged. buildings is not mutated.
Source code in src/lczkit/heights/tiers.py
numeric_column
¶
buildings[name] coerced to float, or an all-null float Series if the column is absent.
Overture's height is float and num_floors a nullable integer, and either can be missing
entirely from a non-Overture layer. Every read of both goes through here so that "column
absent" and "column present but null" behave identically. Neither is an error at this layer:
a missing height is what the rest of the cascade exists to answer.
Source code in src/lczkit/heights/tiers.py
prepare
¶
Copy buildings with the cascade's three output columns present and correctly typed.
Idempotent, so a tier run standalone gets the same frame shape the cascade would give it.
A row is "unresolved" exactly when height_source is null — not when height is null,
because tier 1 reads a height that is already there and must still tag its provenance.
Source code in src/lczkit/heights/tiers.py
build_cascade
¶
build_cascade(config: HeightConfig, source_dir: Callable[[str], Path]) -> list[HeightSource]
Assemble the ordered tier list from config.
source_dir is Settings.source_dir in a real run; taking the callable rather than
Settings keeps tests able to point tiers at a temporary directory without DATA_DIR.
An areal tier with no filename is skipped — the product is simply not available, and a
shorter cascade with an honest height_completeness beats a failure. A tier that names a
file which is not there raises, because that is a misconfiguration rather than an absence.
A tier with enabled=False is skipped whether or not its file is there: the flag means off,
and a flag whose meaning depended on which function read it would be worse than no flag.
Source code in src/lczkit/heights/tiers.py
Raster reads¶
lczkit.heights.raster
¶
A minimal local zonal read for the height cascade.
Tiers 2-4 need raster values, and the RasterSource protocol is about land-cover fractions per
unit rather than a mean per footprint. This module is deliberately kept to one function, so a tier
could be repointed at a RasterSource by changing one line.
It is not a general zonal-statistics implementation and should not grow into one —
lczkit.landcover brings exactextract for that. What it does is read one window of one band and
reduce it to a mean per footprint, which is all tiers 2-4 ask for.
zonal_mean
¶
Mean raster value under each geometry in geoms, as a float array aligned to geoms.
Returns nan for any geometry the raster cannot answer for: outside its extent, or covering
only nodata cells. That is a real answer — "this product does not know" — and the caller
passes those rows on to the next tier rather than inventing a value.
Cells are attributed with all_touched=True, so a footprint smaller than one cell still
picks up the cell(s) it overlaps. For the coarse products this backs (~90-100 m), a building
is typically far smaller than a cell and the result is that cell's neighbourhood mean, which
is exactly what those products measure. Footprints that burn no cell at all fall back to the
value at their representative point.
geoms is reprojected to the raster's CRS internally; it must have a CRS set, and so must
the raster. Overlapping geometries are resolved last-writer-wins by rasterio.features;
planar enforcement in cleaning means building footprints do not overlap, so this does not
arise for the cascade's own use.
Source code in src/lczkit/heights/raster.py
Completeness and provenance¶
height_completeness and height_tier_fractions are primary deliverables, not diagnostics.
lczkit.heights.completeness
¶
Per-unit height provenance: height_completeness and the full tier distribution.
These are primary deliverables, not diagnostics. "90% surveyed heights" and "90% coarse raster fallback" produce the same LCZ label with very different trustworthiness, so the output carries the whole distribution across tiers rather than a single completeness number.
TIER1_SOURCES
module-attribute
¶
TIER1_SOURCES = (OVERTURE_HEIGHT, OVERTURE_NUM_FLOORS)
The height_source tags that count towards height_completeness.
Both tier-1 routes: a surveyed height, and a storey count multiplied by a storey height. The two are reported as separate fractions as well, so a stricter reading — completeness as surveyed heights only — stays computable from the same table without this module choosing it for everyone.
height_metrics
¶
Per-unit height provenance, indexed by unit_id to match units.
Returns height_completeness — the area fraction of building footprint resolved by tier 1 —
plus one height_frac_<source> column for every tag in sources, which is
HeightFillReport.height_sources in a real run. Passing the tag list explicitly is what
fixes the output schema to the configured cascade rather than to whichever tiers happened to
fire in a given city; Phases 6 and 7 need that stability.
Weighting is by the area of each footprint inside the unit, so a building straddling two
grid cells contributes to both in proportion. For EnclosureUnits this is equivalent to
assigning each building to one unit, since cross-layer topology cleaning already removes
buildings that intersect the streets forming enclosure boundaries.
Units containing no building area are all-null, not zero: "no buildings here" and "0% tier-1 coverage" are different statements and collapsing them would misreport every park and water body as a height-data failure.
Source code in src/lczkit/heights/completeness.py
Dispersion¶
Coverage is only half of what a substituted height does. Hr is a geometric mean, so it is
depressed by spread and rises as spread collapses — and the tiers that shipped compress
within-unit spread rather than inflating it, which is the opposite of the failure Open Buildings was
rejected for. Median coefficient of variation across whole-city runs: 0.266 for real Overture
heights in Berlin, 0.192 for WSF-3D in Nairobi, and 0.112 for GHS-BUILT-H in Bogotá, where 23.6% of
units carry a single height throughout. Each run reports its own figures in the manifest.
lczkit.heights.dispersion
¶
Within-unit height dispersion, per cascade tier — what an areal product costs Hr.
height_completeness says where a height came from. It says nothing about what the substitution
did to the shape of the height distribution inside a unit, and that is the quantity Hr is
sensitive to: it is a geometric mean, so it is depressed by spread and rises as spread collapses.
The sensitivity was established from one side. Google Open Buildings 2.5D had the lowest per-building error of any tier and the only within-unit skill, and it degraded the map, because its within-unit spread was 0.441 against reality's 0.195 — over half of it noise. A height tier is therefore accepted on within-unit dispersion and not on mean absolute error.
This module measures the other side, which nothing has: the tiers that were adopted compress dispersion rather than inflating it. Measured on the runs on disk, over units with buildings:
| dominant source | city | median h_std |
median CV | constant units |
|---|---|---|---|---|
Overture height |
Berlin | 1.52 m | 0.266 | 0.1% |
| WSF-3D | Nairobi | 0.88 m | 0.192 | 1.3% |
| WSF-3D | Bogota | 1.05 m | 0.207 | 1.1% |
| GHS-BUILT-H | Bogota | 0.36 m | 0.112 | 23.6% |
A 90 m or 100 m product hands one height to every building it covers, so what survives inside a
unit is variation between raster cells rather than between buildings. Same mechanism, opposite
sign, and it biases Hr upward exactly where the cascade is doing the most work.
Reported per run because it is the target any future shrinkage work aims at: shrinking a fine-resolution product toward the unit mean is only worth doing against a measured statement of how much dispersion the incumbent has already lost.
TierDispersion
¶
Bases: BaseModel
Within-unit height spread across the units one tier dominates.
n_units
instance-attribute
¶
Units where this tier supplied more building area than any other.
median_h_std
instance-attribute
¶
Median of h_std — the area-weighted standard deviation of building height within a unit.
median_cv
instance-attribute
¶
Median of h_std / h_mean_area_weighted. The scale-free form, and the one comparable
against the 0.441-against-0.195 figures above.
constant_fraction
instance-attribute
¶
Share of those units whose buildings all carry the same height to within a centimetre. A direct reading of how often the product resolves nothing inside a unit at all.
DispersionReport
¶
Bases: BaseModel
Within-unit height dispersion for one run, per tier.
min_building_surface_fraction
instance-attribute
¶
Units below this are excluded: a unit holding almost no building has a spread that is about its two buildings rather than about its fabric.
min_building_count
instance-attribute
¶
Units with fewer buildings are excluded, for the same reason. A spread over two buildings is not a description of a neighbourhood.
n_units
instance-attribute
¶
Units that passed both filters and carried a dispersion value.
dispersion_report
¶
dispersion_report(parameters: DataFrame, *, min_building_surface_fraction: float = 0.05, min_building_count: int = 3) -> DispersionReport
Within-unit height dispersion per tier, from a finished parameter table.
parameters is what lczkit.ucp.compute_parameters() returns joined to the per-unit height
fractions — the table a run assembles anyway — so this reads columns rather than
recomputing anything, and it moves no measurement.
A unit is attributed to whichever tier supplied the largest share of its building area, which is a simplification and is stated as one: a unit split evenly between Overture and WSF-3D is counted wholly against the larger. The alternative, area-weighting every unit into every tier, would mix distributions and defeat the comparison the table exists to make.
Source code in src/lczkit/heights/dispersion.py
lczkit.heights.provenance
¶
Reading per-attribute provenance out of Overture's sources column.
Overture records provenance per attribute, not just per feature. Each entry in sources
carries a property naming what it applies to — '' for the footprint as a whole, or a JSON
pointer such as /properties/height for one attribute — alongside the dataset it came from
and, for machine-derived values, a confidence.
That distinction is load-bearing here, and it qualifies the usual description of Overture
conflation as winner-takes-all with no attribute fusion. In release 2026-07-22.0,
394 of the 6195 footprints in the Berlin test fixture are OSM-won yet carry a height
attributed to Microsoft ML Buildings — a quarter of every tier-1 height in that extent. Grouping
provenance by the footprint's dataset alone would report those as surveyed OSM heights.
These two functions are the single parser for that column; the tier-1 height source and the source-availability diagnostic both read through them rather than each growing their own.
FOOTPRINT_PROPERTY
module-attribute
¶
sources[].property value marking the entry that describes the feature as a whole.
HEIGHT_PROPERTY
module-attribute
¶
sources[].property value marking the entry that describes the height attribute.
footprint_datasets
¶
The upstream dataset that won each footprint's geometry, as a string Series.
Null where the row carries no whole-feature provenance entry, and for every row when
buildings has no sources column at all — a non-Overture VectorSource degrades to "no
provenance known" rather than raising.
Source code in src/lczkit/heights/provenance.py
height_attribution
¶
(dataset, confidence) describing where each row's height value came from.
Where Overture records a /properties/height provenance entry, both come from it — this is
the case that separates a conflated machine-learning height from a surveyed one, and the
confidence is a real per-building number rather than anything this package assigns.
Where it does not, the dataset falls back to the footprint's own and the confidence is null: the height, if any, came in with the footprint, and Overture attaches no confidence to it.
Source code in src/lczkit/heights/provenance.py
lczkit.heights.diagnostic
¶
Source-availability diagnostic: is this city viable before anyone waits for a full run?
It reports non-null height and num_floors counts grouped by Overture source dataset over the
study area — twice, because the data forces it: once by the dataset that won the footprint, and
once by the dataset that supplied the height.
Those two tables disagree, and the disagreement is the useful part. In the Berlin test fixture, grouping by footprint reports 1509 heights on OpenStreetMap footprints — but 394 of those heights are Microsoft ML Buildings values conflated onto an OSM footprint. Read the first table alone and a quarter of the city's tier-1 heights look surveyed when they are predicted.
UNKNOWN_DATASET
module-attribute
¶
Stand-in for a row carrying no usable provenance, so it is counted rather than dropped.
DatasetAvailability
¶
Bases: BaseModel
Height and floor-count availability for one upstream dataset.
HeightProvenance
¶
Bases: BaseModel
How many heights one upstream dataset actually supplied.
SourceAvailability
¶
Bases: BaseModel
The full diagnostic for one study area, for the output manifest.
by_footprint_dataset
class-attribute
instance-attribute
¶
by_footprint_dataset: list[DatasetAvailability] = Field(default_factory=list)
Grouped by the dataset that won each footprint's geometry.
by_height_dataset
class-attribute
instance-attribute
¶
by_height_dataset: list[HeightProvenance] = Field(default_factory=list)
Heights only, grouped by the dataset that supplied the height value rather than the one
that won the footprint.
Deliberately narrower than the table above. Provenance in Overture is per attribute, and the
only attribute conflated away from its footprint is height; reporting a building's
num_floors under the dataset that supplied its height would credit a dataset with a value
it never provided. Rows with no height contribute nothing here, which is why these counts do
not sum to n_buildings.
source_availability
¶
source_availability(buildings: GeoDataFrame) -> SourceAvailability
Count height and floor availability by upstream dataset over buildings.
Reads Overture's sources column. A layer without one still returns valid totals, with
every row grouped under "(unknown)" — the diagnostic degrades rather than blocking a
non-Overture VectorSource.
Source code in src/lczkit/heights/diagnostic.py
lczkit.heights.inherit
¶
Carry resolved heights from one building layer onto another.
Cleaning produces two building layers from one source. The height cascade runs once, on
buildings_area — that is the complete population, and so the honest denominator for
height_completeness and for the source-availability diagnostic. buildings_topo still needs
heights, because momepy.street_profile measures a canyon's height-to-width ratio against the
buildings walling it, so the values have to reach it from somewhere.
Not by building_id. A dissolved buildings_topo feature keeps one arbitrary constituent's id,
and on the Berlin fixture that constituent is as likely to be an absorbed sub-20 m² shed as the
block that absorbed it — which would hand a perimeter block a garage's height. Largest overlap is
the correct rule and costs one overlay.
INHERITED_COLUMNS
module-attribute
¶
What the cascade produces per building, and therefore what travels.
inherit_heights
¶
Give every target footprint the height of the source footprint it overlaps most.
source must have been through lczkit.heights.cascade.fill_heights(). A target footprint
overlapping no source footprint keeps a null height rather than being dropped or imputed:
street_profile skips null-height buildings when averaging, so the aspect ratio's coverage
degrades rather than its value. Neither input is mutated.