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() — the four parameter blocks joined into one table per unit_id.
Pure transform. Every input is already in memory and already keyed on the unit of exchange, so this stage reads no raster, opens no file and touches no network; it turns the earlier stages' outputs into the vector the classifier measures against the LCZ prototypes.
Each vector layer is intersected with the units exactly once here. Three blocks below need the
building layer against the units and three need the land-use layer, and each used to perform its
own overlay — semantic_metrics performed six of the land-use one, once for its coverage column
and once per configured semantic group, so the count grew with the configuration. Measured on the
Hong Kong fixture that was seventeen overlays over 21 231 rows to answer questions about 7 203.
The overlays therefore happen here and the pieces are handed down, which is the move this function
already made for building_area_m2 and for the same reason: the intersection is the expensive
half, and sharing it also guarantees every fraction divides by a denominator computed from the
same pieces as its 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.'), ('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 phase 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', "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¶
industrial_fraction exists 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
¶
industrial_fraction — the one functional attribute in the parameter table.
It exists because LCZ 8 (large low-rise) and LCZ 10 (heavy industry) are geometrically near-identical: large footprint, low, sparse. Nothing in morphology or land cover separates a distribution warehouse from a refinery, so without a functional signal LCZ 10 is unreachable and the package would silently never emit it. The classifier applies this after the prototype distance, as an explicit rule — it is deliberately not folded into the morphological metric, where it would distort every other class.
Two evidence sources, combined by union: industrial building footprints are dissolved together
with industrial land-use parcels before the area is measured, so a factory standing inside an
industrial parcel counts once rather than twice. The two sources therefore reinforce each other's
coverage without inflating the magnitude. Each source's own fraction ships alongside the
combined one, together with industrial_evidence naming which contributed, because the two are
very differently reliable.
Two denominators, both emitted, each named for what it divides by. A single column called
industrial_fraction cannot be read correctly when it is unclear whether it divides by building
area or by unit area, and that is not resolvable by
picking, because the two quantities answer different questions:
industrial_fraction_of_building_area— of what is built here, how much is industrial. This is Bernard et al. (2024)'sFIND/B, so their published 0.33 threshold transfers to it directly. Null where nothing is built, because "what share of no buildings is industrial" has no answer.industrial_fraction_of_unit_area— of this cell's ground, how much is industrial. Sensitive to how much of the cell is built at all, which is why Bernard's threshold does not transfer.
A working port plot is a case where they diverge sharply: sparsely built, so a low unit-area share and a high building-area one. That is exactly the fabric the LCZ 10 rule has to catch, which is why the rule reads the building-area column by default.
industrial_fraction is retained as a deprecated alias for the unit-area column, so no stored
figure changes meaning underneath a reader.
The geometry is lczkit.units.overlay's, not this module's. Three private helpers here each
carried a copy of "intersect a layer with the units, measure the pieces, sum by unit_id", and
two more sat in ucp.semantics. One of the five reached its dissolved coverage through a
whole-layer union_all, which is safe on the industrial subset and is the operation this file's
own anti-pattern list warns about on a whole layer — a distinction nothing in the helper's name
carried. ucp.parameters now intersects each layer once and hands the pieces down, and the
recorded values for all three fixtures reproduce to 1e-9.
DEPRECATED_ALIAS
module-attribute
¶
Alias for industrial_fraction_of_unit_area, kept for one release.
Named rather than merely left in place: a bare industrial_fraction is precisely the column whose
denominator nobody could agree on, and anything still reading it is reading the unit-area answer
whether or not it meant to.
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
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 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | |
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.
The package computes twenty parameters and exactly one of them reads a semantic attribute:
industrial_fraction, a literal isin(["industrial"]). Overture ingests and cleaning retains
subtype and class on every building and every land-use parcel, so the vocabulary was there and
unread. This module generalises the industrial machinery — ucp.attributes holds the one
definition of "which features match" and lczkit.units.overlay the one definition of "how much of
a unit they cover" — and adds the two columns that make the result honest.
It used to intersect the land-use layer six times, once for its coverage column and once per
configured semantic group, plus once per group for the buildings: twelve overlays whose count grew
with the configuration rather than with the city. The layers are intersected once by
ucp.parameters and selecting a group is now a mask over pieces that already exist.
The two coverage columns are the point, not a diagnostic. Measured over the sixteen study
cities the registry held at the time - the four added afterwards have no Overture extract on disk
and are not in this figure - 48.6% of building area carries an attribute across Europe and
North America against 13.6% elsewhere — the same collapse tier-1 height coverage shows, on
a second and independent attribute. Rio is at 3.1%, so a lightweight fraction of 0.0 there is not
evidence that there is no informal settlement; it is 97% of building area carrying no tag. Without
building_tag_coverage beside it the two states are indistinguishable, exactly as "90% real
heights" and "90% coarse raster fallback" are without height_tier_fractions.
Land-use parcels are the evidence that generalises. They cover 30-65% of the same cities where building tags are near-absent (Rio 64.5%, Jakarta 55.8%, Cairo 37.6%, Nairobi 35.6%, Mumbai 30.5%), and 79-107% in Europe. That is why the two are reported as separate columns with their denominators in their names rather than fused into one number: they have different availability, different meanings and different failure modes, and a single blended fraction would hide all three.
Scope: built types only. Land use supplies functional semantics and never land cover — rasters
own that. park, forest, grass and farmland are all
present in the vocabulary and all deliberately unmapped, so nothing here can reach LCZ A-G.
The vocabulary is transcribed from docs/references/tables/overture_lcz_semantic_mapping.md, which
tests/test_ucp_semantics.py parses and asserts against, and every value in it was taken from what
is present in the pinned release rather than from the schema documentation.
PARCEL_SUFFIX
module-attribute
¶
Both a numerator and a denominator in every column name.
A column whose name states neither cannot be read correctly, as industrial_fraction showed.
These columns are not comparable to each other and must not look as though they are: one divides
tagged building area by
all building area, the other divides 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.
Each layer is intersected with the units once. building_pieces and land_use_pieces come
from lczkit.ucp.parameters, which overlays each layer once for every consumer of it; passing
None overlays here instead. Selecting a group is then a mask over pieces that already exist,
which is what stops the cost growing with the number of configured groups — this function used
to run one intersection per group per layer, so five groups meant twelve overlays.
building_area_m2 is the denominator for the building columns, handed down for the same reason.
No input is mutated.
Source code in src/lczkit/ucp/semantics.py
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 152 153 154 155 156 157 158 159 160 161 162 163 164 | |
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.