Skip to content

Classification

Classification - the 17-way distance vector and the labels drawn from it.

PrototypeClassifier is the only thing most callers need. The distance vector is the primary output and the label is a convenience over it: no core API here returns a bare LCZ integer without the distances that produced it.

Prototype-distance classification, implemented from the Stewart & Oke parameter table. Each of the seventeen classes has a published range for every surface property, so a class is a box in parameter space and a unit is a point; the distance is the gap from the point to the box, zero inside it, and the nearest class wins. Terms are defined in the glossary.

The distance vector is the primary output. Every unit carries its full 17-way distance to each prototype, plus lcz_primary, lcz_secondary and a uniqueness measure. Hard labelling is a downstream convenience — nothing in the core API returns a bare LCZ integer.

Weights are config, not assumptions

Two presets ship, and the active one appears in the manifest:

  • bernard2024_partial (default). Bernard's published weights, in his notation, are sky view factor 4, aspect ratio 3, building surface fraction (FB) 8, impervious fraction (FI) 0, pervious fraction (FP) 0, height of roughness elements (Hr) 6 and roughness length (z₀) 0.5 — 21.5 units in total. lczkit can apply only 17 of them: sky view factor and roughness length are not computed, and the impervious and pervious fractions carry zero weight in Bernard's own scheme, leaving three parameters with any weight at all. Building surface fraction therefore carries roughly 47% of the result on its own. The preset is named _partial for exactly that reason; it is not Bernard's metric, and the unapplied dimensions and the renormalisation are recorded in the manifest.
  • equal — uniform weights, for comparison.

Null parameters

Some units legitimately have null parameters: aspect_ratio is null wherever no street reaches a building. These are handled by weighted partial distance — sum over available parameters only, renormalising by the sum of their weights, so units stay comparable on a common scale. Nothing is imputed and no unit is dropped. Each unit records n_params_used and which parameters were missing.

Two classes are not assigned by distance

LCZ 10 is removed from the metric entirely and assigned functionally from industrial_fraction_of_building_area, at a threshold calibrated by a precision/recall sweep against the Rotterdam reference rather than picked. LCZ F is unreachable by arithmetic rather than by configuration — LCZ D's prototype box contains F's in every dimension, so d(F) >= d(D) always. The manifest records dominated separately from excluded.

lczkit.classify.classifier

PrototypeClassifier - the full 17-way distance vector and the labels drawn from it.

The primary output is the vector, not the label: no core API here returns a bare LCZ integer without the distances that produced it. Hard labelling is a convenience over it, and every label this module emits records which mechanism produced it in label_route, so no consumer has to guess whether a unit was placed by morphology, by land cover or by the industrial rule.

The vector is computed under two metrics, one per family. Bernard et al. (2024) apply their weights to the built types only (Sect. 2.5) and route the natural types through land cover entirely, and the reason shows up immediately in the numbers: with their published FI and FP weights of zero, LCZ E, F and G become mutually indistinguishable, since impervious and pervious cover are the only dimensions separating them. So lcz_d1-lcz_d10 use the built weights and lcz_d11-lcz_d17 the natural ones. Both are on the same normalised scale, but they are not the same metric, and the label comes from the argmin within the gated family rather than from the argmin across all seventeen. The cross-family entries are reported because they are informative about a unit near the boundary; they are not what decides it.

DISTANCE_PREFIX module-attribute

DISTANCE_PREFIX = 'lcz_d'

Distance columns are lcz_d1 through lcz_d17, matching the integer codes.

FUNCTIONAL_ONLY_CODE module-attribute

FUNCTIONAL_ONLY_CODE = 10

LCZ 10 (heavy industry): scored and reported, never selected by the metric.

Bernard et al. (2024) remove it from the closest-distance approach, and the measurement behind following them is Rotterdam's: the pair-gated rule that assigned it morphologically was inert at every threshold from 0.05 to 0.5 across 671 cells of working port. Its distance stays in the seventeen-way vector, because the vector is always complete; only the argmin excludes it.

DOMINATED_CLASSES module-attribute

DOMINATED_CLASSES: dict[int, int] = {16: 14}

Natural classes whose prototype box is contained in another's, and by which class.

LCZ F (bare soil or sand, 16) sits inside LCZ D (low plants, 14) in every dimension: identical on aspect ratio, building, impervious, pervious, tree and water, and tighter on Hr - at most 0.25 m against D's at most 1 m. A contained box can never be strictly nearer than its container, so d(F) >= d(D) for every possible unit, and _two_closest breaks ties to the lower code.

F is therefore unreachable by arithmetic, not by configuration, and removing it from reachable_natural_classes would not make it assignable. Recorded separately so the run manifest distinguishes a class this package chose not to assign from one it cannot.

PrototypeClassifier

PrototypeClassifier(config: ClassificationConfig | None = None)

Distance-to-prototype classification, satisfying the Classifier protocol.

Stateless with respect to the data: build once from config, call classify() on any parameter table. The prototype space and the standardisation are derived at construction, so a run does not re-derive them per call and two runs sharing a config share a metric exactly.

Derive the prototype space, the weights and the reachable class set from config.

All three are fixed at construction, so classify() is a pure transform and two runs sharing a config share a metric exactly. reachable_natural is computed rather than configured: LCZ D's prototype box contains LCZ F's in every dimension, so F is unreachable by arithmetic and the manifest records dominated apart from excluded.

Source code in src/lczkit/classify/classifier.py
def __init__(self, config: ClassificationConfig | None = None) -> None:
    """Derive the prototype space, the weights and the reachable class set from `config`.

    All three are fixed at construction, so `classify()` is a pure transform and two runs
    sharing a config share a metric exactly. `reachable_natural` is computed rather than
    configured: LCZ D's prototype box contains LCZ F's in every dimension, so F is
    unreachable by arithmetic and the manifest records *dominated* apart from *excluded*.
    """
    self.config = config or ClassificationConfig()
    self.weights: WeightPreset = preset(self.config.weight_preset)
    self.space = PrototypeSpace(
        build_prototypes(
            dominant_fraction=self.config.natural_dominant_fraction,
            negligible_fraction=self.config.natural_negligible_fraction,
        )
    )
    self.reachable_natural: tuple[int, ...] = tuple(
        code_of(label) for label in self.config.reachable_natural_classes
    )
    unreachable = set(NATURAL_CODES) - set(self.reachable_natural)
    self.unreachable_natural: tuple[int, ...] = tuple(sorted(unreachable))
    # LCZ 10 is scored and reported but never selected: it leaves the metric per Bernard et al.
    # and arrives only through the industrial rule. Kept as a derived tuple rather than a
    # literal so `lcz_d10` and the selection set cannot drift apart.
    self.semantic_firings: dict[str, int] = {}
    self.selectable_built: tuple[int, ...] = tuple(
        code for code in BUILT_CODES if code != FUNCTIONAL_ONLY_CODE
    )

classify

classify(parameters: DataFrame) -> DataFrame

Classify an urban canopy parameter table, returning one row per unit_id.

parameters must carry every prototype dimension plus industrial_fraction; the table lczkit.ucp.compute_parameters() returns does. Neither input nor index is mutated, and the result is indexed identically so it joins straight onto the units.

