Urban canopy parameters¶
An urban canopy parameter is a number describing the shape of the urban surface inside one spatial unit — how much of the ground is building, how tall the buildings are, how deep the street canyons are. These are the inputs the classification runs on. Each is defined in the glossary.
The parameter table, keyed by unit_id. Every column is registered with a documented unit and a
source reference — see the registry — because a parameter written to the output
without those is a number nobody can check.
Two definitions are load-bearing and easy to get wrong:
Hr, the height of roughness elements — the typical height of whatever sticks up into the wind — is the geometric mean of building heights, per Stewart & Oke and Bernard et al. (2024) Table 1 — not the area-weighted arithmetic mean. The two diverge materially in units mixing tall and short buildings, and the ranges classification normalises against were defined for the geometric mean.h_mean_area_weighted,h_stdandh_geometric_area_weightedare secondary columns and are not used for classification.- Building surface fraction — the share of a unit's ground covered by building — comes from
buildings_area, neverbuildings_topo. Cleaning produces two building layers: one made valid for topology, at the cost of some footprint area, and one that preserves area. Using the topology layer here discards roughly a quarter of the footprint area and was the single largest known source of classification error.
One further column is a flag rather than a measurement. impervious_clipped marks the units where
the building share exceeded the raster's built-up class, so the subtraction that separates roofs
from other sealed ground had to be clipped at zero. Everywhere else building, impervious and
pervious shares sum to exactly one; where this fires they exceed it. It fires wherever vector
footprints cover more ground than a 10 m land-cover product calls built-up, which is dense
low-rise mapped from imagery.
lczkit.ucp
¶
Urban canopy parameters, one row per unit_id.
The parameters Stewart & Oke (2012) actually define an LCZ by, in the units their table uses,
plus the functional industrial_fraction that makes LCZ 10 reachable at all. Everything here is
a pure transform over the earlier stages' outputs: no raster reads, no network, no file I/O.
Two of Stewart & Oke's seven morphological properties are not computed — sky view factor and
terrain roughness. See lczkit.ucp.registry for why, and the README for the same in prose.
Parameter assembly¶
lczkit.ucp.parameters
¶
compute_parameters(): every parameter block joined into one table per unit_id.
A pure transform over in-memory inputs: no raster, file or network access. Each vector layer is
intersected with the units once here (lczkit.units.overlay.unit_pieces) and the pieces are handed
to every block that needs them, so all area fractions share a denominator computed from the same
pieces as their numerator.
compute_parameters
¶
compute_parameters(units: GeoDataFrame, buildings_area: GeoDataFrame, buildings_topo: GeoDataFrame, streets: GeoDataFrame, land_use: GeoDataFrame, land_cover: DataFrame, *, config: UcpConfig, land_cover_config: LandCoverConfig) -> DataFrame
The full urban canopy parameter table, indexed by unit_id to match units.
Both building layers must have been through lczkit.heights.cascade.fill_heights() — or, for
buildings_topo, lczkit.heights.inherit.inherit_heights(). land_cover is a land-cover
fractions table for the dataset config.land_cover_dataset names. All vector layers must share
units' projected CRS — clean_vectors() returns them that way, and
lczkit.ucp.registry.PARAMETERS documents every column this returns.
Which layer feeds what. buildings_area supplies every area statistic: building surface
fraction, Hr, building count, mean building area, and the industrial fractions. Note the
industrial columns are named for their denominator, which is not building area in both cases -
see lczkit.ucp.registry. buildings_topo supplies only the street profile, because a
footprint lying across a street centreline reports a canyon width of zero and drives up the
aspect ratio — on
the Berlin fixture 439 footprints did exactly that. buildings_topo's facades have been trimmed
back to the road-buffer edge, which is a plausible facade line; buildings_area's have not,
because trimming them would cost the footprint area they exist to preserve.
Two of Stewart & Oke's seven morphological properties are absent by design: see
lczkit.ucp.registry.NOT_COMPUTED.
Source code in src/lczkit/ucp/parameters.py
Registry¶
The controlled vocabulary: one ParameterSpec per column, carrying its unit, its display label
and the paper it comes from.
lczkit.ucp.registry
¶
What every parameter column means, in what unit, from which source.
No parameter reaches the output without a documented unit and a source reference. Prose in a docstring cannot supply that to a machine: the run manifest serialises this registry, and the map site renders each parameter's unit of measurement in the per-unit sidebar. Keeping the documentation in a registry makes the pairing a tested invariant rather than something that drifts the first time a column is renamed.
STEWART_OKE_2012
module-attribute
¶
Stewart & Oke (2012), BAMS 93(12), 1879-1900. Defines the LCZ scheme and the property table the classifier scores against.
BERNARD_2024
module-attribute
¶
Bernard et al. (2024), GMD 17, 2077-2107. Table 1 gives operational definitions for the same properties over vector data.
MOMEPY
module-attribute
¶
Fleischmann (2019), JOSS 4(43), 1807. momepy.street_profile() is derived in turn from
Araldi & Fusco (2019), which momepy cites.
COMPUTED_HERE
module-attribute
¶
No published definition — a plain descriptive statistic, defined by its own description.
UNITS
module-attribute
¶
The controlled vocabulary ParameterSpec.unit draws on. "fraction" means a real in [0, 1].
PARAMETER_COLUMNS
module-attribute
¶
Column order of the table compute_parameters() returns, up to the semantic block.
The semantic columns are not here, because their names depend on the configured
groups. semantic_specs() builds their specs from the same config the columns come from, so a
group added in config cannot end up in the output with no documented unit or reference — which is
the state a static list would produce silently.
NOT_COMPUTED
module-attribute
¶
NOT_COMPUTED: tuple[tuple[str, str], ...] = (('sky_view_factor', 'Not computed. The single most expensive component, and strongly correlated with aspect ratio, which is computed. Bernard et al. (2018), 10.3390/cli6030060, is the preferred route when it is picked up: vector ray-launching, no DSM required. That is also the route GeoClimate takes — buildings as the only obstacles, 100 m rays in 60 directions, no elevation model (Bernard et al. 2024, 10.5194/gmd-17-2077-2024, Table 1) — so this is a gap in what lczkit builds, not in the data it has.'), ('terrain_roughness_class', 'Not computed. The Davenport et al. (2000) lookup maps a roughness class to a roughness length z0, and z0 itself is not computed from morphology either. Bernard et al. (2024) weight z0 at 0.5 against 8 for building fraction and 6 for mean height, so it is the least influential parameter in their scheme — deferring it costs the classification little.'))
Stewart & Oke properties this package does not compute, and why.
Recorded here rather than only in the README so the omission reaches the run manifest: a consumer reading the parameter table needs to know that two dimensions of the LCZ definition are absent, not zero.
LIMITATIONS
module-attribute
¶
LIMITATIONS: tuple[tuple[str, str], ...] = (('industrial_fraction_of_building_area, industrial_fraction_of_unit_area', "Overture exposes a single 'industrial' value with no heavy/light split. GeoClimate keys LCZ 10 on OSM's HEAVY INDUSTRY against light industry and commercial, and that distinction does not survive Overture's schema normalisation — the same normalisation that removes the need for a tag-mapping table also discards the semantic detail OSM carried. A light-industrial estate and a refinery are therefore indistinguishable here, so the LCZ 10 threshold is set to under-trigger: a missing LCZ 10 is a visible gap, whereas a light-industrial estate mislabelled as heavy industry is an invisible error that propagates into any model consuming the map. 'warehouse' is excluded from the industrial vocabulary; it is an LCZ 8 example."), ('h_mean_area_weighted, h_std', "Secondary columns. They are not Stewart & Oke's height of roughness elements and must not be used for classification — Hr is the geometric mean, and the LCZ property ranges were defined for it. These exist for the deferred roughness work (Macdonald, Kanda)."))
Known limitations of specific parameters, in the parameters' own terms.
The Overture heavy/light industry limitation has to reach the run manifest and not only the field
documentation, which means it has to be data rather than a docstring. It is serialised alongside
NOT_COMPUTED.
ParameterSpec
dataclass
¶
One column of the parameter table.
label
instance-attribute
¶
Short human-readable name, for a legend or a sidebar row.
Here rather than in the front end because a display name is part of what a parameter is, and
the alternative — column.replace("_", " ") in JavaScript — produced "height of roughness
elements m" and "industrial fraction of building area" on every published map. Carries no unit;
unit is appended separately so the two cannot disagree.
description
instance-attribute
¶
What the number is, precisely enough to reproduce it.
semantic_specs
¶
semantic_specs(groups: Iterable[SemanticGroupConfig]) -> tuple[ParameterSpec, ...]
ParameterSpec for every column the semantic layer emits for groups.
Built from config rather than transcribed, for the reason PARAMETER_COLUMNS gives: a static
list and a configurable group set drift, and no parameter may reach the output without a
documented unit and source reference.
Source code in src/lczkit/ucp/registry.py
spec
¶
spec(name: str, groups: Iterable[SemanticGroupConfig] | None = None) -> ParameterSpec
The ParameterSpec for column name, or a KeyError naming what exists.
groups extends the lookup over the configured semantic columns, whose names are not knowable
without it.
Source code in src/lczkit/ucp/registry.py
Buildings, streets, surface¶
lczkit.ucp.buildings
¶
Per-unit building morphology: surface fraction, height moments, count, mean footprint.
Buildings are assigned to units two different ways here, deliberately. The surface fraction and
the height statistics split footprints at unit boundaries, so a building straddling two grid cells
reaches both; that is the rule lczkit.heights.completeness.height_metrics() already uses, and
using a second one would make building_surface_fraction and height_completeness describe
subtly different populations. Object quantities — the count and the mean footprint area — assign
each whole building to the unit containing its representative point, because half a building is
not a building and the mean area of a set of fragments is not the mean area of a set of buildings.
For EnclosureUnits the two agree almost everywhere: cross-layer topology cleaning already drops
buildings intersecting the streets that form enclosure boundaries. It is the 100 m grid where the
distinction bites.
Three height statistics come out of this module and only one of them is Hr. Stewart & Oke's
height of roughness elements is the geometric mean of building heights — Bernard et al. (2024)
Table 1 gives it as exp(mean(log(h))) — and the LCZ property ranges the classifier normalises
against were defined for that quantity. The arithmetic mean sits above it whenever a unit mixes
tall and short buildings, so substituting one for the other would bias exactly the units where
classification is hardest, and would do it silently. h_mean_area_weighted and h_std are
therefore shipped as secondary columns: the deferred roughness work (Macdonald, Kanda) needs
them, and classification must not use them.
OVERLAY_COLUMNS
module-attribute
¶
OVERLAY_COLUMNS = (FEATURE_ID, 'height', *ATTRIBUTES)
Attributes carried through the building overlay.
FEATURE_ID and height are this module's; subtype and class belong to the functional
modules. All four ride the same intersection because lczkit.ucp.parameters performs it once and
hands the pieces to every consumer — the alternative is three overlays of a city's whole building
layer to answer three questions about it.
building_metrics
¶
building_metrics(buildings: GeoDataFrame, units: GeoDataFrame, config: UcpConfig, *, pieces: GeoDataFrame | None = None) -> DataFrame
Per-unit building morphology, indexed by unit_id to match units.
buildings must carry height, i.e. have been through
lczkit.heights.cascade.fill_heights(). Buildings that cascade left unresolved keep a null
height: they still count towards building_surface_fraction, building_count and
mean_building_area_m2, because a footprint is observed whether or not its height is, but
they are excluded from the height statistics rather than imputed.
pieces is buildings already intersected with units by lczkit.units.overlay.unit_pieces.
lczkit.ucp.parameters passes it because two other blocks need the same intersection and it is
the expensive half of all three; passing None performs it here. Note the object statistics
below do not read it — a count and a mean footprint area are about whole buildings, not about
the fragments a unit boundary leaves.
Neither input is mutated.
Source code in src/lczkit/ucp/buildings.py
lczkit.ucp.streets
¶
Per-unit street canyon geometry, from momepy.street_profile().
momepy.street_profile() measures along perpendicular ticks cast at a fixed spacing from every
street segment, so its output is per segment; getting to per unit is a
length-weighted mean over the parts of each segment that fall inside the unit.
That weighting is what makes the two unit strategies behave sensibly with one implementation. For
GridUnits a segment is cut where it crosses a cell boundary and each cell gets the piece it
contains. For EnclosureUnits the streets are the boundaries — momepy.enclosures() polygonises
the same linework — so a segment lies on the shared edge of two enclosures and is counted for both.
That is the right answer rather than a leak: a street canyon belongs to the fabric on both of its
sides.
street_metrics
¶
street_metrics(streets: GeoDataFrame, buildings: GeoDataFrame, units: GeoDataFrame, config: UcpConfig) -> DataFrame
Per-unit aspect_ratio, street_openness and street_width_m, indexed by unit_id.
buildings must carry height. Buildings the height cascade left unresolved keep a null
height; momepy skips them when averaging tick heights rather than propagating the null, so a
partially resolved cascade degrades the aspect ratio's coverage rather than corrupting its
value.
A unit crossed by no street is null on all three. A unit crossed by streets that reach no
building is null on aspect_ratio only — momepy still reports a width (the tick length, as a
theoretical maximum) and an openness of 1.0 there, which are real statements about an open
street, whereas the height-to-width ratio of a canyon with no walls is not.
Source code in src/lczkit/ucp/streets.py
lczkit.ucp.surface
¶
Stewart & Oke's surface fractions, reassembled from the land-cover table.
RasterSource emits disjoint classes summing to 1.0, because that is what the protocol
requires. Stewart & Oke (2012) do not partition the surface the same way, and two of the
differences change which LCZ classes are reachable at all:
- Tree cover and water are pervious. Their table puts LCZ A (dense trees) and LCZ G (water)
both at 90%+ pervious surface fraction. The land-cover table carves
treeout ofperviousand reportswaterseparately, so both have to be folded back in here or neither class can ever be matched. Both stay available as their own columns as well — they are what separates LCZ A and B from the other pervious classes, and LCZ G from everything. - Buildings are inside the impervious class, and must come out. A raster's built-up class is measured from above and contains the roofs; Stewart & Oke's building, impervious and pervious fractions partition the surface between them, their per-class midpoints summing to roughly one. Left in, a compact midrise unit would report a building fraction of 0.5 alongside an impervious fraction of 0.9 and sit nowhere near any prototype.
CLIPPED_COLUMN
module-attribute
¶
Per-unit flag: the building share exceeded the raster's impervious class and the subtraction was clipped at zero.
The three Stewart & Oke fractions partition the surface — building + impervious + pervious is
exactly 1.0 by construction here, because the raster's own classes sum to 1.0 and the building
share is moved from one term to another. The clip is the one place that identity breaks, and it
breaks upward: the unit reports more than a whole surface.
It is not a rare corner. It fires wherever the vector footprints cover more ground than the
raster calls built-up, which is dense low-rise mapped from imagery a 10 m product under-detects —
the same fabric, and the same cities, that the height cascade is worst in. Reported rather than
silently absorbed, for the same reason height_tier_fractions is reported: the number is still
usable, but not without knowing it was adjusted.
surface_fractions
¶
surface_fractions(land_cover: DataFrame, building_surface_fraction: Series, dataset: LandCoverDatasetConfig, config: UcpConfig) -> DataFrame
Stewart & Oke surface fractions, indexed to match building_surface_fraction.
land_cover is a land-cover fractions table for dataset — column names are dataset's own
column_prefix plus a class name. Units the raster did not cover are null there and stay null
here; a null land-cover fraction is not zero cover.
Source code in src/lczkit/ucp/surface.py
Functional evidence¶
The industrial shares exist because LCZ 8 and LCZ 10 are geometrically near-identical, and because anthropogenic heat output — the only published Stewart & Oke property separating them directly, at 300+ against ≤50 W m⁻² — is not something this package can measure.
Both industrial_fraction_of_building_area (Bernard's FIND/B, which the LCZ 10 rule reads) and
industrial_fraction_of_unit_area are emitted, each named for what it divides by.
Overture attributes¶
lczkit.ucp.attributes
¶
Overture's subtype and class, and how every functional parameter selects on them.
Ingestion reads these two attributes on every building and every land-use parcel, and cleaning is
test-pinned to retain them. Two parameter blocks select on them — industrial_fraction and the
semantic groups — so the vocabulary lives here rather than in whichever module happened to need
it first.
It lives here instead because three modules now read it: ucp.industrial, ucp.semantics, and
ucp.buildings, which carries the two attributes through its overlay so the other two can select
without intersecting the layer again.
ATTRIBUTES
module-attribute
¶
The two attributes every functional selection in this package reads.
Both, and either — Overture files most industrial buildings under subtype='industrial' and
class='industrial', but the two are independently nullable and a feature carrying only one of
them is still industrial.
UNKNOWN
module-attribute
¶
Overture's sentinel for "no answer recorded", which is not a category.
Counting it as a tag would report coverage the data does not have, which is the exact failure
building_tag_coverage exists to prevent.
require_attributes
¶
require_attributes(layer: GeoDataFrame, name: str, *, subtypes: Sequence[str], classes: Sequence[str]) -> None
Raise if the configuration selects on a column the layer does not carry.
Checked against the layer, not against an overlay of it, so an extent where nothing intersects cannot turn a missing column into a silent zero.
Source code in src/lczkit/ucp/attributes.py
select_pieces
¶
select_pieces(pieces: GeoDataFrame, *, subtypes: Sequence[str], classes: Sequence[str]) -> GeoDataFrame
The pieces whose subtype or class is one of the configured values.
A boolean mask over pieces that already exist, so selecting a second group costs a comparison rather than a second intersection — which is what stops the parameter stage's cost growing with the number of configured semantic groups.
Positional throughout. Neither building layer carries a uniqueness guarantee, and .loc by
index over a duplicated one silently returns extra rows: a wrong number, not an error.
Source code in src/lczkit/ucp/attributes.py
tagged_pieces
¶
The pieces carrying any usable subtype or class, i.e. neither null nor unknown.
The numerator of building_tag_coverage, and the column that makes every other semantic
fraction readable: a lightweight share of 0.0 in Nairobi is 94.8% of building area carrying
no tag, not an absence of informal settlement.
Source code in src/lczkit/ucp/attributes.py
Industrial¶
lczkit.ucp.industrial
¶
The industrial shares that drive the LCZ 10 rule.
LCZ 8 (large low-rise) and LCZ 10 (heavy industry) are geometrically near-identical, so LCZ 10 needs a functional signal; the classifier applies it after the prototype distance, never inside it. Industrial buildings and industrial land-use parcels are combined by union, so a factory standing inside an industrial parcel counts once.
Both denominators are emitted, each named for what it divides by:
industrial_fraction_of_building_area: industrial building area over all building area, Bernard et al. (2024)'sFIND/B, so their 0.33 threshold transfers. Null where nothing is built.industrial_fraction_of_unit_area: industrial ground (buildings or parcels) over unit area.
A working port plot is sparsely built, so the two diverge sharply there; the LCZ 10 rule reads the
building-area share. Each source's own unit-area share ships too, with industrial_evidence naming
which contributed.
EVIDENCE
module-attribute
¶
Fixed category set for industrial_evidence, so the output schema does not depend on which
evidence a given city happens to carry.
industrial_metrics
¶
industrial_metrics(buildings: GeoDataFrame, land_use: GeoDataFrame, units: GeoDataFrame, config: UcpConfig, *, building_area_m2: Series | None = None, building_pieces: GeoDataFrame | None = None, land_use_pieces: GeoDataFrame | None = None) -> DataFrame
Per-unit industrial area shares and evidence, indexed by unit_id to match units.
building_area_m2 is the per-unit building footprint area, the denominator of
industrial_fraction_of_building_area. building_pieces and land_use_pieces are the two
layers already intersected with the units by lczkit.units.overlay.unit_pieces. All three are
passed in rather than recomputed because lczkit.ucp.parameters has them: overlaying a city's
buildings against its units is the expensive half of this function, and it is the same overlay
building_metrics and semantic_metrics need. A direct caller may omit any of them and pay
for the work, which is what they would otherwise write themselves.
Every unit-area column is zero rather than null where nothing industrial is present: unlike a land-cover fraction, which can be genuinely unobserved, "no industrial feature covers this unit" is a measurement. The building-area column is the exception and is null where the unit holds no buildings, because a share of nothing is not zero — it is undefined, and reporting 0.0 there would tell the LCZ 10 rule that a buildingless cell is definitely not industrial rather than that there is nothing to judge. Neither input is mutated.
Source code in src/lczkit/ucp/industrial.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | |
Semantic evidence¶
Overture's subtype/class vocabulary, read through a committed crosswalk. Each fraction ships
beside a coverage column, and that is the point: a lightweight fraction of 0.0 in Nairobi is
94.8% of building area carrying no tag at all, not an absence of informal settlement.
lczkit.ucp.semantics
¶
Functional evidence from Overture's own attributes, and how much of it there is.
Per configured SemanticGroupConfig, the share of building area and of unit area whose Overture
subtype or class places it in the group, plus two coverage columns that make those shares
readable: building_tag_coverage and land_use_coverage.
The coverage columns are the point. Tagged building area is 48.6% across Europe and North
America against 13.6% elsewhere (Rio 3.1%), because ML-derived footprints carry no attributes and
Overture's conflation is winner-takes-all per building. A lightweight share of 0.0 where 97% of
building area is untagged is not evidence of absence. Land-use parcels generalise better (30-65%
coverage where building tags are near-absent), so the two are reported separately.
Built types only: park, forest, grass and farmland are deliberately unmapped, because
rasters own land cover. The vocabulary is docs/references/tables/overture_lcz_semantic_mapping.md,
which a test parses and asserts against.
PARCEL_SUFFIX
module-attribute
¶
Both a numerator and a denominator in every column name.
The two are not comparable: one divides tagged building area by all building area, the other dissolved parcel area by unit area.
group_columns
¶
group_columns(groups: list[SemanticGroupConfig]) -> tuple[str, ...]
Every column semantic_metrics emits for groups, in order.
Derived from the configured groups rather than listed as a constant, so a group added in config cannot silently fail to appear in the output schema or the registry.
Source code in src/lczkit/ucp/semantics.py
semantic_metrics
¶
semantic_metrics(buildings: GeoDataFrame, land_use: GeoDataFrame, units: GeoDataFrame, config: UcpConfig, *, building_area_m2: Series | None = None, building_pieces: GeoDataFrame | None = None, land_use_pieces: GeoDataFrame | None = None) -> DataFrame
Per-unit functional evidence and its coverage, keyed by unit_id.
Per configured group, two columns:
sem_<group>_buildings_of_building_area— share of the unit's building area whosesubtypeorclassplaces it in the group. Bernard et al.'sFIND/Bquantity, generalised. Null where the unit holds no building area at all, never 0.0: "no industrial buildings here" and "no buildings here" are different statements.sem_<group>_parcels_of_unit_area— share of the unit's area under land-use parcels of the group, dissolved first.lczkit.cleaning.land_useappliesmake_validand no overlap resolution, and Milan's parcels sum to 106.6% of its bbox, so anything dividing by unit area without dissolving can exceed 1.0.
Plus, always:
building_tag_coverage— share of the unit's building area carrying anysubtypeorclass.land_use_coverage— share of the unit's area under any land-use parcel, dissolved.
Groups are not a partition and the fractions do not sum to one. A big-box store is genuinely
evidence for both large-low-rise form and commercial function, and retail appears in both
groups deliberately.
building_pieces, land_use_pieces and building_area_m2 are handed down by
lczkit.ucp.parameters, which intersects each layer once; passing None computes them here.
No input is mutated.
Source code in src/lczkit/ucp/semantics.py
lczkit.ucp.tag_diagnostic
¶
Is this city's semantic evidence viable? Answered before anyone waits for a run.
The exact counterpart of lczkit.heights.diagnostic.source_availability, which answers "is this
city viable?" for height before a full run. The same question needs asking of the attributes,
and the answer has the same shape:
| city | tagged area | tagged count | dominant footprint source | its tagged share |
|---|---|---|---|---|
| Berlin | 64.4% | 46.6% | OpenStreetMap (80%) | 51.3% |
| Milan | 62.2% | 45.4% | OpenStreetMap (85%) | 53.5% |
| Hong Kong | 58.1% | 44.5% | OpenStreetMap (60%) | 73.6% |
| Vancouver | 55.3% | 41.4% | OpenStreetMap (90%) | 46.1% |
| Mumbai | 18.1% | 5.4% | Google Open Buildings (56%) | 0.0% |
| Cape Town | 13.3% | 4.8% | Microsoft ML Buildings (64%) | 0.0% |
| Jakarta | 7.5% | 1.4% | OpenStreetMap (75%) | 1.8% |
| São Paulo | 7.1% | 1.2% | OpenStreetMap (56%) | 2.1% |
| Cairo | 5.7% | 1.0% | Microsoft ML Buildings (59%) | 0.0% |
| Nairobi | 5.2% | 1.0% | OpenStreetMap (56%) | 1.7% |
| Islamabad | 4.5% | 1.1% | Google Open Buildings (70%) | 0.0% |
| Rio de Janeiro | 3.1% | 0.4% | Google Open Buildings (49%) | 0.0% |
Europe + N. America 48.6% mean / 50.3% median of building area tagged, against 13.6% / 7.1% everywhere else — the same seven-against-nine split, on a fourth independent quantity.
The mechanism is in the last column, not the first. Wherever an ML source wins the footprints,
its tagged share is exactly 0.0%: Google Open Buildings and Microsoft ML supply geometry and no
attributes at all. The cities are not undertagged because nobody bothered; they are undertagged
because the source that won their footprints has no attributes to give. And note that area coverage
runs well above count coverage everywhere — tagged buildings are systematically the larger ones —
which is why tagged_area_fraction is the reported figure: it is the denominator every semantic
fraction actually divides by.
This is the same limit on a second, independent attribute. Tier-1 height coverage runs 64.3% across Europe and North America against 9.6% everywhere else, which is what makes height availability the binding constraint on morphology-based LCZ mapping outside Europe. Building attributes collapse in the same places and for the same reason — those footprints are ML-derived and carry geometry without tags — so a functional rule keyed on them inherits the constraint rather than escaping it.
Grouped by upstream dataset, because that is what makes the mechanism visible rather than merely the outcome — and it is what turned an observation into an explanation here.
UNKNOWN_VALUE
module-attribute
¶
Overture's sentinel for a recorded absence of knowledge.
Excluded from every "has a tag" test. Counting it would report coverage the data does not have, which is the one failure this whole module exists to prevent.
DatasetTags
¶
Bases: BaseModel
Attribute availability for one upstream dataset.
TagAvailability
¶
Bases: BaseModel
The full diagnostic for one study area, for the output manifest.
tagged_area_fraction
instance-attribute
¶
Share of building area carrying any usable attribute. The area share, not the count share, because that is the denominator every semantic fraction divides by — a city where the tagged buildings are the large ones is in a different position from one where they are the small ones, and the count cannot tell them apart.
land_use_summed_area_m2
instance-attribute
¶
Parcel area summed, not dissolved, and named so.
lczkit.cleaning.land_use gives the layer no overlap resolution, so this double-counts shared
ground and Milan's exceeds its own bbox. It is reported as a summed figure rather than made
exact because the exact version is a whole-extent union_all, which is ruled out —
superlinear, and it raises side location conflict on real Overture
land use even after make_valid. The per-unit land_use_coverage column is the dissolved
quantity, and it gets there by clipping to units first.
by_footprint_dataset
class-attribute
instance-attribute
¶
by_footprint_dataset: list[DatasetTags] = Field(default_factory=list)
Grouped by the dataset that won each footprint, most populated first — the mechanism, not just the outcome.
distinct_values
class-attribute
instance-attribute
¶
The subtype and class values actually present, sorted. A vocabulary the crosswalk does
not cover shows up here rather than only as a fraction that is quietly lower than it should be.
tag_availability
¶
tag_availability(buildings: GeoDataFrame, land_use: GeoDataFrame | None = None) -> TagAvailability
Count attribute availability by upstream dataset over buildings.
Degrades rather than raising on a frame with no sources, no subtype or no class: a
non-Overture VectorSource should produce a diagnostic saying it supplies no attributes, not
an exception three stages in.
Source code in src/lczkit/ucp/tag_diagnostic.py
Measuring on one unit set and classifying on another¶
A street canyon has to be measured against streets, and a 100 m grid cell is not bounded by any —
so aspect_ratio, which is 3 of the 17 applied weight units and the only dimension separating
LCZ 8 from LCZ 3 and 6, is null on 10.8% of one Istanbul extent's built grid cells against
0.9% of its enclosures. The two unit systems are complementary rather than rival: an enclosure
is a block and not an LCZ patch, and it is still the better thing to measure a canyon on.
UcpConfig.measure_on defaults to "units". No accuracy claim is attached: the threshold has
not been calibrated against a reference, so switching it on makes a run incomparable with one at the
defaults.
lczkit.ucp.measure
¶
Computing the parameters on one unit set and moving them to another.
The measurement this exists to answer. A street canyon has to be measured against streets, and
a 100 m grid cell is not bounded by any. momepy.street_profile reports nothing for a cell no
street crosses, so aspect_ratio — 3 of the 17 applied weight units, and the only dimension
separating LCZ 8 from LCZ 3 and 6 — is simply null there. An enclosure is bounded by streets by
construction, so it almost always has one. Measured on one Istanbul extent, over built units:
| units | count | median area | aspect_ratio null |
H/W median |
|---|---|---|---|---|
| 100 m grid | 455 538 | 1.00 ha | 10.8% | 0.52 |
| enclosure | 111 293 | 0.42 ha | 0.9% | 0.64 |
| patch | 10 943 | 10.12 ha | 0.2% | 0.57 |
And on the densest decile the difference is not only coverage but value: the grid gives a median H/W of 0.93 with 70.2% inside LCZ 2's published band, the enclosures 1.03 with 82.2% inside it.
So the two unit systems are complementary rather than rival, which is not how the record has treated them. An enclosure is a block and not an LCZ patch — median 0.42 ha against a So2Sat patch's 10.24 — and has been rejected as a classification unit three times for that reason. It is still the better thing to measure a canyon on. This module lets a run do both: compute on enclosures, classify on whatever the caller asked for.
No accuracy claim is attached and none is available. The residual error this is aimed at shows
up as a normalised compactness lift of 1.16 against height's 0.86, so if measuring on enclosures
is the answer, that compactness lift should fall toward 1.0. Plain enclosures as classification
units raised it to 2.33, so a rise here would be a refutation and not a success. That
sweep is a sweep and has not been run, which is why UcpConfig.measure_on defaults to "units"
and every stored figure remains comparable.
COVERAGE_COLUMN
module-attribute
¶
Share of a target unit covered by the units the parameters were measured on.
Renamed from aggregate.aggregate_coverage on the way through, because in a run there is more than
one aggregation and this one has a specific meaning: how much of this cell was actually reached by
the enclosures its parameters came from. Below 1.0 means the enclosure partition did not cover the
cell — outside the barrier network, typically — and the parameters describe only the part it did.
transfer_parameters
¶
transfer_parameters(parameters: DataFrame, measurement_units: GeoDataFrame, target_units: GeoDataFrame) -> DataFrame
Move a parameter table from the units it was measured on to the units to be classified.
Numeric columns are moved area-weighted and non-numeric ones by majority, which is the
only defensible pair: there is no mean of industrial_evidence, and taking the majority of a
building surface fraction would throw away most of the cell. Both reducers run over one
overlay, so a target unit's numeric and categorical answers describe the same overlap.
The weight is computed per column, over the pieces that carried a value. That is not what
lczkit.units.aggregate does — it divides by the total overlap area, so a piece contributing a
null still enlarges the denominator and pulls the mean towards zero. Harmless where every
column is populated, and wrong for the one column this module exists to move: aspect_ratio is
null exactly where no street reached a building, which is a large minority of enclosures, and a
cell must take the mean of the enclosures that had a canyon rather than a mean diluted by
those that did not. aggregate is left alone because its normalisation is what every stored
arm-B projection was computed under.
Every result carries measurement_coverage. A target unit no measurement unit reaches is
all-null rather than zero — the same rule the rest of the package applies to an unobserved
quantity — and its coverage is null too.
Neither input is mutated, and the result is indexed by target_units' unit_id.