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.lczkitcan 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_partialfor 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 columns are lcz_d1 through lcz_d17, matching the integer codes.
FUNCTIONAL_ONLY_CODE
module-attribute
¶
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
¶
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
classify
¶
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
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | |
describe
¶
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
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
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
¶
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
¶
as_dict
¶
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
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
¶
Indexed like the input, one column per LCZ code, in the order codes was given.
n_params_used
instance-attribute
¶
Dimensions that carried non-zero weight and a non-null value, per unit.
n_params_available
instance-attribute
¶
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
¶
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
indistinguishable
¶
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
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
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
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
uniqueness
¶
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
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), BAMS 93(12), 1879-1900, Table 3.
LCZKIT
module-attribute
¶
Not from any publication. See docs/references/tables/lczkit_natural_class_ranges.md.
DEFAULT_DOMINANT_FRACTION
module-attribute
¶
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
¶
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
¶
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
¶
Property name as it appears in the transcribed table, lower-cased and underscored.
column
instance-attribute
¶
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
¶
Unit the transcribed values are in: "percent", "m", "fraction" or "class".
scale
instance-attribute
¶
Multiplier converting a transcribed value into the column's unit. 0.01 for the three percentage properties, 1.0 elsewhere.
reads_building_height
class-attribute
instance-attribute
¶
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.
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
ranges_for
¶
column -> (lo, hi) for one class, omitting dimensions it does not constrain.
Source code in src/lczkit/classify/prototypes.py
property_of
¶
property_of(column: str) -> PropertySpec
The PropertySpec whose column is column.
Source code in src/lczkit/classify/prototypes.py
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 et al. (2024), GMD 17, 2077-2107, Sect. 2.5.
WeightPreset
dataclass
¶
A named pair of weight vectors, one per LCZ family.
preset
¶
preset(name: str) -> WeightPreset
The preset called name, or a KeyError naming what exists.
Source code in src/lczkit/classify/weights.py
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.
ROUTE_SEMANTIC
module-attribute
¶
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
¶
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
¶
The two nearest prototypes and their distances, per unit.
family_of
¶
"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
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
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
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
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
¶
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
¶
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
¶
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
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 et al. (2022), ESSD 14, 3835-3873. The coding convention and colour table.
CODES
module-attribute
¶
Every code, ascending. The column order of the 17-way distance vector.
BUILT_CODES
module-attribute
¶
LCZ 1-10. The types Bernard et al. (2024) apply the closest-distance approach to.
NATURAL_CODES
module-attribute
¶
LCZ A-G, codes 11-17.
NODATA_CODE
module-attribute
¶
The published map's nodata value. Never a class, and never written as a label.
COMPACTNESS_AXIS_PAIRS
module-attribute
¶
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
¶
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
¶
One Local Climate Zone.
label
instance-attribute
¶
Stewart & Oke's own label: "1"-"10" for the built types, "A"-"G" for the natural.
lcz
¶
lcz(code: int) -> LczClass
The class with integer code, or a KeyError saying what exists.
Source code in src/lczkit/classify/labels.py
code_of
¶
The integer code for a Stewart & Oke label such as "3" or "A".
Source code in src/lczkit/classify/labels.py
legend
¶
The full legend, keyed by code as a string, for the run manifest and the map site.