Source code in src/lczkit/classify/classifier.py
def classify(self, parameters: pd.DataFrame) -> pd.DataFrame:
    """Classify an urban canopy parameter table, returning one row per `unit_id`.

    `parameters` must carry every prototype dimension plus `industrial_fraction`; the table
    `lczkit.ucp.compute_parameters()` returns does. Neither input nor index is mutated, and
    the result is indexed identically so it joins straight onto the units.
    """
    if parameters.index.name != "unit_id":
        raise ValueError("parameters must be indexed by unit_id")
    industrial_column = self.config.lcz10_industrial_column
    if industrial_column not in parameters.columns:
        raise ValueError(
            f"parameters has no {industrial_column!r} column, so LCZ 10 would be unreachable - "
            "it is not in the distance metric and the industrial rule is its only route. "
            "Pass the table lczkit.ucp.compute_parameters() returns, or point "
            "ClassificationConfig.lcz10_industrial_column at a column it carries."
        )

    built = self.space.distances(parameters, BUILT_CODES, self.weights.built)
    natural = self.space.distances(parameters, NATURAL_CODES, self.weights.natural)
    built_distances = rules.drop_lcz1_below_height(
        built.distances,
        parameters["height_of_roughness_elements_m"],
        self.config.lcz1_min_height_m,
    )

    is_built = (
        rules.family_of(
            parameters["building_surface_fraction"], self.config.built_min_building_fraction
        )
        == rules.BUILT
    )
    candidates = _combine(
        built_distances[list(self.selectable_built)],
        natural.distances[list(self.reachable_natural)],
        is_built,
    )
    tied = _n_tied(candidates)
    ranked, fired = rules.apply_lcz10_rule(
        _two_closest(candidates),
        parameters[industrial_column],
        self.config.lcz10_min_industrial_fraction,
    )
    # After the industrial rule, so a unit both would claim keeps the calibrated answer. Every
    # semantic rule ships disabled, so by default this is a no-op that still records zeros —
    # "never fired" and "never configured" have to stay distinguishable.
    ranked, semantic_fired, self.semantic_firings = rules.apply_semantic_rules(
        ranked, parameters, self.config.semantic_rules
    )
    route = (
        pd.Series(
            np.where(is_built, rules.ROUTE_BUILT, rules.ROUTE_NATURAL),
            index=parameters.index,
            dtype="object",
        )
        .where(~fired, rules.ROUTE_INDUSTRIAL)
        .where(~semantic_fired, rules.ROUTE_SEMANTIC)
    )

    # Select by code before renaming. Concatenating and assigning column labels positionally
    # assumes each frame comes back in its family's code order - true today, since `distances`
    # builds its dict in `codes` order, but a mislabelled distance vector would be silent and
    # selecting by code costs nothing.
    vector = pd.concat(
        [built_distances[list(BUILT_CODES)], natural.distances[list(NATURAL_CODES)]],
        axis=1,
    )
    vector.columns = pd.Index(
        [f"{DISTANCE_PREFIX}{code}" for code in [*BUILT_CODES, *NATURAL_CODES]]
    )
    result = pd.concat(
        [
            vector[list(DISTANCE_COLUMNS)],
            pd.DataFrame(
                {
                    "lcz_primary": ranked.primary.astype("Int8"),
                    "lcz_secondary": ranked.secondary.astype("Int8"),
                    "min_distance": ranked.closest,
                    "uniqueness": uniqueness(ranked.closest, ranked.runner_up),
                    "label_route": pd.Categorical(route, categories=list(rules.ROUTES)),
                    "lcz10_rule_applied": fired,
                    "semantic_rule_applied": semantic_fired,
                    "n_params_used": _pick(
                        built.n_params_used, natural.n_params_used, is_built
                    ),
                    "n_params_available": pd.Series(
                        np.where(
                            is_built, built.n_params_available, natural.n_params_available
                        ),
                        index=parameters.index,
                        dtype="int64",
                    ),
                    "n_tied_classes": tied,
                    "missing_parameters": _pick(
                        built.missing_parameters, natural.missing_parameters, is_built
                    ),
                },
                index=parameters.index,
            ),
        ],
        axis=1,
    )
    result.index.name = "unit_id"
    return result[list(CLASSIFICATION_COLUMNS)]

describe

describe() -> dict[str, object]

The full classification setup, for the run manifest.

Everything a reader needs to reproduce a label: the active weights per family, the normalisation the distances were measured in, every threshold, and - importantly - which classes could not be assigned and why.

Source code in src/lczkit/classify/classifier.py
def describe(self) -> dict[str, object]:
    """The full classification setup, for the run manifest.

    Everything a reader needs to reproduce a label: the active weights per family, the
    normalisation the distances were measured in, every threshold, and - importantly - which
    classes could not be assigned and why.
    """
    return {
        "weight_preset": self.weights.name,
        "weight_preset_description": self.weights.description,
        "weights": {"built": dict(self.weights.built), "natural": dict(self.weights.natural)},
        "normalisation": self.space.normalisation.as_dict(),
        "height_dependent_weight": self._height_dependent_weight(),
        "indistinguishable_classes": self._indistinguishable(),
        "geometric_prior": {
            "built": self.space.occupancy(self.selectable_built, self.weights.built),
            "natural": self.space.occupancy(self.reachable_natural, self.weights.natural),
        },
        "prototypes": [
            {
                "code": prototype.code,
                "dimension": prototype.column,
                "property": prototype.property_name,
                "min": prototype.lo,
                "max": prototype.hi,
                "source": prototype.source,
            }
            for prototype in self.space.prototypes
        ],
        "semantic_rules": [
            {
                "name": rule.name,
                "lcz": rule.lcz,
                "column": rule.column,
                "min_fraction": rule.min_fraction,
                "enabled": rule.enabled,
                "reason": rule.reason,
                "units_assigned": self.semantic_firings.get(rule.name, 0),
            }
            for rule in self.config.semantic_rules
        ],
        "thresholds": {
            "built_min_building_fraction": self.config.built_min_building_fraction,
            "lcz10_industrial_column": self.config.lcz10_industrial_column,
            "lcz10_min_industrial_fraction": self.config.lcz10_min_industrial_fraction,
            "lcz1_min_height_m": self.config.lcz1_min_height_m,
            "natural_dominant_fraction": self.config.natural_dominant_fraction,
            "natural_negligible_fraction": self.config.natural_negligible_fraction,
        },
        "unreachable_classes": {
            **{str(code): _unreachable_reason(code) for code in self.unreachable_natural},
            str(FUNCTIONAL_ONLY_CODE): (
                "Functional only: removed from the distance metric per Bernard et al. (2024) "
                "and assigned solely by the industrial rule, above "
                f"{self.config.lcz10_min_industrial_fraction} of "
                f"{self.config.lcz10_industrial_column}. The morphological rule it replaced "
                "was measured inert on the Rotterdam fixture at every threshold from 0.05 to "
                "0.5 - port plots are sparsely built, so building surface fraction places them "
                "on LCZ 9 and LCZ 10 never came within reach of the argmin. Its distance is "
                "still computed and reported as lcz_d10."
            ),
        },
    }

classify_units

classify_units(parameters: DataFrame, config: ClassificationConfig | None = None) -> DataFrame

Convenience wrapper: build a PrototypeClassifier from config and classify once.

Source code in src/lczkit/classify/classifier.py
def classify_units(
    parameters: pd.DataFrame, config: ClassificationConfig | None = None
) -> pd.DataFrame:
    """Convenience wrapper: build a `PrototypeClassifier` from `config` and classify once."""
    return PrototypeClassifier(config).classify(parameters)

Distance and normalisation

lczkit.classify.distance

Normalisation and weighted partial distance from a unit to each LCZ prototype.

Bernard et al. (2024) Sect. 2.3 give the recipe. A class is a hypercube in the UCP space and a unit is a point; the distance is the gap from the point to the box, zero inside it. Because the dimensions have wildly different spreads - building height runs from zero to hundreds of metres while a fraction is confined to [0, 1] - each is standardised first, "using the mean and the standard deviation of all LCZ-type boundary values". That is the published operationalisation of normalising against the LCZ-defined range, and it is what this module does.

Two details are load-bearing and neither is spelled out in the paper.

An open-ended bound never penalises. LCZ 1's height range is "25 m and above"; a 90 m unit is inside it, not 65 m outside. Blank cells in the transcribed table are exactly this, and there are many of them.

A dimension a prototype does not constrain is an unbounded interval, not missing data. LCZ G has no published height range at all. Treating that as an absent dimension would shrink LCZ G's denominator relative to every other class's and make its distance systematically smaller - the class would win units it has no claim on. Treating it as unbounded gives it a zero penalty there while keeping the denominator identical across all seventeen, which is what makes the distances comparable at all.

Only a null unit value shrinks the denominator, and then it shrinks it identically for every prototype. That is the weighted partial distance: sum over available parameters, renormalise by the sum of their weights, never impute and never drop the unit.

Normalisation dataclass

Normalisation(dimensions: tuple[str, ...], means: dict[str, float], stds: dict[str, float], degenerate: tuple[str, ...])

Per-dimension standardisation derived from the prototype table itself.

degenerate instance-attribute

degenerate: tuple[str, ...]

Dimensions whose boundary values had zero spread, and whose standard deviation was therefore replaced by 1.0. None occur in the shipped table; recorded so a user-supplied prototype override cannot introduce a silent division by zero.

z

z(column: str, value: float) -> float

Standardise one value in one dimension.

Source code in src/lczkit/classify/distance.py
def z(self, column: str, value: float) -> float:
    """Standardise one value in one dimension."""
    return (value - self.means[column]) / self.stds[column]

as_dict

as_dict() -> dict[str, dict[str, float]]

The per-dimension mean and standard deviation, keyed by column, for the manifest.

Recorded because the metric is only interpretable against the standardisation it was computed under: the same distance means different things under two prototype tables.

Source code in src/lczkit/classify/distance.py
def as_dict(self) -> dict[str, dict[str, float]]:
    """The per-dimension mean and standard deviation, keyed by column, for the manifest.

    Recorded because the metric is only interpretable against the standardisation it was
    computed under: the same distance means different things under two prototype tables.
    """
    return {
        column: {"mean": self.means[column], "std": self.stds[column]}
        for column in self.dimensions
    }

DistanceResult dataclass

DistanceResult(distances: DataFrame, n_params_used: Series, n_params_available: int, missing_parameters: Series)

Distances to one family's prototypes, plus what the metric had to work with.

distances instance-attribute

distances: DataFrame

Indexed like the input, one column per LCZ code, in the order codes was given.

n_params_used instance-attribute

n_params_used: Series

Dimensions that carried non-zero weight and a non-null value, per unit.

