Skip to content

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_std and h_geometric_area_weighted are 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, never buildings_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
def compute_parameters(
    units: gpd.GeoDataFrame,
    buildings_area: gpd.GeoDataFrame,
    buildings_topo: gpd.GeoDataFrame,
    streets: gpd.GeoDataFrame,
    land_use: gpd.GeoDataFrame,
    land_cover: pd.DataFrame,
    *,
    config: UcpConfig,
    land_cover_config: LandCoverConfig,
) -> pd.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`.
    """
    dataset = land_cover_config.dataset(config.land_cover_dataset)

    # The two intersections every block below is built on. `buildings_topo` is deliberately not
    # among them: only `street_metrics` reads it, and it reads it through `momepy.street_profile`
    # rather than through an overlay against the units.
    building_pieces = unit_pieces(units, buildings_area, columns=OVERLAY_COLUMNS)
    land_use_pieces = unit_pieces(units, land_use, columns=ATTRIBUTES)

    morphology = building_metrics(buildings_area, units, config, pieces=building_pieces)
    # Recovered from the fraction rather than overlaid again: `building_metrics` has already put
    # every footprint against every unit, and repeating that is the expensive half of
    # `industrial_metrics` at metropolitan scale. Exact, and it guarantees the industrial
    # building-area share shares a denominator with `building_surface_fraction`.
    building_area_m2 = morphology["building_surface_fraction"] * units.geometry.area
    table = pd.concat(
        [
            morphology,
            street_metrics(streets, buildings_topo, units, config),
            surface_fractions(land_cover, morphology["building_surface_fraction"], dataset, config),
            industrial_metrics(
                buildings_area,
                land_use,
                units,
                config,
                building_area_m2=building_area_m2,
                building_pieces=building_pieces,
                land_use_pieces=land_use_pieces,
            ),
            # Shares the same handed-down denominator and the same pieces, so every
            # semantic building share is on the same base as `building_surface_fraction` and as
            # `FIND/B`, measured over the same intersection.
            semantic_metrics(
                buildings_area,
                land_use,
                units,
                config,
                building_area_m2=building_area_m2,
                building_pieces=building_pieces,
                land_use_pieces=land_use_pieces,
            ),
        ],
        axis=1,
    )

    missing = [column for column in PARAMETER_COLUMNS if column not in table.columns]
    if missing:  # pragma: no cover - a registry/implementation mismatch, caught by its own test
        raise RuntimeError(f"parameter blocks did not produce: {', '.join(missing)}")
    result = table[[*PARAMETER_COLUMNS, *group_columns(config.semantic_groups)]]
    result.index.name = "unit_id"
    return result

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 = '10.1175/BAMS-D-11-00019.1'

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_2024 = '10.5194/gmd-17-2077-2024'

Bernard et al. (2024), GMD 17, 2077-2107. Table 1 gives operational definitions for the same properties over vector data.

MOMEPY module-attribute

MOMEPY = '10.21105/joss.01807'

Fleischmann (2019), JOSS 4(43), 1807. momepy.street_profile() is derived in turn from Araldi & Fusco (2019), which momepy cites.

COMPUTED_HERE module-attribute

COMPUTED_HERE = 'computed here'

No published definition — a plain descriptive statistic, defined by its own description.

UNITS module-attribute

UNITS = ('m', 'm2', 'fraction', 'count', 'dimensionless', 'category')

The controlled vocabulary ParameterSpec.unit draws on. "fraction" means a real in [0, 1].

PARAMETER_COLUMNS module-attribute

PARAMETER_COLUMNS: tuple[str, ...] = tuple(parameter.name for parameter in PARAMETERS)

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

ParameterSpec(name: str, label: str, unit: str, description: str, reference: str)

One column of the parameter table.

name instance-attribute

name: str

Column name in the table compute_parameters() returns.

label instance-attribute

label: str

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.

unit instance-attribute

unit: str

Unit of measurement. One of UNITS.

description instance-attribute

description: str

What the number is, precisely enough to reproduce it.

reference instance-attribute

reference: str

DOI of the source that defines it, or COMPUTED_HERE.

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
def 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.
    """
    specs: list[ParameterSpec] = []
    for group in groups:
        hint = f" Evidence for LCZ {group.lcz_hint}." if group.lcz_hint else ""
        specs.append(
            ParameterSpec(
                name=f"sem_{group.name}_buildings_of_building_area",
                label=f"{group.name.replace('_', ' ').capitalize()} share of building area",
                unit="fraction",
                description=(
                    f"Share of the unit's building area whose Overture `subtype` or `class` places "
                    f"it in the '{group.name}' group.{hint} Null where the unit holds no building "
                    "area. Bernard et al.'s FIND/B quantity, generalised beyond industry. Groups "
                    "are not a partition and these do not sum to one."
                ),
                reference=BERNARD_2024,
            )
        )
    for group in groups:
        hint = f" Evidence for LCZ {group.lcz_hint}." if group.lcz_hint else ""
        specs.append(
            ParameterSpec(
                name=f"sem_{group.name}_parcels_of_unit_area",
                label=f"{group.name.replace('_', ' ').capitalize()} share of unit area",
                unit="fraction",
                description=(
                    f"Share of the unit's area under land-use parcels of the '{group.name}' "
                    f"group, dissolved before measuring.{hint} A different numerator *and* "
                    "denominator from the building column of the same group; the two are not "
                    "comparable and their names say so."
                ),
                reference=COMPUTED_HERE,
            )
        )
    return (*specs, *COVERAGE_SPECS)

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
def 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.
    """
    if name in _BY_NAME:
        return _BY_NAME[name]
    for candidate in semantic_specs(groups or []):
        if candidate.name == name:
            return candidate
    raise KeyError(f"no parameter named {name!r}; known: {', '.join(PARAMETER_COLUMNS)}")

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
def building_metrics(
    buildings: gpd.GeoDataFrame,
    units: gpd.GeoDataFrame,
    config: UcpConfig,
    *,
    pieces: gpd.GeoDataFrame | None = None,
) -> pd.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.
    """
    check_units(units)
    if buildings.empty:
        return _empty(units)
    assert_projected_crs(buildings, "buildings")
    if buildings.crs != units.crs:
        raise ValueError(f"buildings.crs ({buildings.crs}) != units.crs ({units.crs})")
    if "height" not in buildings.columns:
        raise ValueError(
            "buildings has no height column; run lczkit.heights.cascade.fill_heights before "
            "computing urban canopy parameters."
        )

    if pieces is None:
        pieces = unit_pieces(units, buildings, columns=OVERLAY_COLUMNS)
    frame = pd.concat(
        [_area_metrics(pieces, units, config), _object_metrics(buildings, units)], axis=1
    )
    frame[list(_ZERO_WHEN_EMPTY)] = frame[list(_ZERO_WHEN_EMPTY)].fillna(0.0)
    frame["building_count"] = frame["building_count"].astype("int64")
    return frame[list(COLUMNS)]

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
def street_metrics(
    streets: gpd.GeoDataFrame,
    buildings: gpd.GeoDataFrame,
    units: gpd.GeoDataFrame,
    config: UcpConfig,
) -> pd.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.
    """
    check_units(units)
    if streets.empty or buildings.empty:
        return _all_null(units.index)
    assert_projected_crs(streets, "streets")
    assert_projected_crs(buildings, "buildings")
    for name, layer in (("streets", streets), ("buildings", buildings)):
        if layer.crs != units.crs:
            raise ValueError(f"{name}.crs ({layer.crs}) != units.crs ({units.crs})")
    if "height" not in buildings.columns:
        raise ValueError(
            "buildings has no height column; run lczkit.heights.cascade.fill_heights before "
            "computing urban canopy parameters."
        )

    profile = momepy.street_profile(
        streets,
        buildings,
        distance=config.street_profile_distance_m,
        tick_length=config.street_profile_tick_length_m,
        height=buildings["height"],
    )
    segments = gpd.GeoDataFrame(
        profile[list(_RENAMES)].rename(columns=_RENAMES).reset_index(drop=True),
        geometry=streets.geometry.reset_index(drop=True),
        crs=streets.crs,
    )

    pieces = gpd.overlay(
        units[["geometry"]].reset_index(), segments, how="intersection", keep_geom_type=False
    )
    pieces = pieces.assign(length=pieces.geometry.length)
    pieces = pieces[pieces["length"] > 0]
    if pieces.empty:
        return _all_null(units.index)

    # One weighted mean per column rather than one over the frame: a segment reaching no building
    # has a width and an openness but no aspect ratio, and dropping the whole segment would throw
    # away the two measurements it does carry.
    return pd.DataFrame({column: _weighted_mean(pieces, column, units.index) for column in COLUMNS})

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 tree out of pervious and reports water separately, 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

CLIPPED_COLUMN = 'impervious_clipped'

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
def surface_fractions(
    land_cover: pd.DataFrame,
    building_surface_fraction: pd.Series,
    dataset: LandCoverDatasetConfig,
    config: UcpConfig,
) -> pd.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.
    """
    groups = _resolve(dataset, config)
    index = building_surface_fraction.index
    aligned = land_cover.reindex(index)

    # A unit the raster never reached is null in every land-cover column. Kept as an explicit mask
    # so an empty group can distinguish "this dataset emits no such class" from "this unit was not
    # observed" - answering 0.0 for both would report cover the raster never measured.
    observed = (
        aligned.notna().any(axis=1)
        if len(aligned.columns)
        else pd.Series(False, index=index, dtype="bool")
    )

    def total(group: str) -> pd.Series:
        """Summed fraction over one configured class group, null where the raster reached nothing.

        `min_count` keeps a partially-null group null rather than summing the columns that did
        arrive; an empty group answers 0.0, but only for units the raster actually covered.
        """
        columns = groups[group]
        if not columns:
            return pd.Series(0.0, index=index, dtype="float64").where(observed)
        return aligned[columns].sum(axis=1, min_count=len(columns))

    tree = total("tree_classes")
    water = total("water_classes")
    pervious = total("pervious_classes") + tree + water
    raw = total("impervious_classes") - building_surface_fraction
    impervious = raw.clip(lower=0.0)

    return pd.DataFrame(
        {
            "impervious_surface_fraction": impervious,
            "pervious_surface_fraction": pervious,
            "tree_fraction": tree,
            "water_fraction": water,
            # Nullable, and null where the raster reached nothing: "the clip did not fire" and
            # "nothing was measured here" are different statements, and answering False for the
            # second would claim a partition held over a unit that has no fractions at all.
            CLIPPED_COLUMN: (raw < 0.0).astype("boolean").mask(raw.isna()),
        }
    )

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

ATTRIBUTES = ('subtype', 'class')

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

UNKNOWN = 'unknown'

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
def require_attributes(
    layer: gpd.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.
    """
    if layer.empty:
        return
    for column, wanted in (("subtype", subtypes), ("class", classes)):
        if wanted and column not in layer.columns:
            raise ValueError(
                f"{name} has no {column!r} column, but ucp config selects features by it "
                f"({', '.join(wanted)}). Cleaning must retain subtype and class."
            )

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
def select_pieces(
    pieces: gpd.GeoDataFrame, *, subtypes: Sequence[str], classes: Sequence[str]
) -> gpd.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.
    """
    if pieces.empty:
        return pieces
    mask = pd.Series(False, index=pieces.index)
    for column, wanted in (("subtype", subtypes), ("class", classes)):
        if wanted and column in pieces.columns:
            mask |= pieces[column].isin(list(wanted))
    return pieces.loc[mask]

tagged_pieces

tagged_pieces(pieces: GeoDataFrame) -> GeoDataFrame

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
def tagged_pieces(pieces: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """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.
    """
    if pieces.empty:
        return pieces
    mask = pd.Series(False, index=pieces.index)
    for column in ATTRIBUTES:
        if column in pieces.columns:
            values = pieces[column]
            mask |= values.notna() & (values.astype("string").str.lower() != UNKNOWN)
    return pieces.loc[mask]

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)'s FIND/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

DEPRECATED_ALIAS = 'industrial_fraction'

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

EVIDENCE = ('none', 'buildings', 'land_use', 'both')

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
def industrial_metrics(
    buildings: gpd.GeoDataFrame,
    land_use: gpd.GeoDataFrame,
    units: gpd.GeoDataFrame,
    config: UcpConfig,
    *,
    building_area_m2: pd.Series | None = None,
    building_pieces: gpd.GeoDataFrame | None = None,
    land_use_pieces: gpd.GeoDataFrame | None = None,
) -> pd.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.
    """
    check_units(units)
    for name, layer in (("buildings", buildings), ("land_use", land_use)):
        if layer.empty:
            continue
        assert_projected_crs(layer, name)
        if layer.crs != units.crs:
            raise ValueError(f"{name}.crs ({layer.crs}) != units.crs ({units.crs})")

    require_attributes(
        buildings,
        "buildings",
        subtypes=config.industrial_building_subtypes,
        classes=config.industrial_building_classes,
    )
    require_attributes(
        land_use,
        "land_use",
        subtypes=config.industrial_land_use_subtypes,
        classes=config.industrial_land_use_classes,
    )

    if building_pieces is None:
        building_pieces = unit_pieces(units, buildings, columns=ATTRIBUTES)
    if land_use_pieces is None:
        land_use_pieces = unit_pieces(units, land_use, columns=ATTRIBUTES)

    from_buildings = select_pieces(
        building_pieces,
        subtypes=config.industrial_building_subtypes,
        classes=config.industrial_building_classes,
    )
    from_land_use = select_pieces(
        land_use_pieces,
        subtypes=config.industrial_land_use_subtypes,
        classes=config.industrial_land_use_classes,
    )

    # `from_buildings` comes from `buildings_area`, which `trim_overlaps` has already made
    # non-overlapping, so it needs no dissolve. `from_land_use` does: `lczkit.cleaning.land_use`
    # states it gets no overlap resolution of any kind, and two parcels covering the same ground
    # would count it twice. The union of the two dissolves for the same reason — counting a factory
    # standing inside an industrial parcel once is the whole point of combining the sources.
    building_share = covered_fraction(units, from_buildings, dissolve=False)
    land_use_share = covered_fraction(units, from_land_use, dissolve=True)
    combined = _concat_pieces(from_buildings, from_land_use, units)
    union_share = covered_fraction(units, combined, dissolve=True)

    # Bernard et al. (2024)'s `FIND/B`: industrial building area over *all* building area.
    # **Industrial buildings only, never the union with the parcels.** A parcel is evidence about
    # ground, and `industrial_fraction_of_unit_area` is where ground evidence belongs; folding it
    # into a building-area numerator would make this a second unit-area measure wearing a different
    # name, which is also not what `FIND/B` means in the paper. Sharing `total` with
    # `building_surface_fraction` is what keeps the two internally consistent.
    total = (
        building_area_m2.reindex(units.index)
        if building_area_m2 is not None
        else area_in_units(units, building_pieces)
    )
    of_building_area = share_of(area_in_units(units, from_buildings), total)

    evidence = pd.Series("none", index=units.index, dtype="object")
    evidence[building_share > 0] = "buildings"
    evidence[land_use_share > 0] = "land_use"
    evidence[(building_share > 0) & (land_use_share > 0)] = "both"

    frame = pd.DataFrame(
        {
            "industrial_fraction_of_building_area": of_building_area,
            "industrial_fraction_of_unit_area": union_share,
            "industrial_fraction": union_share,
            "industrial_fraction_buildings": building_share,
            "industrial_fraction_land_use": land_use_share,
            "industrial_evidence": pd.Categorical(evidence, categories=EVIDENCE),
        }
    )
    frame.index.name = "unit_id"
    return frame

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

PARCEL_SUFFIX = '_parcels_of_unit_area'

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
def 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.
    """
    return (
        *(f"{BUILDING_PREFIX}{g.name}{BUILDING_SUFFIX}" for g in groups),
        *(f"{BUILDING_PREFIX}{g.name}{PARCEL_SUFFIX}" for g in groups),
        *COVERAGE_COLUMNS,
    )

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 whose subtype or class places it in the group. Bernard et al.'s FIND/B quantity, 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_use applies make_valid and 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 any subtype or class.
  • 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
def semantic_metrics(
    buildings: gpd.GeoDataFrame,
    land_use: gpd.GeoDataFrame,
    units: gpd.GeoDataFrame,
    config: UcpConfig,
    *,
    building_area_m2: pd.Series | None = None,
    building_pieces: gpd.GeoDataFrame | None = None,
    land_use_pieces: gpd.GeoDataFrame | None = None,
) -> pd.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 whose `subtype`
      or `class` places it in the group. Bernard et al.'s `FIND/B` quantity, 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_use` applies `make_valid` and 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 any `subtype` or `class`.
    - `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.
    """
    check_units(units)
    assert_projected_crs(buildings, "buildings")
    assert_projected_crs(land_use, "land_use")

    groups = config.semantic_groups
    columns = group_columns(groups)
    result = pd.DataFrame(index=units.index, columns=list(columns), dtype="float64")

    if building_pieces is None:
        building_pieces = unit_pieces(units, buildings, columns=ATTRIBUTES)
    if land_use_pieces is None:
        land_use_pieces = unit_pieces(units, land_use, columns=ATTRIBUTES)

    total = (
        area_in_units(units, building_pieces)
        if building_area_m2 is None
        else building_area_m2.reindex(units.index)
    )

    result["building_tag_coverage"] = share_of(
        area_in_units(units, tagged_pieces(building_pieces)), total
    )
    result["land_use_coverage"] = covered_fraction(units, land_use_pieces, dissolve=True)

    for group in groups:
        selected = select_pieces(
            building_pieces,
            subtypes=group.building_subtypes,
            classes=group.building_classes,
        )
        result[f"{BUILDING_PREFIX}{group.name}{BUILDING_SUFFIX}"] = share_of(
            area_in_units(units, selected), total
        )

        parcels = select_pieces(
            land_use_pieces,
            subtypes=group.land_use_subtypes,
            classes=group.land_use_classes,
        )
        result[f"{BUILDING_PREFIX}{group.name}{PARCEL_SUFFIX}"] = covered_fraction(
            units, parcels, dissolve=True
        )

    result.index.name = "unit_id"
    return result[list(columns)]

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

UNKNOWN_VALUE = 'unknown'

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

tagged_area_fraction: float

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

land_use_summed_area_m2: float

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

distinct_values: dict[str, list[str]] = Field(default_factory=dict)

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
def tag_availability(
    buildings: gpd.GeoDataFrame, land_use: gpd.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.
    """
    has_subtype = _present(buildings, "subtype")
    has_class = _present(buildings, "class")
    has_either = has_subtype | has_class
    area = (
        buildings.geometry.area
        if buildings.crs is not None and buildings.crs.is_projected
        else pd.Series(0.0, index=buildings.index)
    )
    total_area = float(area.sum())
    tagged_area = float(area[has_either].sum())

    dataset = footprint_datasets(buildings).fillna(UNKNOWN_DATASET)
    rows = [
        DatasetTags(
            dataset=str(name),
            n_buildings=int(len(index)),
            n_with_subtype=int(has_subtype.loc[index].sum()),
            n_with_class=int(has_class.loc[index].sum()),
            n_with_either=int(has_either.loc[index].sum()),
            area_m2=float(area.loc[index].sum()),
            area_with_either_m2=float(area.loc[index][has_either.loc[index]].sum()),
        )
        for name, index in buildings.groupby(dataset, sort=False).groups.items()
    ]
    rows.sort(key=lambda row: (-row.n_buildings, row.dataset))

    parcels = 0
    parcel_area = 0.0
    if land_use is not None and not land_use.empty:
        parcels = int(len(land_use))
        if land_use.crs is not None and land_use.crs.is_projected:
            parcel_area = float(land_use.geometry.area.sum())

    return TagAvailability(
        n_buildings=int(len(buildings)),
        n_with_subtype=int(has_subtype.sum()),
        n_with_class=int(has_class.sum()),
        n_with_either=int(has_either.sum()),
        area_m2=total_area,
        area_with_either_m2=tagged_area,
        tagged_area_fraction=tagged_area / total_area if total_area > 0 else 0.0,
        n_land_use_parcels=parcels,
        land_use_summed_area_m2=parcel_area,
        by_footprint_dataset=rows,
        distinct_values={
            column: sorted(
                {
                    str(value)
                    for value in buildings[column].dropna().unique()
                    if str(value).lower() != UNKNOWN_VALUE
                }
            )
            for column in ("subtype", "class")
            if column in buildings.columns
        },
    )

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

COVERAGE_COLUMN = 'measurement_coverage'

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.

Source code in src/lczkit/ucp/measure.py
def transfer_parameters(
    parameters: pd.DataFrame,
    measurement_units: gpd.GeoDataFrame,
    target_units: gpd.GeoDataFrame,
) -> pd.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`.
    """
    if not parameters.index.equals(measurement_units.index):
        raise ValueError(
            "parameters and measurement_units must share an index; parameters must be the table "
            "compute_parameters() returned for those units"
        )
    assert_projected_crs(measurement_units, "measurement_units")
    assert_projected_crs(target_units, "target_units")
    if measurement_units.crs != target_units.crs:
        raise ValueError(
            f"measurement_units.crs ({measurement_units.crs}) != "
            f"target_units.crs ({target_units.crs})"
        )

    numeric = [
        column
        for column in parameters.columns
        if pd.api.types.is_numeric_dtype(parameters[column])
        and not pd.api.types.is_bool_dtype(parameters[column])
    ]
    other = [column for column in parameters.columns if column not in numeric]

    left = target_units[["geometry"]].reset_index().rename(columns={"unit_id": "to_id"})
    right = (
        measurement_units[["geometry"]]
        .join(parameters)
        .reset_index()
        .rename(columns={"unit_id": "from_id"})
    )
    pieces = gpd.overlay(left, right, how="intersection", keep_geom_type=False)
    pieces = pieces.assign(overlap_area=pieces.geometry.area)
    pieces = pieces[pieces["overlap_area"] > 0]

    frame = pd.DataFrame(index=target_units.index, columns=list(parameters.columns), dtype="object")
    frame.index.name = "unit_id"
    coverage = pd.Series(np.nan, index=target_units.index, dtype="float64")
    if pieces.empty:
        frame[COVERAGE_COLUMN] = coverage
        return frame[[*parameters.columns, COVERAGE_COLUMN]]

    area = pieces["overlap_area"]
    group = pieces["to_id"]
    for column in numeric:
        values = pd.to_numeric(pieces[column], errors="coerce")
        known = values.notna()
        weight = area.where(known)
        totals = pd.DataFrame({"to_id": group, "w": weight, "wx": weight * values}).groupby("to_id")
        summed = totals[["w", "wx"]].sum(min_count=1)
        frame[column] = (summed["wx"] / summed["w"].where(summed["w"] > 0)).reindex(
            target_units.index
        )
    if other:
        # Majority: the value of whichever measurement unit covers most of the target.
        dominant = pieces.loc[pieces.groupby("to_id")["overlap_area"].idxmax()].set_index("to_id")
        for column in other:
            frame[column] = dominant[column].reindex(target_units.index)

    target_area = target_units.geometry.area
    covered = area.groupby(group).sum().reindex(target_units.index)
    frame[COVERAGE_COLUMN] = covered.div(target_area.where(target_area > 0))
    return frame[[*parameters.columns, COVERAGE_COLUMN]].infer_objects()