n_params_available instance-attribute

n_params_available: int

How many dimensions carried non-zero weight at all, for this family's weight vector.

The denominator n_params_used is a count out of, and it differs between families: under bernard2024_partial a built unit can reach 3 and a natural unit 7, because four dimensions are zero-weighted for built types and leave both sides of the renormalisation. Without this, a single n_params_used column silently mixes the two scales and a built unit scoring 3 of 3 is indistinguishable from a natural unit scoring 3 of 7.

missing_parameters instance-attribute

missing_parameters: Series

Comma-separated names of the weighted dimensions that were null, per unit; empty string where nothing was missing. A string rather than a list so the column survives a GeoParquet round trip unchanged and reads plainly in the map site's sidebar.

PrototypeSpace

PrototypeSpace(prototypes: Sequence[PrototypeRange] = PROTOTYPES)

A prototype table plus the normalisation derived from it.

Holds the standardised bounds so a run does not re-derive them per call. Immutable in use; build a second one to classify against different thresholds.

Derive the normalisation and each class's standardised bounds from prototypes.

Defaults to the transcribed Stewart & Oke table. Both derivations happen once here, so distances() neither re-derives them nor depends on call order.

Source code in src/lczkit/classify/distance.py
def __init__(self, prototypes: Sequence[PrototypeRange] = PROTOTYPES) -> None:
    """Derive the normalisation and each class's standardised bounds from `prototypes`.

    Defaults to the transcribed Stewart & Oke table. Both derivations happen once here, so
    `distances()` neither re-derives them nor depends on call order.
    """
    self.prototypes = tuple(prototypes)
    self.normalisation = normalisation(self.prototypes)
    self.dimensions = self.normalisation.dimensions
    self._position = {column: index for index, column in enumerate(self.dimensions)}
    self._bounds = {
        code: self._standardised_bounds(code)
        for code in sorted({prototype.code for prototype in self.prototypes})
    }

codes property

codes: tuple[int, ...]

Every class this space carries ranges for, ascending.

indistinguishable

indistinguishable(codes: Sequence[int], dimensions: Sequence[str]) -> tuple[tuple[int, int], ...]

Class pairs from codes whose boxes overlap when only dimensions are available.

A unit landing in such an overlap is at distance zero from both classes, so the label is decided by _two_closest's tie-break — ascending code — and not by any measurement. The pair is structurally inseparable on that dimension set: no amount of precision in the parameters that remain would tell the two apart there.

This is why it is worth reporting per weight vector and per missing-parameter signature rather than only per unit. On the shipped built weights the full three dimensions give a single overlapping pair, LCZ 3 with LCZ 7 — so the metric is nearly a partition, which is not the intuitive answer. Drop aspect_ratio, which is null wherever no street reaches a building, and {3, 8} and {6, 8} join it; drop height_of_roughness_elements_m instead and {2, 3}, {2, 7}, {3, 7} and {5, 6} do. That second set is the height confusion axis, and it falls out of the prototype table's own geometry without looking at a single city.

Overlap is tested on the standardised bounds, which is equivalent to testing the raw ones: standardisation is a positive affine map per dimension and so preserves interval intersection. An open end is NaN and reads as unbounded.

Source code in src/lczkit/classify/distance.py
def indistinguishable(
    self, codes: Sequence[int], dimensions: Sequence[str]
) -> tuple[tuple[int, int], ...]:
    """Class pairs from `codes` whose boxes overlap when only `dimensions` are available.

    A unit landing in such an overlap is at distance zero from both classes, so the label is
    decided by `_two_closest`'s tie-break — ascending code — and not by any measurement. The
    pair is *structurally* inseparable on that dimension set: no amount of precision in the
    parameters that remain would tell the two apart there.

    This is why it is worth reporting per weight vector and per missing-parameter signature
    rather than only per unit. On the shipped built weights the full three dimensions give a
    single overlapping pair, LCZ 3 with LCZ 7 — so the metric is nearly a partition, which is
    not the intuitive answer. Drop `aspect_ratio`, which is null wherever no street reaches a
    building, and {3, 8} and {6, 8} join it; drop `height_of_roughness_elements_m` instead and
    {2, 3}, {2, 7}, {3, 7} and {5, 6} do. That second set is the height confusion axis, and it
    falls out of the prototype table's own geometry without looking at a single city.

    Overlap is tested on the standardised bounds, which is equivalent to testing the raw ones:
    standardisation is a positive affine map per dimension and so preserves interval
    intersection. An open end is NaN and reads as unbounded.
    """
    index = [self._position[column] for column in dimensions]
    pairs: list[tuple[int, int]] = []
    for position, first in enumerate(codes):
        for second in codes[position + 1 :]:
            lo_a, hi_a = self._bounds[first]
            lo_b, hi_b = self._bounds[second]
            if all(
                np.fmax(
                    np.nan_to_num(lo_a[i], nan=-np.inf), np.nan_to_num(lo_b[i], nan=-np.inf)
                )
                < np.fmin(
                    np.nan_to_num(hi_a[i], nan=np.inf), np.nan_to_num(hi_b[i], nan=np.inf)
                )
                for i in index
            ):
                pairs.append((first, second))
    return tuple(pairs)

occupancy

occupancy(codes: Sequence[int], weights: Mapping[str, float], *, samples: int = 200000, seed: int = 0) -> dict[str, object]

What share of the parameter space each class would claim before any data is seen.

The metric is a nearest-box rule, and the boxes tile only a small part of the space they sit in — so most units are assigned by the gap to the nearest box, and the size of each class's catchment is a property of the prototype table and the normalisation rather than of any city. It is very uneven: on the shipped built weights LCZ 2 claims roughly a third of the reachable space while LCZ 8 and LCZ 9 claim under two percent each.

That is not a defect to fix — the classes genuinely are different sizes in UCP space — but it is a prior the output carries silently, and a reader comparing per-class recall across classes needs it. Reported for the same reason height_tier_fractions is: a number that changes how the result should be read belongs in the manifest, not in a reader's head.

Sampling bounds are [0, largest boundary] per weighted dimension, taken from the prototype table itself rather than chosen, and returned alongside the shares because they set what "the space" means — classes open at the top (LCZ 1 and 4 in height) claim more of a taller cube. seed is fixed so a manifest reproduces.

Source code in src/lczkit/classify/distance.py
def occupancy(
    self,
    codes: Sequence[int],
    weights: Mapping[str, float],
    *,
    samples: int = 200_000,
    seed: int = 0,
) -> dict[str, object]:
    """What share of the parameter space each class would claim before any data is seen.

    The metric is a nearest-box rule, and the boxes tile only a small part of the space they
    sit in — so most units are assigned by the *gap* to the nearest box, and the size of each
    class's catchment is a property of the prototype table and the normalisation rather than
    of any city. It is very uneven: on the shipped built weights LCZ 2 claims roughly a third
    of the reachable space while LCZ 8 and LCZ 9 claim under two percent each.

    That is not a defect to fix — the classes genuinely are different sizes in UCP space — but
    it is a prior the output carries silently, and a reader comparing per-class recall across
    classes needs it. Reported for the same reason `height_tier_fractions` is: a number that
    changes how the result should be read belongs in the manifest, not in a reader's head.

    Sampling bounds are `[0, largest boundary]` per weighted dimension, taken from the
    prototype table itself rather than chosen, and returned alongside the shares because they
    set what "the space" means — classes open at the top (LCZ 1 and 4 in height) claim more of
    a taller cube. `seed` is fixed so a manifest reproduces.
    """
    active = [column for column in self.dimensions if weights.get(column, 0.0) > 0]
    if not active or not codes:
        return {"samples": 0, "bounds": {}, "share": {}}

    upper: dict[str, float] = {}
    for column in active:
        bounds = [
            bound
            for prototype in self.prototypes
            if prototype.column == column
            for bound in (prototype.lo, prototype.hi)
            if bound is not None
        ]
        upper[column] = float(max(bounds)) if bounds else 1.0

    rng = np.random.default_rng(seed)
    frame = pd.DataFrame(
        {
            column: (
                rng.uniform(0.0, upper[column], samples)
                if column in active
                # An unweighted dimension cannot influence the result; hold it at zero rather
                # than sampling a quantity nothing reads.
                else np.zeros(samples)
            )
            for column in self.dimensions
        }
    )
    distances = self.distances(frame, codes, weights).distances
    winner = np.asarray(distances.columns)[distances.to_numpy().argmin(axis=1)]
    counts = pd.Series(winner).value_counts()
    return {
        "samples": samples,
        "seed": seed,
        "bounds": {column: [0.0, upper[column]] for column in active},
        "share": {str(code): float(counts.get(code, 0)) / samples for code in codes},
    }

distances

distances(values: DataFrame, codes: Sequence[int], weights: Mapping[str, float]) -> DistanceResult

Weighted partial distance from each row of values to each prototype in codes.

values must carry every dimension; extra columns are ignored. A row where no weighted dimension has a value gets an all-null distance row rather than a zero one - a unit the metric knows nothing about is unclassifiable, not equidistant from everything.

Source code in src/lczkit/classify/distance.py
def distances(
    self,
    values: pd.DataFrame,
    codes: Sequence[int],
    weights: Mapping[str, float],
) -> DistanceResult:
    """Weighted partial distance from each row of `values` to each prototype in `codes`.

    `values` must carry every dimension; extra columns are ignored. A row where no weighted
    dimension has a value gets an all-null distance row rather than a zero one - a unit the
    metric knows nothing about is unclassifiable, not equidistant from everything.
    """
    absent = [column for column in self.dimensions if column not in values.columns]
    if absent:
        raise ValueError(
            f"values is missing prototype dimensions: {', '.join(absent)}. "
            "Pass the table lczkit.ucp.compute_parameters() returns."
        )
    unknown = [code for code in codes if code not in self._bounds]
    if unknown:
        raise KeyError(f"no prototype ranges for LCZ code(s) {unknown}")
    if not codes:
        raise ValueError("codes must name at least one prototype")

    weight = np.asarray([float(weights[column]) for column in self.dimensions])
    raw = values[list(self.dimensions)].to_numpy(dtype="float64", na_value=np.nan)
    known = ~np.isnan(raw)

    means = np.asarray([self.normalisation.means[c] for c in self.dimensions])
    stds = np.asarray([self.normalisation.stds[c] for c in self.dimensions])
    z = (raw - means) / stds

    # Identical for every prototype, so a unit's distances stay comparable; a zero-weight
    # dimension leaves both sides and cannot influence the result.
    denominator = (known * weight).sum(axis=1)
    usable = denominator > 0
    safe = np.where(usable, denominator, 1.0)

    columns: dict[int, np.ndarray] = {}
    for code in codes:
        lo, hi = self._bounds[code]
        # An open end is NaN, which propagates through the comparison; `nan_to_num` then
        # reads it as "no bound on this side", i.e. no penalty. Only one of the two terms
        # can be positive, so adding them is the same as taking the gap to the nearer bound.
        penalty = np.nan_to_num(np.maximum(0.0, lo - z)) + np.nan_to_num(
            np.maximum(0.0, z - hi)
        )
        numerator = (np.where(known, penalty, 0.0) ** 2 * weight).sum(axis=1)
        columns[code] = np.where(usable, np.sqrt(numerator / safe), np.nan)

    weighted = [column for column, w in zip(self.dimensions, weight, strict=True) if w > 0]
    present = known[:, [self._position[column] for column in weighted]]
    return DistanceResult(
        distances=pd.DataFrame(columns, index=values.index),
        n_params_used=pd.Series(present.sum(axis=1), index=values.index, dtype="int64"),
        n_params_available=len(weighted),
        missing_parameters=pd.Series(
            [
                ",".join(name for name, ok in zip(weighted, row, strict=True) if not ok)
                for row in present
            ],
            index=values.index,
            dtype="object",
        ),
    )

normalisation

normalisation(prototypes: Sequence[PrototypeRange] = PROTOTYPES) -> Normalisation

Mean and standard deviation of every non-null boundary value, per dimension.

Computed over all prototypes rather than per family, so the built and natural distances are expressed on one scale.

The population standard deviation is used: these are the boundary values, the whole set of them, not a sample drawn from a larger population.

Source code in src/lczkit/classify/distance.py
def normalisation(prototypes: Sequence[PrototypeRange] = PROTOTYPES) -> Normalisation:
    """Mean and standard deviation of every non-null boundary value, per dimension.

    Computed over *all* prototypes rather than per family, so the built and natural distances are
    expressed on one scale.

    The population standard deviation is used: these are the boundary values, the whole set of
    them, not a sample drawn from a larger population.
    """
    boundaries: dict[str, list[float]] = {}
    for prototype in prototypes:
        bounds = boundaries.setdefault(prototype.column, [])
        bounds += [bound for bound in (prototype.lo, prototype.hi) if bound is not None]

    means: dict[str, float] = {}
    stds: dict[str, float] = {}
    degenerate: list[str] = []
    for column, values in boundaries.items():
        if not values:
            raise ValueError(f"dimension {column!r} has no prototype boundary values to scale by")
        array = np.asarray(values, dtype="float64")
        means[column] = float(array.mean())
        spread = float(array.std())
        if spread == 0.0:
            degenerate.append(column)
            spread = 1.0
        stds[column] = spread
    return Normalisation(
        dimensions=tuple(boundaries),
        means=means,
        stds=stds,
        degenerate=tuple(degenerate),
    )

uniqueness

uniqueness(closest: Series, second: Series) -> Series

Bernard et al. (2024) Eq. (1): |d1 - d2| / (d1 + d2), in [0, 1].

Zero means the two nearest prototypes are equidistant and the label is a coin toss; one means the nearest is unrivalled. It answers a different question from the distance itself - a unit can sit close to its class and still be ambiguous, or far from every class but unambiguously nearest one of them.

Both distances zero - a unit inside two hypercubes at once - gives zero rather than a division by zero, which is the formula's own limit and the honest answer: the label is arbitrary. A missing runner-up gives one: nothing rivals the label, which is the opposite of ambiguous.

Source code in src/lczkit/classify/distance.py
def uniqueness(closest: pd.Series, second: pd.Series) -> pd.Series:
    """Bernard et al. (2024) Eq. (1): `|d1 - d2| / (d1 + d2)`, in [0, 1].

    Zero means the two nearest prototypes are equidistant and the label is a coin toss; one means
    the nearest is unrivalled. It answers a different question from the distance itself - a unit
    can sit close to its class and still be ambiguous, or far from every class but unambiguously
    nearest one of them.

    Both distances zero - a unit inside two hypercubes at once - gives zero rather than a division
    by zero, which is the formula's own limit and the honest answer: the label is arbitrary. A
    missing runner-up gives one: nothing rivals the label, which is the opposite of ambiguous.
    """
    total = closest + second
    scores = (closest - second).abs().div(total.where(total > 0)).fillna(0.0)
    return scores.mask(second.isna(), 1.0).where(closest.notna())

Prototypes

Transcribed from docs/references/tables/. The three ranges lczkit defines itself are tagged source="lczkit" rather than attributed to Stewart & Oke: tree_fraction and water_fraction, without which the natural classes cannot be separated at all, and mean_building_area_m2, without which LCZ 7 and LCZ 8 come out swapped — measured over built cells, "large low-rise" landing on 55–93 m² footprints and "lightweight low-rise" on 7 000–13 000 m² ones, in every city checked. The building-size dimension carries weight 0.0 in every shipped preset and so changes no label; its weight and its two bounds are for a sweep to set.

lczkit.classify.prototypes

The LCZ prototype table: per class, the range each property is allowed to take.

Stewart & Oke (2012) Table 3 defines seventeen classes as ranges over ten properties. In the closest-distance approach a class is a hypercube in that space and a unit is a point, so this table is the entire basis of classification. Every number here is transcribed from docs/references/tables/, never reproduced from memory: a plausible-looking wrong threshold is the worst failure mode this package has.

Every value here is transcribed from docs/references/tables/stewart_oke_2012_properties.md, verbatim and in the table's own units, including the properties this package cannot compute. test_classify_prototypes.py parses that markdown and asserts cell-for-cell agreement, so the committed table stays the authority and this module is a copy of it that ships in the wheel. Percent-to-fraction conversion happens in PropertySpec.scale, in one place, rather than being folded into the transcription where a test could not see it.

Three properties are lczkit's own and are marked source=LCZKIT. tree_fraction and water_fraction come from docs/references/tables/lczkit_natural_class_ranges.md and exist because the published table cannot separate the natural classes at all with the parameters this package computes — see that file for the full argument, and UNUSED_PROPERTIES below for the properties whose absence causes it.

mean_building_area_m2 comes from docs/references/tables/lczkit_building_size_ranges.md and exists because LCZ 7 and LCZ 8 — lightweight low-rise and large low-rise — are separated by nothing in the metric that measures how big a building is, and consequently come out swapped: measured over built cells, LCZ 8 lands on 55-93 m² footprints and LCZ 7 on 7 000-13 000 m² ones, in every city checked. It carries weight 0.0 in every shipped preset and therefore changes no label, because its weight has not been calibrated against a reference.

STEWART_OKE_2012 module-attribute

STEWART_OKE_2012 = '10.1175/BAMS-D-11-00019.1'

Stewart & Oke (2012), BAMS 93(12), 1879-1900, Table 3.

LCZKIT module-attribute

LCZKIT = 'lczkit'

Not from any publication. See docs/references/tables/lczkit_natural_class_ranges.md.

DEFAULT_DOMINANT_FRACTION module-attribute

DEFAULT_DOMINANT_FRACTION = 0.5

Tree or water cover at which a natural class is "dense trees" or "water" - the majority of the unit. lczkit's own, from docs/references/tables/lczkit_natural_class_ranges.md.

DEFAULT_NEGLIGIBLE_FRACTION module-attribute

DEFAULT_NEGLIGIBLE_FRACTION = 0.1

Tree or water cover a natural class treats as absent. Reuses the 10% boundary the Stewart & Oke table itself applies to building and impervious cover throughout its natural rows.

PROTOTYPES module-attribute

PROTOTYPES: tuple[PrototypeRange, ...] = build_prototypes()

Every (class, dimension) interval the distance metric can use, in column units.

DIMENSIONS module-attribute

DIMENSIONS: tuple[str, ...] = tuple(spec.column for spec in PROPERTIES if spec.column is not None)

The parameter columns classification runs over, in PROPERTIES order.

HEIGHT_DEPENDENT_DIMENSIONS module-attribute

HEIGHT_DEPENDENT_DIMENSIONS: tuple[str, ...] = tuple(spec.column for spec in PROPERTIES if spec.column is not None and spec.reads_building_height)

Dimensions whose value moves when the height cascade does. See PropertySpec.reads_building_height — there are two, and one of them is not called a height.

UNUSED_PROPERTIES module-attribute

UNUSED_PROPERTIES: tuple[tuple[str, str], ...] = (('sky_view_factor', 'Not computed: the single most expensive component, and strongly correlated with aspect ratio, which is computed. Its absence is the main reason the published table cannot separate LCZ A, B, C and D: it is one of only three dimensions distinguishing them, and the other two are also building-derived. Bernard et al. (2024) weight it at 4 of 21.5, second only to building surface fraction.'), ('terrain_roughness_class', 'Not computed. Davenport et al. (2000) map the class to a roughness length z0, and deriving z0 from morphology (Macdonald, Kanda) is not implemented, so the lookup has no input. Bernard et al. (2024) weight z0 at 0.5 of 21.5, the least influential dimension in their scheme.'), ('surface_admittance', 'A thermal property of the materials, not a morphological one. Nothing in the open vector or raster data this package ingests measures it, and no proxy for it is proposed. Stewart & Oke publish it as a descriptive attribute of each class rather than as a classification input, and Bernard et al. (2024) assign it no weight.'), ('surface_albedo', 'As for surface admittance: a radiative property of the materials. Deriving it would need a multispectral product and a narrowband-to-broadband conversion, which is a different package.'), ('anthropogenic_heat_output', 'An energy-use quantity, not a surface one. Estimating it needs population, traffic and building-energy data none of which this package ingests. Note that it is the only property in the published table that would separate LCZ 10 from LCZ 8 directly - 300+ W m-2 against at most 50 - which is why the LCZ 10 rule has to reach for a functional attribute instead.'))

Stewart & Oke properties present in the transcribed table but absent from the distance metric.

Recorded as data so the run manifest can say which dimensions of the LCZ definition a run actually measured. Five of the ten are unused, which is a material caveat on every label this package emits and must not be discoverable only by reading the source.

PropertySpec dataclass

PropertySpec(name: str, column: str | None, table_unit: str, scale: float, source: str, reads_building_height: bool = False)

One axis of the prototype space.

name instance-attribute

name: str

Property name as it appears in the transcribed table, lower-cased and underscored.

column instance-attribute

column: str | None

The lczkit.ucp parameter column carrying it, or None if this package does not compute it. A None column drops the property out of the distance metric entirely.

table_unit instance-attribute

table_unit: str

Unit the transcribed values are in: "percent", "m", "fraction" or "class".

scale instance-attribute

scale: float

Multiplier converting a transcribed value into the column's unit. 0.01 for the three percentage properties, 1.0 elsewhere.

source instance-attribute

source: str

STEWART_OKE_2012 or LCZKIT.

reads_building_height class-attribute instance-attribute

reads_building_height: bool = False

Whether computing this dimension consumes the building height.

Two do, and only one of them is obviously a height: height_of_roughness_elements_m is the geometric mean of building heights, and aspect_ratio is momepy.street_profile(..., height=buildings["height"]), whose numerator is that same column. So a height error does not perturb one dimension of the metric, it perturbs two — and under bernard2024_partial those two carry 9 of the 17 applied weight units between them.

Recorded as data rather than as prose because the manifest states the figure, and a hand-written constant would go stale the moment a weight preset or a dimension changed.

PrototypeRange dataclass

PrototypeRange(code: int, property_name: str, column: str, lo: float | None, hi: float | None, source: str)

One class's allowed interval in one dimension, in the column's unit.

lo or hi of None is an open end: the class is unbounded on that side and a unit beyond it is never penalised there. A dimension missing from a class entirely is open on both sides.

code instance-attribute

code: int

LCZ integer code 1-17.

property_name instance-attribute

property_name: str

Entry in PROPERTIES.

column instance-attribute

column: str

The lczkit.ucp parameter column, always non-None here.

source instance-attribute

source: str

STEWART_OKE_2012 or LCZKIT.

build_prototypes

build_prototypes(*, dominant_fraction: float = DEFAULT_DOMINANT_FRACTION, negligible_fraction: float = DEFAULT_NEGLIGIBLE_FRACTION) -> tuple[PrototypeRange, ...]

The prototype table, with the two lczkit-owned thresholds substituted in.

Called with no arguments this reproduces PROTOTYPES exactly. ClassificationConfig calls it with the configured thresholds, so moving them moves the table rather than leaving config and prototypes disagreeing.

Source code in src/lczkit/classify/prototypes.py
def build_prototypes(
    *,
    dominant_fraction: float = DEFAULT_DOMINANT_FRACTION,
    negligible_fraction: float = DEFAULT_NEGLIGIBLE_FRACTION,
) -> tuple[PrototypeRange, ...]:
    """The prototype table, with the two lczkit-owned thresholds substituted in.

    Called with no arguments this reproduces `PROTOTYPES` exactly. `ClassificationConfig` calls it
    with the configured thresholds, so moving them moves the table rather than leaving config and
    prototypes disagreeing.
    """
    if not 0.0 < negligible_fraction < dominant_fraction <= 1.0:
        raise ValueError(
            "expected 0 < negligible_fraction < dominant_fraction <= 1, got "
            f"{negligible_fraction} and {dominant_fraction}"
        )
    cover = _natural_cover(dominant_fraction, negligible_fraction)
    ranges: list[PrototypeRange] = []
    for entry in LCZ_CLASSES:
        by_property = {
            **_RANGES[entry.label],
            **cover.get(entry.label, {}),
            **_BUILDING_SIZE.get(entry.label, {}),
        }
        for spec in PROPERTIES:
            if spec.column is None or spec.name not in by_property:
                continue
            lo, hi = by_property[spec.name]
            ranges.append(
                PrototypeRange(
                    code=entry.code,
                    property_name=spec.name,
                    column=spec.column,
                    lo=None if lo is None else lo * spec.scale,
                    hi=None if hi is None else hi * spec.scale,
                    source=spec.source,
                )
            )
    return tuple(ranges)

ranges_for

ranges_for(code: int) -> dict[str, tuple[float | None, float | None]]

column -> (lo, hi) for one class, omitting dimensions it does not constrain.

Source code in src/lczkit/classify/prototypes.py
def ranges_for(code: int) -> dict[str, tuple[float | None, float | None]]:
    """`column -> (lo, hi)` for one class, omitting dimensions it does not constrain."""
    return {
        prototype.column: (prototype.lo, prototype.hi)
        for prototype in PROTOTYPES
        if prototype.code == code
    }

property_of

property_of(column: str) -> PropertySpec

The PropertySpec whose column is column.

Source code in src/lczkit/classify/prototypes.py
def property_of(column: str) -> PropertySpec:
    """The `PropertySpec` whose `column` is `column`."""
    for spec in PROPERTIES:
        if spec.column == column:
            return spec
    raise KeyError(f"no prototype dimension for column {column!r}; have {', '.join(DIMENSIONS)}")

Weights

lczkit.classify.weights

Per-dimension weights for the prototype-distance metric.

Bernard et al. (2024) Sect. 2.3 make the weight vector an explicit degree of freedom rather than an assumption, for three stated reasons: the input data may not represent reality well, the method used for a given UCP may not match Stewart & Oke's definition, and a user may simply disagree that the seven properties matter equally. lczkit follows them - weights are config, and the active preset appears in the manifest.

Their weights are for the built types only. Sect. 2.5, p. 2085: "Those weights are only used in the closest-distance approach for LCZ built types." The natural types never touch the distance metric in GeoClimate at all; they go through a land-cover decision tree. That is why WeightPreset carries two vectors, and why the natural half of bernard2024_partial is marked as lczkit's rather than as published.

BERNARD_2024 module-attribute

BERNARD_2024 = '10.5194/gmd-17-2077-2024'

Bernard et al. (2024), GMD 17, 2077-2107, Sect. 2.5.

WeightPreset dataclass

WeightPreset(name: str, built: dict[str, float], natural: dict[str, float], description: str)

A named pair of weight vectors, one per LCZ family.

built instance-attribute

built: dict[str, float]

Weight per dimension for LCZ 1-10. Zero means the dimension is ignored entirely - it leaves both the numerator and the denominator of the distance.

natural instance-attribute

natural: dict[str, float]

Weight per dimension for LCZ A-G.

for_family

for_family(family: str) -> dict[str, float]

The vector for "built" or "natural".

Source code in src/lczkit/classify/weights.py
def for_family(self, family: str) -> dict[str, float]:
    """The vector for `"built"` or `"natural"`."""
    if family == "built":
        return self.built
    if family == "natural":
        return self.natural
    raise KeyError(f"unknown family {family!r}; expected 'built' or 'natural'")

preset

preset(name: str) -> WeightPreset

The preset called name, or a KeyError naming what exists.

Source code in src/lczkit/classify/weights.py
def preset(name: str) -> WeightPreset:
    """The preset called `name`, or a `KeyError` naming what exists."""
    try:
        return _BY_NAME[name]
    except KeyError:
        raise KeyError(
            f"no weight preset named {name!r}; available: {', '.join(_BY_NAME)}"
        ) from None

Functional rules

lczkit.classify.rules

The two rules that sit outside the distance metric, and why each has to.

Both exist because a dimension the LCZ scheme depends on is not in the parameter vector.

The family gate. Stewart & Oke separate LCZ A, B, C and D by sky view factor, aspect ratio and height of roughness elements alone - all three building-derived, all three null or zero in open ground - so once a unit has no buildings the natural classes collapse onto one point and the built ones are the only thing left with any spread. Bernard et al. (2024) avoid this by deciding land cover first and running the closest-distance approach only over the built types (Sect. 2.3, Figs. 2-3). The gate is the same idea in one threshold: below a building surface fraction the published table itself treats as the built/natural boundary, a unit is compared against the natural prototypes and never against the built ones.

LCZ 10. Large low-rise and heavy industry are geometrically near-identical - large footprint, low, sparse - and the only published property separating them is anthropogenic heat output, at 300+ W m-2 against at most 50, which nothing in open vector or raster data measures. So a functional attribute has to break the tie. It is applied after the distance and never folded into the metric, where a functional attribute would silently distort every other class.

The rule is functional, not a pair gate, and the difference was measured. The original design swapped LCZ 10 in only where it was already the runner-up behind LCZ 8. That was measured inert on the Rotterdam fixture at every threshold from 0.05 to 0.5: 671 cells of working port, 254 industrial buildings, three quarters of cells over 90% industrial by area, 88 placed in LCZ 10 by the reference - and the pair never opened once. Port plots are large and sparsely built, so building surface fraction lands them on LCZ 9 and LCZ 10 is nowhere near second. The threshold was never the binding constraint, so no amount of tuning it could have helped.

Following Bernard et al. (2024), LCZ 10 is therefore removed from the distance metric entirely and assigned functionally. Its distance is still computed and reported in the seventeen-way vector - the vector is always complete, and a class that is unreachable by selection is exactly what the manifest's unreachable_classes field exists to record - but it can no longer win an argmin, so the only route to LCZ 10 is the industrial evidence.

Note the asymmetry with LCZ 8, which is a deliberate divergence from Bernard, who excludes both. LCZ 8's defining character - large, low, sparse buildings - is genuinely morphological, so it stays in the metric. Excluding it would leave it assignable only functionally, which is worse.

Family module-attribute

Family = str

"built" or "natural".

ROUTE_SEMANTIC module-attribute

ROUTE_SEMANTIC = 'semantic_rule'

A label assigned by a functional rule other than the industrial one.

Distinct from industrial_rule rather than folded into it: that rule's threshold is calibrated against the Rotterdam reference and its firing count is a published figure, so a second rule sharing its route value would silently change what that count means. Which rule fired is in semantic_rule_applied.

ROUTE_SMOOTHED module-attribute

ROUTE_SMOOTHED = 'modal_filter'

A label taken from the unit's neighbours rather than from its own parameters.

Only lczkit.classify.smoothing emits it, and only when that filter is enabled — which it is not by default. Kept in the vocabulary regardless, so the category set does not depend on a configuration flag and a run with the filter off is schema-identical to one with it on.

ROUTES module-attribute

ROUTES: tuple[str, ...] = (ROUTE_BUILT, ROUTE_NATURAL, ROUTE_INDUSTRIAL, ROUTE_SEMANTIC, ROUTE_SMOOTHED)

Every value label_route can take. A fixed vocabulary so the column is a stable category.

Ranked dataclass

Ranked(primary: Series, secondary: Series, closest: Series, runner_up: Series)

The two nearest prototypes and their distances, per unit.

family_of

family_of(building_surface_fraction: Series, threshold: float) -> Series

"built" where the building surface fraction reaches threshold, else "natural".

building_surface_fraction is never null - the parameter stage reports 0.0, not NaN, for a unit holding no buildings, because "no buildings here" is a measurement - so the gate is defined for every unit and no unit goes unclassified for want of it.

Source code in src/lczkit/classify/rules.py
def family_of(building_surface_fraction: pd.Series, threshold: float) -> pd.Series:
    """`"built"` where the building surface fraction reaches `threshold`, else `"natural"`.

    `building_surface_fraction` is never null - the parameter stage reports 0.0, not NaN, for a
    unit holding no buildings, because "no buildings here" is a measurement - so the gate is defined
    for every unit and no unit goes unclassified for want of it.
    """
    if building_surface_fraction.isna().any():
        raise ValueError(
            "building_surface_fraction contains nulls; the parameter stage reports 0.0 for a unit "
            "with no buildings, so a null here means the parameter table was not produced by "
            "lczkit.ucp.compute_parameters()."
        )
    return pd.Series(
        np.where(building_surface_fraction >= threshold, BUILT, NATURAL),
        index=building_surface_fraction.index,
        dtype="object",
    )

apply_lcz10_rule

apply_lcz10_rule(ranked: Ranked, industrial_fraction: Series, threshold: float, *, lcz10: int = 10) -> tuple[Ranked, Series]

Assign LCZ 10 wherever the industrial evidence exceeds threshold, whatever the morphology.

Functional assignment, not a swap between two candidates the metric already liked. LCZ 10 is not in the built prototype set at all, so this is the only thing that can produce it: a unit over the threshold becomes LCZ 10 regardless of where the distance placed it, which is the point - the measured failure of the previous rule was that the port cells it was meant to catch were nowhere near LCZ 10 in the metric.

The displaced morphological answer is preserved as secondary, so the output still says precisely what would have been emitted without the industrial evidence, and runner_up moves with it - it becomes the distance to that displaced class, keeping the invariant that runner_up is the distance to secondary.

closest becomes null for a fired unit. LCZ 10 is outside the metric, so no distance to it is defined, and carrying the displaced class's distance under a column called min_distance would be a quiet lie about a label that was never measured by distance at all. uniqueness follows the same null: a margin between the two nearest prototypes is a property of the metric, and a functional assignment did not come from it.

A null industrial_fraction never fires the rule. The unit-area share is 0.0 rather than null where there is no evidence, so a null means either that the layer was missing entirely or - for the building-area share, which is the default column - that the unit holds no buildings to judge. Neither is grounds for calling it heavy industry.

Source code in src/lczkit/classify/rules.py
def apply_lcz10_rule(
    ranked: Ranked,
    industrial_fraction: pd.Series,
    threshold: float,
    *,
    lcz10: int = 10,
) -> tuple[Ranked, pd.Series]:
    """Assign LCZ 10 wherever the industrial evidence exceeds `threshold`, whatever the morphology.

    Functional assignment, not a swap between two candidates the metric already liked. LCZ 10 is
    not in the built prototype set at all, so this is the only thing that can produce it: a unit
    over the threshold becomes LCZ 10 regardless of where the distance placed it, which is the
    point - the measured failure of the previous rule was that the port cells it was meant to
    catch were nowhere near LCZ 10 in the metric.

    The displaced morphological answer is preserved as `secondary`, so the output still says
    precisely what would have been emitted without the industrial evidence, and `runner_up` moves
    with it - it becomes the distance to that displaced class, keeping the invariant that
    `runner_up` is the distance to `secondary`.

    `closest` becomes null for a fired unit. LCZ 10 is outside the metric, so no distance to it is
    defined, and carrying the displaced class's distance under a column called `min_distance` would
    be a quiet lie about a label that was never measured by distance at all. `uniqueness` follows
    the same null: a margin between the two nearest prototypes is a property of the metric, and a
    functional assignment did not come from it.

    A null `industrial_fraction` never fires the rule. The unit-area share is 0.0 rather than null
    where there is no evidence, so a null means either that the layer was missing entirely or -
    for the building-area share, which is the default column - that the unit holds no buildings to
    judge. Neither is grounds for calling it heavy industry.
    """
    fired = (industrial_fraction > threshold).fillna(False)
    return (
        Ranked(
            primary=ranked.primary.where(~fired, lcz10),
            secondary=ranked.secondary.where(~fired, ranked.primary),
            closest=ranked.closest.where(~fired),
            runner_up=ranked.runner_up.where(~fired, ranked.closest),
        ),
        fired,
    )

apply_semantic_rules

apply_semantic_rules(ranked: Ranked, parameters: DataFrame, rules: Sequence[SemanticRuleConfig]) -> tuple[Ranked, Series, dict[str, int]]

Apply the configured functional rules in order, returning what each one fired on.

Mechanically identical to apply_lcz10_rule — a unit over the threshold takes the rule's class whatever the morphology said, the displaced answer is kept as secondary, and closest goes null because the assigned class was not reached by distance. Generalised rather than copied so there is one definition of what a functional assignment does to a Ranked.

Order matters and is the config's order. A later rule overrides an earlier one on a unit both would fire on, so the list reads most-general to most-specific. The per-rule counts are of units where that rule fired and survived, so they sum to the number of relabelled units and a rule shadowed by a later one is visible as a count of zero rather than by inference.

A rule that never fires must be distinguishable from one never configured, which is why every configured rule appears in the returned mapping whether or not it fired.

Every threshold here is uncalibrated, which is why they all ship disabled. A threshold is swept against a reference and chosen at an operating point, never picked; enabling one of these before that would put an invented number into a published label.

Source code in src/lczkit/classify/rules.py
def apply_semantic_rules(
    ranked: Ranked,
    parameters: pd.DataFrame,
    rules: Sequence[SemanticRuleConfig],
) -> tuple[Ranked, pd.Series, dict[str, int]]:
    """Apply the configured functional rules in order, returning what each one fired on.

    Mechanically identical to `apply_lcz10_rule` — a unit over the threshold takes the rule's class
    whatever the morphology said, the displaced answer is kept as `secondary`, and `closest` goes
    null because the assigned class was not reached by distance. Generalised rather than copied so
    there is one definition of what a functional assignment does to a `Ranked`.

    **Order matters and is the config's order.** A later rule overrides an earlier one on a unit
    both would fire on, so the list reads most-general to most-specific. The per-rule counts are of
    units where that rule fired *and survived*, so they sum to the number of relabelled units and a
    rule shadowed by a later one is visible as a count of zero rather than by inference.

    **A rule that never fires must be distinguishable from one never configured**, which is why
    every configured rule appears in the returned mapping whether or not it fired.

    Every threshold here is **uncalibrated**, which is why they all ship disabled. A threshold is
    swept against a reference and chosen at an operating point, never picked; enabling one of
    these before that would put an invented number into a published label.
    """
    # Which rule *last* fired on each unit. Counting from this rather than from the resulting
    # labels is the difference between "this rule placed 40 units" and "40 units carry LCZ 8",
    # which are the same number only for a class the metric can never assign — true of LCZ 10 and
    # false of LCZ 7 and 8, both of which are in the prototype set.
    winner = pd.Series("", index=ranked.primary.index, dtype="object")
    counts: dict[str, int] = {}
    for rule in rules:
        if not rule.enabled:
            counts[rule.name] = 0
            continue
        if rule.column not in parameters.columns:
            raise ValueError(
                f"semantic rule {rule.name!r} reads {rule.column!r}, which the parameter table "
                f"does not carry. Configure `ucp.semantic_groups` so that column is emitted, or "
                "disable the rule."
            )
        fires = (parameters[rule.column] > rule.min_fraction).fillna(False)
        if rule.max_mean_building_area_m2 is not None:
            fires &= (parameters["mean_building_area_m2"] <= rule.max_mean_building_area_m2).fillna(
                False
            )
        if rule.min_mean_building_area_m2 is not None:
            fires &= (parameters["mean_building_area_m2"] >= rule.min_mean_building_area_m2).fillna(
                False
            )
        ranked = Ranked(
            primary=ranked.primary.where(~fires, rule.lcz),
            secondary=ranked.secondary.where(~fires, ranked.primary),
            closest=ranked.closest.where(~fires),
            runner_up=ranked.runner_up.where(~fires, ranked.closest),
        )
        counts[rule.name] = 0
        winner = winner.where(~fires, rule.name)

    for name in counts:
        counts[name] = int((winner == name).sum())
    return ranked, winner.ne(""), counts

drop_lcz1_below_height

drop_lcz1_below_height(distances: DataFrame, height_of_roughness_elements_m: Series, minimum: float | None, *, lcz1: int = 1) -> DataFrame

Discard the LCZ 1 distance for units shorter than minimum, if one is configured.

Bernard et al. (2024) Sect. 2.3 apply the equivalent constraint on mean building levels, reporting that without it GeoClimate produced LCZ 1 across European cities where no urban researcher would place any. Off by default here: lczkit has no reliable storey count, so this reaches for Hr instead, and applying an untested constraint by default would be a worse failure than the over-prediction it guards against.

A null height never triggers the drop - the constraint is evidence of shortness, not absence of evidence of tallness.

Source code in src/lczkit/classify/rules.py
def drop_lcz1_below_height(
    distances: pd.DataFrame,
    height_of_roughness_elements_m: pd.Series,
    minimum: float | None,
    *,
    lcz1: int = 1,
) -> pd.DataFrame:
    """Discard the LCZ 1 distance for units shorter than `minimum`, if one is configured.

    Bernard et al. (2024) Sect. 2.3 apply the equivalent constraint on mean building levels,
    reporting that without it GeoClimate produced LCZ 1 across European cities where no urban
    researcher would place any. Off by default here: lczkit has no reliable storey count, so this
    reaches for `Hr` instead, and applying an untested constraint by default would be a worse
    failure than the over-prediction it guards against.

    A null height never triggers the drop - the constraint is evidence of shortness, not absence
    of evidence of tallness.
    """
    if minimum is None or lcz1 not in distances.columns:
        return distances
    too_short = (height_of_roughness_elements_m < minimum).fillna(False)
    result = distances.copy()
    result.loc[too_short, lcz1] = np.nan
    return result

Two things worth reading per unit

n_params_used says how many of the weighted parameters the unit actually had a value for — a unit scored on two dimensions and one scored on seven are not comparable, and this is what tells them apart. n_tied_classes counts the classes sitting at exactly the minimum distance: two or more means the unit fell inside more than one class's box and the label was settled by an arbitrary tie-break rather than by any measurement.

Both differ from uniqueness, which measures how far the runner-up was from the winner. That is a statement about the metric's geometry; these two are statements about what the unit had to be scored on.

Spatial smoothing

Every unit is classified independently of its neighbours, so an isolated cell can carry a label the fabric around it does not — salt-and-pepper at a grain Stewart & Oke never intended a class to be read at. A spatial filter is the standard answer in this literature. It ships disabled, because its threshold has not been calibrated against a reference; switching it on changes labels and makes a run incomparable with one at the defaults.

lczkit.classify.smoothing

A modal filter over the classified units — the minimum mapping unit this package never had.

Every unit is classified independently of its neighbours. Nothing in the pipeline has ever looked at what surrounds a cell, so a 100 m cell whose parameters wobble across a prototype boundary takes a different label from the fabric it sits in, and the result is salt-and-pepper at a grain Stewart & Oke never intended a class to be read at. An LCZ patch is a neighbourhood — the published guidance is a few hundred metres across, and a So2Sat reference patch is 320 m — so a single isolated 1 ha cell is not a claim the scheme can carry.

The LCZ literature's answer is a spatial filter, and it is standard: the LCZ Generator applies one before publishing a map.

It ships disabled, and that is deliberate rather than cautious. min_like_neighbours has not been calibrated against a reference, and every published figure here was measured without a filter. Turning it on moves labels in a run on the strength of a number nobody has measured.

A functionally assigned label is never overwritten. The industrial rule and the semantic rules place a unit on evidence about what is there, not on morphology that might have wobbled, so an isolated LCZ 10 cell in a residential block is a claim about an industrial parcel and not noise. Smoothing it away would silently undo the one part of the classifier that reads the data directly.

DEFAULT_MIN_LIKE_NEIGHBOURS module-attribute

DEFAULT_MIN_LIKE_NEIGHBOURS = 2

Placeholder marking where a swept number goes. Not calibrated — see the module docstring.

A unit with fewer than this many neighbours sharing its label is treated as isolated. Two is the weakest setting that does anything at all on a Queen-contiguous grid, chosen so that a caller who enables the filter without sweeping it does the smallest thing rather than the boldest.

SmoothingReport

Bases: BaseModel

What the modal filter did to one run.

n_relabelled instance-attribute

n_relabelled: int

Units the filter moved. Zero when disabled, and zero on a map with no isolated cells — "never fired" and "never configured" stay distinguishable via enabled.

n_protected instance-attribute

n_protected: int

Units the filter left alone because a rule had placed them. See the module docstring.

modal_filter

modal_filter(units: GeoDataFrame, classification: DataFrame, *, enabled: bool = False, min_like_neighbours: int = DEFAULT_MIN_LIKE_NEIGHBOURS) -> tuple[DataFrame, SmoothingReport]

Replace an isolated unit's label with the most common label among its neighbours.

A unit is isolated when strictly fewer than min_like_neighbours of its contiguous neighbours carry its own label. Isolation is judged on the labels the classifier produced, and every reassignment is computed from that same snapshot rather than applied in sequence, so the result does not depend on the order units are visited and one pass cannot cascade.

Only lcz_primary moves. The distance vector, uniqueness and n_params_used describe the unit's own parameters and remain true of it; overwriting them would make the metric's own output disagree with the label it produced, which is worse than the label being smoothed. label_route records the change.

Neither input is mutated.

Source code in src/lczkit/classify/smoothing.py
def modal_filter(
    units: gpd.GeoDataFrame,
    classification: pd.DataFrame,
    *,
    enabled: bool = False,
    min_like_neighbours: int = DEFAULT_MIN_LIKE_NEIGHBOURS,
) -> tuple[pd.DataFrame, SmoothingReport]:
    """Replace an isolated unit's label with the most common label among its neighbours.

    A unit is *isolated* when strictly fewer than `min_like_neighbours` of its contiguous
    neighbours carry its own label. Isolation is judged on the labels the classifier produced, and
    every reassignment is computed from that same snapshot rather than applied in sequence, so the
    result does not depend on the order units are visited and one pass cannot cascade.

    Only `lcz_primary` moves. The distance vector, `uniqueness` and `n_params_used` describe the
    unit's own parameters and remain true of it; overwriting them would make the metric's own
    output disagree with the label it produced, which is worse than the label being smoothed.
    `label_route` records the change.

    Neither input is mutated.
    """
    out = classification.copy()
    protected = classification["lcz10_rule_applied"].to_numpy(dtype="bool") | classification[
        "semantic_rule_applied"
    ].to_numpy(dtype="bool")
    if not enabled or len(units) < 2:
        return out, SmoothingReport(
            enabled=enabled,
            min_like_neighbours=min_like_neighbours,
            n_units=len(units),
            n_relabelled=0,
            n_protected=0,
        )

    labels = classification["lcz_primary"]
    graph = Graph.build_contiguity(units, rook=False)
    positions = {unit_id: index for index, unit_id in enumerate(units.index)}
    values = labels.to_numpy(dtype="float64")

    replacement = np.full(len(units), np.nan)
    like = np.zeros(len(units), dtype="int64")
    for focal, neighbours in graph.neighbors.items():
        index = positions[focal]
        if np.isnan(values[index]) or not len(neighbours):
            continue
        around = values[[positions[other] for other in neighbours]]
        around = around[~np.isnan(around)]
        if not around.size:
            continue
        like[index] = int((around == values[index]).sum())
        codes, counts = np.unique(around, return_counts=True)
        # Ties break to the lower code, matching `_two_closest` — arbitrary, but the same
        # arbitrary rule the rest of the classifier already uses.
        replacement[index] = codes[counts.argmax()]

    isolated = (like < min_like_neighbours) & ~np.isnan(replacement) & ~np.isnan(values)
    moved = isolated & (replacement != values) & ~protected

    out.loc[moved, "lcz_primary"] = replacement[moved].astype("int64")
    # `ROUTE_SMOOTHED` is in `rules.ROUTES`, so the classifier's categorical already carries it and
    # the column needs no widening — which is what keeps a filtered run schema-identical to an
    # unfiltered one rather than differing by a category.
    out.loc[moved, "label_route"] = rules.ROUTE_SMOOTHED
    return out, SmoothingReport(
        enabled=True,
        min_like_neighbours=min_like_neighbours,
        n_units=len(units),
        n_relabelled=int(moved.sum()),
        n_protected=int((isolated & (replacement != values) & protected).sum()),
    )

Labels and colours

LCZ Generator integer codes (1–10 built, 11–17 for A–G) and the standard Demuzere colour table, so results drop into existing tooling.

lczkit.classify.labels

The 17 Local Climate Zones: integer code, Stewart & Oke label, name, colour.

Integer coding and colours follow Demuzere et al. (2022), so a run's output drops straight into the LCZ Generator's tooling and can be compared against the global map without a translation step. Transcribed from docs/references/tables/demuzere_2022_lcz_codes.md, which a test parses and asserts equal to LCZ_CLASSES — the committed table is the authority, this module is a copy of it that ships in the wheel.

Nothing here is configurable. The codes are an interchange convention and the colours are how every published LCZ map is read; a run that renumbered them would be unreadable by the tools this package exists to feed.

DEMUZERE_2022 module-attribute

DEMUZERE_2022 = '10.5194/essd-14-3835-2022'

Demuzere et al. (2022), ESSD 14, 3835-3873. The coding convention and colour table.

CODES module-attribute

CODES: tuple[int, ...] = tuple(lcz.code for lcz in LCZ_CLASSES)

Every code, ascending. The column order of the 17-way distance vector.

BUILT_CODES module-attribute

BUILT_CODES: tuple[int, ...] = tuple(lcz.code for lcz in LCZ_CLASSES if lcz.label.isdigit())

LCZ 1-10. The types Bernard et al. (2024) apply the closest-distance approach to.

NATURAL_CODES module-attribute

NATURAL_CODES: tuple[int, ...] = tuple(lcz.code for lcz in LCZ_CLASSES if not lcz.label.isdigit())

LCZ A-G, codes 11-17.

NODATA_CODE module-attribute

NODATA_CODE = 0

The published map's nodata value. Never a class, and never written as a label.

COMPACTNESS_AXIS_PAIRS module-attribute

COMPACTNESS_AXIS_PAIRS: tuple[tuple[int, int], ...] = ((1, 4), (2, 5), (3, 6))

Pairs holding the height band fixed and varying compactness.

1 and 4 are both high-rise, 2 and 5 both midrise, 3 and 6 both low-rise; within each pair the compact member differs from the open one in building surface fraction alone (LCZ 2 is 40-70%, LCZ 5 is 20-40%). A disagreement here is evidence about footprint coverage and unit definition - whether the buildings are all present and whether the unit is the right size to hold an LCZ patch - not about height.

HEIGHT_AXIS_PAIRS module-attribute

HEIGHT_AXIS_PAIRS: tuple[tuple[int, int], ...] = ((1, 2), (2, 3), (1, 3), (4, 5), (5, 6), (4, 6))

Pairs holding compactness fixed and varying the height band.

1<->2<->3 among the compact types and 4<->5<->6 among the open ones, which is the axis Stewart & Oke separate on height: >25 m, 10-25 m and <10 m. A disagreement here is evidence about the height estimate, which is why this is the axis that pairs with height_completeness: where heights come from an areal product, error concentrates along it, because such a product cannot resolve those three bands within a heterogeneous unit.

Every pair within each compactness group, not only the adjacent ones. 1<->3 is a high-rise read as low-rise: a height confusion of two full bands rather than one, and the most severe kind. Counting only 1<->2 and 2<->3 would report the axis as quieter than it is.

The two axes are reported separately and under the names that describe them. They are easy to confuse: the compactness pairs hold height fixed and vary building surface fraction.

LczClass dataclass

LczClass(code: int, label: str, name: str, colour: str)

One Local Climate Zone.

code instance-attribute

code: int

Integer code 1-17, as written to the output raster/table.

label instance-attribute

label: str

Stewart & Oke's own label: "1"-"10" for the built types, "A"-"G" for the natural.

name instance-attribute

name: str

Class name, e.g. "Compact high-rise".

colour instance-attribute

colour: str

Lower-case #rrggbb, from the published map's colormap.

lcz

lcz(code: int) -> LczClass

The class with integer code, or a KeyError saying what exists.

Source code in src/lczkit/classify/labels.py
def lcz(code: int) -> LczClass:
    """The class with integer `code`, or a `KeyError` saying what exists."""
    try:
        return _BY_CODE[code]
    except KeyError:
        raise KeyError(f"no LCZ with code {code}; codes are {CODES[0]}-{CODES[-1]}") from None

code_of

code_of(label: str) -> int

The integer code for a Stewart & Oke label such as "3" or "A".

Source code in src/lczkit/classify/labels.py
def code_of(label: str) -> int:
    """The integer code for a Stewart & Oke label such as `"3"` or `"A"`."""
    try:
        return _BY_LABEL[label].code
    except KeyError:
        raise KeyError(f"no LCZ labelled {label!r}; labels are {', '.join(_BY_LABEL)}") from None

legend

legend() -> dict[str, dict[str, str | int]]

The full legend, keyed by code as a string, for the run manifest and the map site.

Source code in src/lczkit/classify/labels.py
def legend() -> dict[str, dict[str, str | int]]:
    """The full legend, keyed by code as a string, for the run manifest and the map site."""
    return {
        str(entry.code): {
            "code": entry.code,
            "label": entry.label,
            "name": entry.name,
            "colour": entry.colour,
        }
        for entry in LCZ_CLASSES
    }