Skip to content

Configuration

One pydantic model, serialised verbatim into every run's manifest alongside the pinned Overture release, the Google Earth Engine collection identifiers and date ranges, the resolved versions of momepy, neatnet and geopandas, and a run timestamp.

DATA_DIR is resolved once, here. No other module reads os.environ, builds a path relative to __file__, or joins its own paths — sources ask for settings.source_dir(name) instead. A missing or unreachable DATA_DIR fails at config load, which is a much better place to find out than three stages in.

Almost every field on this page carries its own docstring giving the value's provenance: a published threshold and its paper, or a fixture measurement and what it separates. Where a threshold was swept rather than chosen, the docstring says which sweep and at what operating point.

Two options ship switched off, and why

A threshold in this package is swept against a reference and chosen at an operating point, never picked because it looks reasonable. Anything whose threshold has not been swept ships disabled, and two options are currently in that state:

  • ucp.measure_on = "enclosures" measures the parameters on street-bounded blocks and transfers them to the units being classified. A street canyon has to be measured against streets, and a grid cell is bounded by none — on one Istanbul extent the aspect ratio is missing from 10.8% of built grid cells against 0.9% of enclosures. Default "units".
  • classification.modal_filter replaces an isolated unit's label with the one most of its neighbours carry — a minimum mapping unit, standard in this literature. Default off.

Switching either on changes labels, so a run with one enabled is not comparable with a run at the defaults until that threshold has been calibrated.

lczkit.config

Pydantic configuration model for lczkit runs.

DATA_DIR is resolved exactly once, here, via Settings.load(). Every other module reaches data through settings.input_dir, settings.output_dir, settings.source_dir(name) and settings.run_dir; nothing else reads os.environ or builds a path from __file__ or the working directory.

The whole model is serialised verbatim into each run's manifest, so every threshold a run used is part of its record. Thresholds with no published value (cleaning limits, height confidences) default to None and raise when used: lczkit.presets supplies the measured values.

HeightProduct module-attribute

HeightProduct = Annotated[Wsf3dProduct | GhslProduct | OpenBuildings25dProduct, Field(discriminator='kind')]

Where an areal tier's raster comes from. See lczkit.sources.height_products.

NodataPolicy module-attribute

NodataPolicy = Literal['exclude', 'assign']

What a nodata cell means for a land-cover product.

"exclude": no observation, so the cell leaves the denominator. "assign": the product masks this surface on purpose, so the cell counts towards a named class. ETH canopy height sets built-up, snow, ice and water to 255; reading those as "exclude" reports central Berlin as ~96% tree cover instead of ~22%.

UnmappedPolicy module-attribute

UnmappedPolicy = Literal['exclude', 'assign', 'raise']

What to do with a raster value no class mapping covers. "raise" (the default) because an unmapped value means the mapping does not match the product on disk.

GeeAssetType module-attribute

GeeAssetType = Literal['image_collection', 'image']

Whether an Earth Engine asset is a collection to filter and mosaic, or a single image.

LandCoverBackend module-attribute

LandCoverBackend = Literal['local', 'gee']

Which RasterSource reduces the land cover for a run.

"local" mosaics the ESA WorldCover tiles the extent spans and reduces them with exactextract; "gee" reduces the configured Earth Engine asset server-side with reduceRegions. The tables are schema-identical but not bit-identical: exactextract weights partial cells, reduceRegions counts whole pixels by centre, about a one-percent difference on a 100 m unit.

RasterFormat module-attribute

RasterFormat = Literal['gtiff', 'cog', 'zarr']

Morphometrics raster format.

"gtiff" is a plain GeoTIFF; "cog" the same file tiled with overviews for partial HTTP reads. "zarr" is written through xarray/rioxarray rather than GDAL's Zarr driver, which stores the CRS only in a GDAL pam.aux.xml sidecar that a plain zarr/xarray reader cannot see.

OvertureConfig

Bases: BaseModel

Configuration for OvertureSource, the Overture Maps vector source.

release class-attribute instance-attribute

release: str | None = None

Pinned Overture release, e.g. "2026-07-22.0". Never "latest": OvertureSource raises if this is unset.

source_dir_name class-attribute instance-attribute

source_dir_name: str = 'Overture_Maps'

Subdirectory under input/ that OvertureSource caches into.

duckdb_threads class-attribute instance-attribute

duckdb_threads: int | None = None

Threads each DuckDB connection may use; None is DuckDB's default (every core).

DuckDB ignores OMP_NUM_THREADS, so inside a worker pool this is the only way to keep N workers from using N x (host cores) threads during ingestion.

CleaningConfig

Bases: BaseModel

Thresholds for the building and street cleaning pipeline.

Majer & Fleischmann (arXiv:2603.00132) Supplementary D describes these operations only qualitatively, so there are no literature defaults: every field is None and the cleaning pipeline raises if it is used unset. lczkit.presets holds the fixture-derived values.

building_max_area_m2 class-attribute instance-attribute

building_max_area_m2: float | None = None

Footprints larger than this are dropped as implausible.

building_min_area_m2 class-attribute instance-attribute

building_min_area_m2: float | None = None

On buildings_topo, footprints smaller than this are dissolved into a touching neighbour. Those touching nothing are kept: small is not spurious.

building_road_buffer_m class-attribute instance-attribute

building_road_buffer_m: float | None = None

Half-width of the road buffer the buildings_topo street rule measures against, in metres. Fixture-derived: 4.0 m separates perimeter blocks from structures standing in the roadway.

building_road_overlap_limit class-attribute instance-attribute

building_road_overlap_limit: float | None = None

Share of a footprint inside the road buffer above which it is dropped rather than trimmed. Fixture-derived (0.5); re-derive for a city whose road generalisation differs.

building_merge_limit_m2 class-attribute instance-attribute

building_merge_limit_m2: float | None = None

geoplanar.merge_overlaps' merge_limit: overlapping polygons smaller than this are merged into a neighbour regardless of overlap size.

building_overlap_limit class-attribute instance-attribute

building_overlap_limit: float | None = None

geoplanar.merge_overlaps' overlap_limit: larger polygons merge only if the shared overlap exceeds this fraction of their area.

street_tile_size_m class-attribute instance-attribute

street_tile_size_m: float | None = None

Tile edge for chunked street simplification, in metres. None runs neatnet over the whole extent, which stops completing in usable time above roughly 50 km².

street_tile_buffer_m class-attribute instance-attribute

street_tile_buffer_m: float | None = None

Margin simplified around each tile core, in metres. Seam artefacts fall sharply up to ~600 m and not beyond; below ~300 m a dual carriageway no longer fits in the buffer.

street_tile_workers class-attribute instance-attribute

street_tile_workers: int | None = None

Processes to run tiles across. None uses every core the process may use.

street_artifact_threshold class-attribute instance-attribute

street_artifact_threshold: float | None = None

Face-artifact index separating road artefacts from ordinary fabric. None derives it from the data (pooled across tiles on the tiled path); setting it pins the value for an A/B.

Wsf3dProduct

Bases: BaseModel

WSF-3D V02 building height (DLR, TanDEM-X), CC-BY-4.0: one global tiled GeoTIFF.

Int16 decimetres, nodata -32767 (README_BuildingHeight.txt), hence the tier's scale=0.1. Not in the Earth Engine catalogue (DLR/WSF holds only a 10 m settlement mask), so it is a one-off 2.1 GB HTTP download read by window thereafter.

GhslProduct

Bases: BaseModel

GHS-BUILT-H ANBH R2023A (JRC), free reuse with attribution: 1000 km Mollweide tiles.

ANBH (BUVOL / BUSURF) is the mean height of the built fabric rather than the gross AGBH averaged over open ground. Float32 metres, nodata 255, 100 m (GHSL Data Package 2023, p. 36). Also in Earth Engine as JRC/GHSL/P2023A/GHS_BUILT_H/2018, with identical values, so it is fetched over HTTP and needs no credential.

OpenBuildings25dProduct

Bases: BaseModel

Google Open Buildings 2.5D Temporal v1, CC-BY-4.0, exported per window from Earth Engine.

Metres above terrain at an effective 4 m, annual 2016-2023, over Africa, South and South-East Asia, Latin America and the Caribbean. No public bucket exists. The request caps are Earth Engine's own.

year class-attribute instance-attribute

year: int = 2023

Pinned epoch, never "latest".

scale_m class-attribute instance-attribute

scale_m: float = 4.0

The product's effective resolution; the 0.5 m serving grid carries no more detail.

ArealTierConfig

Bases: BaseModel

One areal raster tier of the height cascade: where its raster comes from and how to read it.

Areal products assign a neighbourhood mean to each building, a categorically weaker measurement than a per-building height, and the output tags every building with the tier that resolved it.

name instance-attribute

name: str

The tier's height_source tag, e.g. "ghsl". Unique within a cascade.

source_dir_name instance-attribute

source_dir_name: str

Subdirectory under input/ holding this product.

enabled class-attribute instance-attribute

enabled: bool = True

Whether the tier takes part in the cascade. Distinct from filename is None (product not available): a tier measured and switched off must stay distinguishable from one never placed.

filename class-attribute instance-attribute

filename: str | None = None

Raster filename within input/<source_dir_name>/. Filled per study area by lczkit.sources.height_products.resolve_areal_tiers when product is set; set it by hand for a raster you placed yourself. None skips the tier.

product class-attribute instance-attribute

product: HeightProduct | None = None

How to fetch the raster. None means it is placed by hand.

band class-attribute instance-attribute

band: int = 1

1-based raster band carrying height.

scale class-attribute instance-attribute

scale: float = 1.0

Multiplier from raw raster values to metres (0.1 for a decimetre product).

nodata class-attribute instance-attribute

nodata: float | None = None

Override the raster's declared nodata. None uses the file's own.

min_height_m class-attribute instance-attribute

min_height_m: float = 0.0

Values at or below this read as "no building here" and pass to the next tier.

Zero for every shipped tier, Open Buildings 2.5D included. Its damage is within-unit dispersion (CV 0.441 against reality's 0.195), which a floor on one tail does not fix.

confidence class-attribute instance-attribute

confidence: float | None = None

height_confidence for every building this tier resolves. No default: see HeightConfig.

HeightConfig

Bases: BaseModel

The building-height cascade: Overture attributes first, then areal_tiers in list order.

Adding an areal product is an entry in areal_tiers, not a code change. The confidences have no default because no published number defines them: height_confidence is an ordinal ranking, and an invented default would put a quality claim nobody chose into every manifest.

storey_height_m class-attribute instance-attribute

storey_height_m: float = 3.0

Metres per storey for the num_floors fallback. Varies regionally; set it per city where known.

overture_height_confidence class-attribute instance-attribute

overture_height_confidence: float | None = None

height_confidence for Overture's height, unless Overture supplies its own per-building confidence, which is preferred.

overture_num_floors_confidence class-attribute instance-attribute

overture_num_floors_confidence: float | None = None

height_confidence for heights derived as num_floors x storey_height_m.

areal_tiers class-attribute instance-attribute

areal_tiers: list[ArealTierConfig] = Field(default_factory=_default_areal_tiers)

Areal tiers in cascade order.

GeeAssetConfig

Bases: BaseModel

Earth Engine coordinates for one land-cover dataset, recorded in the run manifest.

collection_id class-attribute instance-attribute

collection_id: str | None = None

Full asset ID, e.g. "ESA/WorldCover/v200". None: no verified asset, and EarthEngineSource refuses to guess one.

band class-attribute instance-attribute

band: str | None = None

Band name within the asset, e.g. "Map".

start_date class-attribute instance-attribute

start_date: str | None = None

Inclusive ISO date for filterDate. Required for a collection; recorded for an image.

end_date class-attribute instance-attribute

end_date: str | None = None

Exclusive ISO date for filterDate. Same caveat as start_date.

scale_m class-attribute instance-attribute

scale_m: float | None = None

Reduction scale in metres. Match the native resolution: a coarser one resamples silently.

required_fields

required_fields() -> tuple[str, ...]

Fields EarthEngineSource cannot run without, given this asset's kind.

Source code in src/lczkit/config.py
def required_fields(self) -> tuple[str, ...]:
    """Fields `EarthEngineSource` cannot run without, given this asset's kind."""
    common = ("collection_id", "band", "scale_m")
    if self.asset_type == "image":
        return common
    return (*common, "start_date", "end_date")

LandCoverDatasetConfig

Bases: BaseModel

One land-cover product and the mapping from its raw values to fraction classes.

LocalRasterSource and EarthEngineSource read the same instance, which is what makes the two backends schema-identical. A dataset is either categorical (value_classes) or binned (bins + bin_classes), never both; classes are disjoint and sum to 1.0 over the cells that count.

name instance-attribute

name: str

Short identifier, unique within LandCoverConfig.datasets.

source_dir_name instance-attribute

source_dir_name: str

Subdirectory under input/ holding this product.

filename class-attribute instance-attribute

filename: str | None = None

COG filename within input/<source_dir_name>/; None if not available locally.

band class-attribute instance-attribute

band: int = 1

1-based band to read from the local COG.

column_prefix class-attribute instance-attribute

column_prefix: str = 'frac_'

Prefixed to every class name to form output columns, so two datasets that both emit tree can share a units table.

classes instance-attribute

classes: list[str]

The full, ordered output class list. A class with no cells still gets a 0.0 column, so two cities produce the same schema.

value_classes class-attribute instance-attribute

value_classes: dict[int, str] | None = None

Categorical mapping from raw value to class name.

bins class-attribute instance-attribute

bins: list[float] | None = None

Ascending breakpoints for a continuous product: v falls in bin i where bins[i-1] <= v < bins[i].

bin_classes class-attribute instance-attribute

bin_classes: list[str] | None = None

Class per bin, lowest first; len(bins) + 1 entries.

nodata class-attribute instance-attribute

nodata: float | None = None

Override the raster's declared nodata. None uses the file's own.

nodata_class class-attribute instance-attribute

nodata_class: str | None = None

Class nodata counts towards; required exactly when nodata_policy is "assign".

unmapped_class class-attribute instance-attribute

unmapped_class: str | None = None

Class unmapped values count towards; required exactly when unmapped_policy is "assign".

gee class-attribute instance-attribute

gee: GeeAssetConfig = Field(default_factory=GeeAssetConfig)

Earth Engine coordinates for the same product.

LandCoverConfig

Bases: BaseModel

The land-cover fraction sources.

source class-attribute instance-attribute

source: LandCoverBackend = 'local'

Which backend supplies the fractions. "local" needs nothing but HTTP and is what CI tests. Choose "gee" for a dataset with no local product, or a window too large for max_raster_cells. The two have not been benchmarked against each other for speed.

datasets class-attribute instance-attribute

datasets: list[LandCoverDatasetConfig] = Field(default_factory=_default_land_cover_datasets)

The configured products, by name.

gee_project class-attribute instance-attribute

gee_project: str | None = None

Google Cloud project Earth Engine bills against. Read from GEE_PROJECT_NAME.

gee_batch_size class-attribute instance-attribute

gee_batch_size: int = 2000

Units per reduceRegions call, keeping each request under Earth Engine's limits.

gee_max_units class-attribute instance-attribute

gee_max_units: int | None = None

Refuse an Earth Engine run over more than this many units. None means no ceiling.

max_raster_cells class-attribute instance-attribute

max_raster_cells: int = 200000000

Refuse a local read whose window exceeds this many cells (~450 x 450 km at 10 m).

dataset

dataset(name: str) -> LandCoverDatasetConfig

The configured dataset called name, or a KeyError naming what is available.

Source code in src/lczkit/config.py
def dataset(self, name: str) -> LandCoverDatasetConfig:
    """The configured dataset called `name`, or a `KeyError` naming what is available."""
    for dataset in self.datasets:
        if dataset.name == name:
            return dataset
    available = ", ".join(repr(d.name) for d in self.datasets) or "(none configured)"
    raise KeyError(f"no land-cover dataset named {name!r}; configured: {available}")

SemanticGroupConfig

Bases: BaseModel

One functional group of Overture attribute values, and the LCZ class it is evidence for.

Transcribed from docs/references/tables/overture_lcz_semantic_mapping.md, which a test parses and asserts against. A building matches on subtype or class. Groups are not a partition: retail is evidence for both large-low-rise form and commercial function.

lcz_hint class-attribute instance-attribute

lcz_hint: str = ''

The LCZ class this group is evidence for. Documentation only; nothing keys off it.

UcpConfig

Bases: BaseModel

The urban canopy parameters.

Which land-cover classes and Overture values feed which parameter, plus momepy's street-profile settings restated so they reach the manifest.

street_profile_distance_m class-attribute instance-attribute

street_profile_distance_m: float = 10.0

Spacing of momepy.street_profile()'s perpendicular ticks. momepy's default.

street_profile_tick_length_m class-attribute instance-attribute

street_profile_tick_length_m: float = 50.0

Tick length; also the width reported for a street no building walls. momepy's default.

min_building_height_m class-attribute instance-attribute

min_building_height_m: float = 0.1

Floor applied to heights before taking logs for the geometric mean, so one zero-height row cannot take a unit's Hr to zero. A numerical guard, below any plausible building.

measure_on class-attribute instance-attribute

measure_on: Literal['units', 'enclosures'] = 'units'

Units the parameters are measured on, as distinct from classified on.

"enclosures" measures on street-bounded blocks and moves the result to the target units, area-weighted. It exists for aspect_ratio, which a grid cell no street crosses cannot measure: null on 10.8% of one Istanbul extent's built grid cells against 0.9% of its enclosures. Not calibrated against a reference, so a run with it on is not comparable with the defaults.

land_cover_dataset class-attribute instance-attribute

land_cover_dataset: str = 'worldcover'

Which LandCoverConfig.datasets entry supplies the surface fractions.

pervious_classes class-attribute instance-attribute

pervious_classes: list[str] = Field(default_factory=lambda: ['pervious'])

Pervious before tree and water are folded in; see lczkit.ucp.surface.

impervious_classes class-attribute instance-attribute

impervious_classes: list[str] = Field(default_factory=lambda: ['impervious'])

Impervious, roofs included; the building share is subtracted in lczkit.ucp.surface.

industrial_building_subtypes class-attribute instance-attribute

industrial_building_subtypes: list[str] = Field(default_factory=lambda: ['industrial'])

Overture building subtype values counting as industrial.

industrial_building_classes class-attribute instance-attribute

industrial_building_classes: list[str] = Field(default_factory=lambda: ['industrial'])

Overture building class values counting as industrial. warehouse is excluded on purpose: it is the LCZ 8 case the LCZ 10 rule must keep apart.

industrial_land_use_subtypes class-attribute instance-attribute

industrial_land_use_subtypes: list[str] = Field(default_factory=list)

Overture land-use subtype values counting as industrial. Empty: industrial parcels sit under developed, which also covers commercial and retail.

industrial_land_use_classes class-attribute instance-attribute

industrial_land_use_classes: list[str] = Field(default_factory=lambda: ['industrial'])

Overture land-use class values counting as industrial. brownfield is excluded: it says what a parcel was, not what it does.

semantic_groups class-attribute instance-attribute

semantic_groups: list[SemanticGroupConfig] = Field(default_factory=_default_semantic_groups)

Functional groups read from Overture's subtype and class. Set [] to skip the layer.

Independent of the industrial_* lists above, which feed the column the LCZ 10 threshold was calibrated on; heavy_industry is a slightly wider vocabulary reported beside it.

SemanticRuleConfig

Bases: BaseModel

A functional assignment rule: a unit over min_fraction of column takes lcz.

The displaced label is kept as lcz_secondary. A rule ships enabled only once its threshold has been swept against a reference; reason records the measurement either way.

column instance-attribute

column: str

The parameter column to threshold, e.g. sem_lightweight_buildings_of_building_area.

max_mean_building_area_m2 class-attribute instance-attribute

max_mean_building_area_m2: float | None = None

Optional gates on mean_building_area_m2, which a rule may read although the metric does not weight it.

reason class-attribute instance-attribute

reason: str = ''

Why the rule exists and how it was calibrated, for the manifest.

ClassificationConfig

Bases: BaseModel

The prototype-distance classifier. Every threshold it applies lives here.

weight_preset class-attribute instance-attribute

weight_preset: str = 'bernard2024_partial'

Entry in lczkit.classify.weights.PRESETS.

"bernard2024_partial" is Bernard et al. (2024)'s built-type default with the dimensions this package cannot compute left out: SVF (4) and z0 (0.5) are not computed, so 17 of the published 21.5 weight units apply and building surface fraction carries ~47% of the metric. "equal" is the uniform comparison.

built_min_building_fraction class-attribute instance-attribute

built_min_building_fraction: float = 0.1

Building surface fraction at or above which a unit is classified as a built type. Stewart & Oke's table draws this line itself: every built class is at least 10%, every natural class at most 10%.

reachable_natural_classes class-attribute instance-attribute

reachable_natural_classes: list[str] = Field(default_factory=lambda: ['A', 'B', 'D', 'E', 'G'])

Natural classes the classifier may assign, by Stewart & Oke label.

C is excluded because where nothing is built it ties with D on every computed parameter. F is not excluded by this list but by arithmetic: D's prototype box contains F's in every dimension, so adding "F" here cannot make it reachable. Distances to both are still reported.

natural_dominant_fraction class-attribute instance-attribute

natural_dominant_fraction: float = 0.5

Tree or water cover at which a unit reads as LCZ A or G. lczkit's own; see docs/references/tables/lczkit_natural_class_ranges.md.

natural_negligible_fraction class-attribute instance-attribute

natural_negligible_fraction: float = 0.1

Tree or water cover a natural class treats as absent (Stewart & Oke's 10% boundary).

semantic_rules class-attribute instance-attribute

semantic_rules: list[SemanticRuleConfig] = Field(default_factory=_default_semantic_rules)

Functional rules read off Overture's attributes, applied after the LCZ 10 rule.

lcz10_industrial_column class-attribute instance-attribute

lcz10_industrial_column: str = 'industrial_fraction_of_building_area'

The industrial share the LCZ 10 rule reads: Bernard et al. (2024)'s FIND/B, so their published 0.33 transfers to it. The threshold below was calibrated on this column.

lcz10_min_industrial_fraction class-attribute instance-attribute

lcz10_min_industrial_fraction: float = 0.45

lcz10_industrial_column above which a unit is LCZ 10.

Swept over nineteen settings against the Rotterdam reference; 0.45 is the precision maximum and Bernard's 0.33 performs comparably. Precision is roughly flat (16.7-23.2%) across the range, so this sets how much of the map carries LCZ 10 more than how often the label is right: Overture cannot tell heavy from light industry.

A threshold only. Bernard et al. (2024) Sect. 2.3 also require the industrial share to exceed the residential and large-low-rise shares; that condition is not applied, and the sweep was run without it. The rule also fires whatever the family gate decided, so a nearly unbuilt cell holding one industrial building reads 1.0 and becomes LCZ 10.

lcz1_min_height_m class-attribute instance-attribute

lcz1_min_height_m: float | None = None

Optional Hr floor below which LCZ 1 is not assignable. Bernard et al. (2024) apply the equivalent on storey counts; lczkit has no reliable storey count, so this is off.

modal_filter class-attribute instance-attribute

modal_filter: bool = False

Replace an isolated unit's label with its neighbours' modal label.

Off: the minimum-neighbour threshold has not been swept, and every stored figure was measured without a filter. See lczkit.classify.smoothing.

modal_filter_min_like_neighbours class-attribute instance-attribute

modal_filter_min_like_neighbours: int = 2

A unit with fewer contiguous neighbours sharing its label is isolated. A placeholder for a swept value, set to the weakest setting that does anything.

OutputConfig

Bases: BaseModel

What a run writes into output/lczkit/<run_id>/.

break_count class-attribute instance-attribute

break_count: int = 7

Classification breaks precomputed per continuous variable, for the map site.

break_method class-attribute instance-attribute

break_method: Literal['quantile'] = 'quantile'

How the breaks are derived. Only quantiles are implemented.

viz_significant_figures class-attribute instance-attribute

viz_significant_figures: int = 3

Significant figures floats are rounded to in units_viz.parquet.

viz_distance_scale class-attribute instance-attribute

viz_distance_scale: int = 1000

Multiplier applied to the 17 distances before they are stored as int16.

gis_format class-attribute instance-attribute

gis_format: Literal['none', 'gpkg'] = 'gpkg'

Also write the unit table as units.gpkg.

units.parquet is valid GeoParquet with its CRS, but GDAL's Parquet driver is optional, so some QGIS builds open it without a CRS. GeoPackage is in GDAL's core. Costs 1.8 s and 67 MB at 116 491 units.

VizConfig

Bases: BaseModel

The static map site lczkit.viz.build_site writes into a run.

Zoom ranges are recorded because a tileset is only reproducible if the zooms that made it are.

unit_max_zoom class-attribute instance-attribute

unit_max_zoom: int = 14

Unit tileset zooms. A 100 m cell is ~21 px at z14; MapLibre overzooms beyond.

basemap_max_zoom class-attribute instance-attribute

basemap_max_zoom: int = 13

One zoom short of the units: the basemap sits under a translucent fill, and dropping z14 halved a measured land-use tileset.

basemap_simplification class-attribute instance-attribute

basemap_simplification: int = 10

tippecanoe --simplification for the basemap only. Unit and building tilesets are left at tippecanoe's faithful default.

basemap_layers class-attribute instance-attribute

basemap_layers: list[str] = Field(default_factory=lambda: ['water', 'streets'])

Persisted context layers to draw, in order. "land_use" is available and off: it was 94% of the basemap's bytes at 9 km².

include_buildings class-attribute instance-attribute

include_buildings: bool = False

Tile the building footprints for extrusion. Off: at metropolitan scale they cost two to three times the rest of the site.

render_columns class-attribute instance-attribute

render_columns: list[str] = Field(default_factory=lambda: ['lcz_primary', 'lcz_secondary', 'uniqueness', 'height_completeness', 'building_surface_fraction', 'impervious_surface_fraction', 'pervious_surface_fraction', 'tree_fraction', 'water_fraction', 'height_of_roughness_elements_m', 'aspect_ratio', 'street_openness', 'mean_building_area_m2', 'industrial_fraction_of_building_area'])

Attributes tiled at every zoom, for choropleths. Everything else rides in a detail tileset at maximum zoom only, because MVT repeats a feature's attributes in every tile at every zoom. Names a run did not produce are skipped.

render_column_prefixes class-attribute instance-attribute

render_column_prefixes: list[str] = Field(default_factory=lambda: ['height_frac_'])

Column families tiled at every zoom, by prefix: the height tier fractions are named after whichever cascade ran.

detail_max_features class-attribute instance-attribute

detail_max_features: int = 200000

Above this many units the detail tileset is skipped and the sidebar shows render attributes only.

online_basemaps class-attribute instance-attribute

online_basemaps: list[str] = Field(default_factory=list)

Remote raster grounds offered in the site's picker, by key from lczkit.viz.basemaps.PROVIDERS.

Empty by default, so a library-built site names no remote host and works offline years from now. The command line offers the keyless grounds unless told --basemap none.

maptiler_key class-attribute instance-attribute

maptiler_key: str | None = Field(default=None, exclude=True)

MapTiler API key, from MAPTILER_API_KEY.

Excluded from serialisation so it never reaches the manifest (which the site copies); it reaches only style.json's tile URLs, where the browser needs it in plain text.

UnitsConfig

Bases: BaseModel

Which spatial units the pipeline classifies.

No automatic choice, by region or otherwise: the trade-off is documented and the strategy that ran is recorded in the manifest.

strategy class-attribute instance-attribute

strategy: UnitStrategy = 'grid'

grid (default), enclosure or patch.

  • grid: 100 m cells, what every LCZ map, validation dataset and WRF workflow uses.
  • enclosure: street-, rail- and water-bounded blocks. Over fifteen cities built-class agreement was +3.8 points and overall −0.2, so not adopted as the default.
  • patch: enclosures merged to LCZ-patch scale; a block (median 0.04 ha) is much smaller than a WUDAPT patch (2.2-52 ha). See lczkit.units.patches.

cell_size_m class-attribute instance-attribute

cell_size_m: float = 100.0

Grid cell side. Other sizes are comparable with no published figure or reference.

patch_min_area_m2 class-attribute instance-attribute

patch_min_area_m2: float = 50000.0

patch only. A floor, not a centre: the median lands near twice it.

patch_max_area_m2 class-attribute instance-attribute

patch_max_area_m2: float | None = 500000.0

patch only. Oversized seeds are split to this before merging; None removes it.

patch_merge_on_morphology class-attribute instance-attribute

patch_merge_on_morphology: bool = True

patch only: merge towards the neighbour most similar in building surface fraction and height, rather than on size alone.

drop_pedestrian_barriers class-attribute instance-attribute

drop_pedestrian_barriers: bool = True

Leave footway/steps/path/cycleway/bridleway out of the barrier set for enclosure and patch. They are 50-73% of the mapped network in Berlin, Hong Kong and Milan and 3.5-7.5% elsewhere, so keeping them measures footpath survey effort rather than the city.

WudaptConfig

Bases: BaseModel

The WUDAPT LCZ training areas: the globally available hand-labelled reference.

Note these polygons are the training data behind the Demuzere global map, so agreement between the two is not an independent ceiling. See lczkit.validation.wudapt.

source_dir_name class-attribute instance-attribute

source_dir_name: str = 'WUDAPT'

Subdirectory under input/ holding the WUDAPT export.

filename class-attribute instance-attribute

filename: str | None = None

The dated LCZ Generator export, e.g. LCZ-Generator_training_areas_2024-10-01.gpkg. Must be pinned: contributors keep adding to it, so an unpinned name changes the reference.

layer class-attribute instance-attribute

layer: str | None = None

GeoPackage layer; None takes the first, which is right for the published export.

class_column class-attribute instance-attribute

class_column: str = 'class'

WUDAPT's LCZ column, coded 1-17 as in Demuzere.

require_qc class-attribute instance-attribute

require_qc: bool = False

Keep only polygons passing all three QC flags. Off: it halves the reference and moved agreement with So2Sat by +0.4, +2.9 and −1.9 points on Cairo, Mumbai and Jakarta.

min_oa class-attribute instance-attribute

min_oa: float | None = None

Minimum LCZ Generator accuracy of the polygon's submission. Off: at 0.7 it made agreement with So2Sat worse on all three test cities, because oa scores a submission against itself.

min_area_m2 class-attribute instance-attribute

min_area_m2: float = 0.0

Drop polygons below this area.

max_area_m2 class-attribute instance-attribute

max_area_m2: float | None = None

Drop polygons above this area. The largest is an 18 680 km² sea.

citation class-attribute instance-attribute

citation: str = '10.3390/ijgi4010199'

Bechtel et al. (2015), IJGI 4(1), 199-219: the WUDAPT Level 0 protocol.

ValidationConfig

Bases: BaseModel

Agreement against reference LCZ maps, reported per class and as a confusion matrix.

reference class-attribute instance-attribute

reference: LandCoverDatasetConfig = Field(default_factory=_default_reference_dataset)

The Demuzere global map, described as a categorical raster.

reference_citation class-attribute instance-attribute

reference_citation: str = '10.5194/essd-14-3835-2022'

Demuzere et al. (2022). The file in use is lcz_v3.tif, a later version than the paper describes; the two are recorded separately.

ground_truth_citation class-attribute instance-attribute

ground_truth_citation: str = '10.1109/MGRS.2020.2964708'

Zhu et al. (2020), So2Sat LCZ42: hand labels, and the primary reference where they exist. lcz_v3 is a model with its own error and only a comparator.

wudapt class-attribute instance-attribute

wudapt: WudaptConfig = Field(default_factory=WudaptConfig)

The WUDAPT training areas.

min_reference_coverage class-attribute instance-attribute

min_reference_coverage: float = 0.5

Share of a unit the reference must cover for the unit to be scored.

height_completeness_deciles class-attribute instance-attribute

height_completeness_deciles: int = 10

Equal-width strata for the height-completeness breakdown.

MorphometricsConfig

Bases: BaseModel

The 2D morphometrics stage (Majer & Fleischmann 2026), a descriptive output only.

Computed on enclosed tessellation cells and written as morphometrics.parquet (and a raster if raster_resolution_m is set); never joined to units.parquet or read by the classifier. Off by default because its cost rises steeply with extent. The neighbourhood scales are the paper's and live in lczkit.morphometrics.compute, since the registry names every column after them.

tessellation_shrink class-attribute instance-attribute

tessellation_shrink: float = 0.4

momepy.enclosed_tessellation shrink; momepy's default.

tessellation_segment class-attribute instance-attribute

tessellation_segment: float = 0.5

momepy.enclosed_tessellation segment; momepy's default.

tessellation_threshold class-attribute instance-attribute

tessellation_threshold: float | None = 0.05

momepy.enclosed_tessellation threshold; momepy's default.

tessellation_n_jobs class-attribute instance-attribute

tessellation_n_jobs: int = -1

Processes momepy.enclosed_tessellation may spawn; -1 is every core. Set 1 inside your own process pool: thread pinning does not cap processes, and 8 workers left at -1 measured 2 923 processes on a 256-core node.

street_profile_distance_m class-attribute instance-attribute

street_profile_distance_m: float = 10.0

Tick spacing for the 2D street profile. Separate from UcpConfig's so tuning one does not retune the other.

contextual class-attribute instance-attribute

contextual: bool = False

Add the 25th/50th/75th percentile of each attribute over neighbouring cells. Unlike the paper, the primary attributes are kept alongside.

contextual_steps class-attribute instance-attribute

contextual_steps: int = 3

Topological steps the contextual neighbourhood spans.

max_tessellation_cells class-attribute instance-attribute

max_tessellation_cells: int = 50000

Refuse the stage above this many cells. Measured on Berlin: 51 s at 5 406 cells and 1 029 s at 12 322, so the ceiling is set well below any extrapolation.

max_contextual_cells class-attribute instance-attribute

max_contextual_cells: int = 20000

A tighter ceiling for the contextual expansion, whose graph is denser.

raster_resolution_m class-attribute instance-attribute

raster_resolution_m: float | None = None

Rasterise the attributes at this resolution; None skips. lczkit morphometrics raster regenerates it later at another resolution.

max_raster_cells class-attribute instance-attribute

max_raster_cells: int = 50000000

Refuse a raster grid larger than this.

raster_tile_deg class-attribute instance-attribute

raster_tile_deg: float | None = None

Split the raster into a geographic tile grid of this size, named grid_{lon:.2f}_{lat:.2f} after each tile's centre as geotessera does. None writes one file. Empty tiles are dropped.

max_raster_bytes class-attribute instance-attribute

max_raster_bytes: int = 4000000000

Refuse a band stack (bands x rows x cols x 4 bytes) above this. An arithmetic memory ceiling, not a measured one.

Settings

Bases: BaseModel

Resolved configuration for one run. Construct with Settings.load().

input_dir property

input_dir: Path

$DATA_DIR/input/: organised by data origin, shared with other projects.

output_dir property

output_dir: Path

$DATA_DIR/output/: organised by the tool that produced the results.

run_dir property

run_dir: Path

$DATA_DIR/output/lczkit/<run_id>/: this run's own directory.

tile_cache_dir property

tile_cache_dir: Path

$DATA_DIR/output/lczkit/_cache/tiles/: memoised per-tile street simplification.

Under lczkit's own output tree rather than input/, because a simplified tile is derived by lczkit, not source data, and it outlives the run that computed it.

source_dir

source_dir(name: str) -> Path

input/<name>/, the directory the source implementation for name owns.

Source code in src/lczkit/config.py
def source_dir(self, name: str) -> Path:
    """`input/<name>/`, the directory the source implementation for `name` owns."""
    return self.input_dir / name

load classmethod

load(*, run_id: str | None = None, dotenv_path: Path | str | None = None, create_run_dir: bool = True) -> Settings

Load .env, resolve DATA_DIR, and create output/lczkit/<run_id>/ unless told not to.

Also reads the optional GEE_PROJECT_NAME and MAPTILER_API_KEY. An absent variable leaves any configured value alone rather than overwriting it with None. Never touches input/. Raises ValueError if DATA_DIR is unset, and a ValidationError if it does not exist.

Source code in src/lczkit/config.py
@classmethod
def load(
    cls,
    *,
    run_id: str | None = None,
    dotenv_path: Path | str | None = None,
    create_run_dir: bool = True,
) -> Settings:
    """Load `.env`, resolve `DATA_DIR`, and create `output/lczkit/<run_id>/` unless told not to.

    Also reads the optional `GEE_PROJECT_NAME` and `MAPTILER_API_KEY`. An absent variable leaves
    any configured value alone rather than overwriting it with `None`. Never touches `input/`.
    Raises `ValueError` if `DATA_DIR` is unset, and a `ValidationError` if it does not exist.
    """
    load_dotenv(dotenv_path=dotenv_path)
    raw_data_dir = os.environ.get("DATA_DIR")
    if raw_data_dir is None:
        raise ValueError(
            "DATA_DIR is not set. Copy .env.example to .env and point DATA_DIR at the "
            "shared data directory."
        )
    settings = (
        cls(data_dir=Path(raw_data_dir), run_id=run_id)
        if run_id is not None
        else cls(data_dir=Path(raw_data_dir))
    )
    gee_project = os.environ.get("GEE_PROJECT_NAME")
    if gee_project is not None:
        settings.land_cover.gee_project = gee_project
    api_key = maptiler_key(dotenv_path=dotenv_path)
    if api_key is not None:
        settings.viz.maptiler_key = api_key
    if create_run_dir:
        settings.run_dir.mkdir(parents=True, exist_ok=True)
    return settings

maptiler_key

maptiler_key(*, dotenv_path: Path | str | None = None) -> str | None

MAPTILER_API_KEY from the environment, stripped, or None if unset or blank.

Separate from Settings.load because lczkit site build needs the key without DATA_DIR. The strip matters: a trailing space in .env would otherwise make every tile request 403.

Source code in src/lczkit/config.py
def maptiler_key(*, dotenv_path: Path | str | None = None) -> str | None:
    """`MAPTILER_API_KEY` from the environment, stripped, or `None` if unset or blank.

    Separate from `Settings.load` because `lczkit site build` needs the key without `DATA_DIR`. The
    strip matters: a trailing space in `.env` would otherwise make every tile request 403.
    """
    load_dotenv(dotenv_path=dotenv_path)
    raw = os.environ.get("MAPTILER_API_KEY")
    if raw is None:
        return None
    stripped = raw.strip()
    return stripped or None

Presets

A Settings produced by Settings.load() is deliberately not runnable — it has no bbox and no release. Presets close that gap with the exact constants a published figure was produced under.

lczkit.presets

Named, complete run configurations: the measured values that have no safe default.

CleaningConfig's thresholds and the height confidences default to None in lczkit.config and raise when used, because an invented default would enter every manifest looking like a measurement. A preset supplies them, so lczkit run and the published sites cannot drift apart. There is one preset, published, because only one configuration has been measured.

OVERTURE_RELEASE module-attribute

OVERTURE_RELEASE = '2026-07-22.0'

The release the committed fixtures were built from, so only the extent differs between a run here and the offline numbers. Never "latest": a floating release is not reproducible.

AREAL_CONFIDENCE module-attribute

AREAL_CONFIDENCE = {'gob25d': 0.5, 'wsf3d': 0.35, 'ghsl': 0.25}

height_confidence per areal tier, descending with coarseness below tier 1's 0.9 / 0.6. Ordinal, with no published number behind it; recorded in the manifest.

RunPreset dataclass

RunPreset(name: str, description: str, overture_release: str, cleaning: CleaningConfig = _published_cleaning(), heights: HeightConfig = _published_heights(), land_cover: LandCoverConfig = LandCoverConfig(), ucp: UcpConfig = UcpConfig())

A complete set of the configuration a run cannot default its way into.

apply

apply(settings: Settings) -> Settings

Write this preset over settings, in place, and return it.

Sections are copied, not shared, so two runs from one preset cannot mutate each other. An environment-supplied gee_project survives unless the preset names one itself: replacing the whole land_cover section used to discard it.

Source code in src/lczkit/presets.py
def apply(self, settings: Settings) -> Settings:
    """Write this preset over `settings`, in place, and return it.

    Sections are copied, not shared, so two runs from one preset cannot mutate each other. An
    environment-supplied `gee_project` survives unless the preset names one itself: replacing
    the whole `land_cover` section used to discard it.
    """
    settings.overture.release = self.overture_release
    settings.cleaning = self.cleaning.model_copy(deep=True)
    settings.heights = self.heights.model_copy(deep=True)
    land_cover = self.land_cover.model_copy(deep=True)
    if land_cover.gee_project is None:
        land_cover.gee_project = settings.land_cover.gee_project
    settings.land_cover = land_cover
    settings.ucp = self.ucp.model_copy(deep=True)
    return settings

preset

preset(name: str) -> RunPreset

The preset called name, or a KeyError naming the ones that exist.

Source code in src/lczkit/presets.py
def preset(name: str) -> RunPreset:
    """The preset called `name`, or a `KeyError` naming the ones that exist."""
    try:
        return PRESETS[name]
    except KeyError:
        raise KeyError(f"unknown run preset {name!r}; choose from {sorted(PRESETS)}") from None

apply_preset

apply_preset(settings: Settings, name: str = DEFAULT_PRESET) -> Settings

Apply the named preset to settings in place, returning it for chaining.

Source code in src/lczkit/presets.py
def apply_preset(settings: Settings, name: str = DEFAULT_PRESET) -> Settings:
    """Apply the named preset to `settings` in place, returning it for chaining."""
    return preset(name).apply(settings)

Places — the general locator

Every urban region on earth, by name, from the Global Urban Polygons and Points Dataset (GUPPD) — a gazetteer from NASA's Socioeconomic Data and Applications Center and the European Commission's Joint Research Centre: 5 558 regions across 173 countries, in one 564 KB table. This is what lczkit run --city and lczkit cities resolve against, and it is a locator, not a reference — nothing here labels or validates anything.

A name is not unique (149 of the 5 558 are shared), so an ambiguous query is refused with the candidates rather than answered with the first match. Getting that wrong would run the wrong continent and record a manifest that looks entirely correct.

lczkit.places

Any city in the world, by name — the general locator a run's extent comes from.

A run needs an extent. --bbox has always been the general answer and needs nothing on disk, and lczkit.cities is the other end of the range: 28 named cities whose windows are pinned to where the So2Sat labels are dense, so a run is comparable with a recorded agreement figure. Neither is what someone wanting a map of their own city reaches for — the first asks them to find four numbers and the second only knows 28 places, and only if the label archive is on disk.

This module is the middle. NASA/JRC's GUPPD ships one small table of every urban region on earth — 5 558 of them across 173 countries, with a name, an ISO code, a country and a bounding box — and it is 564 KB. Nothing in this package read it until now.

It is a locator, not a reference. Nothing here labels, validates or measures anything; it turns a name into four numbers and records which row it came from. The hand-labelled LCZ sets stay where they are, in lczkit.validation, reached by the sweep scripts and not by anything on the path from a city name to a map.

Sizing, because it decides whether a plain --city run is a sensible default. Measured over the shipped table: the median urban region is 80 km², the 90th percentile 412 km², and only 239 of 5 558 exceed 900 km² — the extent Berlin's 9.8-minute benchmark was measured over. So the ordinary case is minutes and the tail is real: Jakarta's region is 17 661 km². shrink is how a caller trims one, and the command line says the area before it starts.

GUPPD_SOURCE_DIR_NAME module-attribute

GUPPD_SOURCE_DIR_NAME = 'NASA'

Subdirectory under input/. GUPPD is filed under the agency rather than under its own name.

GUPPD_BOUNDS module-attribute

GUPPD_BOUNDS = Path('GUPPD') / 'guppd_bounds.csv'

The post-processed bounds table within input/NASA/, keyed on SMOD_ID.

The full GUPPD release beside it is a 117 MB GeoPackage of urban-region polygons. Reading the CSV rather than the polygons is deliberate: a locator needs a rectangle, and the rectangle is what the CSV holds, so resolving a name costs a 564 KB read rather than opening a spatial file.

Place dataclass

Place(smod_id: str, name: str, iso: str, country: str, bbox: BBox)

One GUPPD urban region: what it is called, where it is, and which row said so.

smod_id instance-attribute

smod_id: str

GUPPD's own identifier, e.g. "30_3528". Recorded in the run manifest, because a name is ambiguous — 149 of the 5 558 names are shared by more than one region — and this is not.

iso instance-attribute

iso: str

ISO 3166-1 alpha-3, e.g. "DEU". What --country matches against, alongside the name.

bbox instance-attribute

bbox: BBox

The region's bounding box in lon/lat degrees, as (west, south, east, north).

area_km2 property

area_km2: float

Roughly how much ground the bbox covers, for deciding whether to shrink it.

A cosine-corrected rectangle rather than a projected area: the answer is used to print an order of magnitude and to decide whether to mention --extent-km, and reprojecting 5 558 rectangles to answer that would be work spent on a digit nobody reads.

label property

label: str

"Berlin, Germany (DEU)" — how a place is named in output and error messages.

normalise

normalise(value: str) -> str

A name reduced to what two spellings of it have in common.

Accents are stripped, case is folded and everything that is not alphanumeric collapses away, so bogota finds Bogota, sao paulo finds São Paulo and washington d.c. finds Washington D.C.. Applied to both sides, never to stored data — the table keeps its own spelling, which is what gets printed back.

Source code in src/lczkit/places.py
def normalise(value: str) -> str:
    """A name reduced to what two spellings of it have in common.

    Accents are stripped, case is folded and everything that is not alphanumeric collapses away, so
    `bogota` finds `Bogota`, `sao paulo` finds `São Paulo` and `washington d.c.` finds
    `Washington D.C.`. Applied to both sides, never to stored data — the table keeps its own
    spelling, which is what gets printed back.
    """
    decomposed = unicodedata.normalize("NFKD", value)
    stripped = "".join(char for char in decomposed if not unicodedata.combining(char))
    return re.sub(r"[^a-z0-9]+", "", stripped.casefold())

bounds_path

bounds_path(settings: Settings) -> Path

Where the GUPPD bounds table lives under input/.

Source code in src/lczkit/places.py
def bounds_path(settings: Settings) -> Path:
    """Where the GUPPD bounds table lives under `input/`."""
    return settings.source_dir(GUPPD_SOURCE_DIR_NAME) / GUPPD_BOUNDS

load_places

load_places(settings: Settings) -> tuple[Place, ...]

Every GUPPD urban region, in file order.

Raises FileNotFoundError naming the path when the table is not on disk, and saying that --bbox needs nothing — the alternative is a bare csv error several frames down that names neither the dataset nor the way round it.

Source code in src/lczkit/places.py
def load_places(settings: Settings) -> tuple[Place, ...]:
    """Every GUPPD urban region, in file order.

    Raises `FileNotFoundError` naming the path when the table is not on disk, and saying that
    `--bbox` needs nothing — the alternative is a bare `csv` error several frames down that names
    neither the dataset nor the way round it.
    """
    return _load(bounds_path(settings))

in_country

in_country(places: tuple[Place, ...], country: str | None) -> tuple[Place, ...]

places narrowed to one country, matched on the ISO code or the country name.

Either in full or as a prefix, under normalise — so GBR, gb and "united kingdom" all reach the United Kingdom. None returns everything, so a caller can pass an optional flag through without branching.

Source code in src/lczkit/places.py
def in_country(places: tuple[Place, ...], country: str | None) -> tuple[Place, ...]:
    """`places` narrowed to one country, matched on the ISO code or the country name.

    Either in full or as a prefix, under `normalise` — so `GBR`, `gb` and `"united kingdom"` all
    reach the United Kingdom. `None` returns everything, so a caller can pass an optional flag
    through without branching.
    """
    if country is None:
        return places
    key = normalise(country)
    if not key:
        return places
    return tuple(
        entry
        for entry in places
        if normalise(entry.iso).startswith(key) or normalise(entry.country).startswith(key)
    )

find

find(places: tuple[Place, ...], query: str, *, country: str | None = None) -> list[Place]

Regions whose name matches query, exact matches first.

Two tiers rather than a score: a name that is the query outranks every name that merely contains it, so london returns London GBR and London CAN ahead of East London ZAF, and cambridge returns both Cambridges and nothing else. Within a tier the file's order is kept, so the answer does not depend on a sort that was never specified.

An empty query returns every region in country, so lczkit cities --country KEN is a listing rather than a no-op. in_country is what narrows it.

Source code in src/lczkit/places.py
def find(places: tuple[Place, ...], query: str, *, country: str | None = None) -> list[Place]:
    """Regions whose name matches `query`, exact matches first.

    Two tiers rather than a score: a name that *is* the query outranks every name that merely
    contains it, so `london` returns London GBR and London CAN ahead of East London ZAF, and
    `cambridge` returns both Cambridges and nothing else. Within a tier the file's order is kept,
    so the answer does not depend on a sort that was never specified.

    An empty query returns every region in `country`, so `lczkit cities --country KEN` is a
    listing rather than a no-op. `in_country` is what narrows it.
    """
    candidates = in_country(places, country)
    wanted = normalise(query)
    if not wanted:
        return list(candidates)
    exact = [entry for entry in candidates if normalise(entry.name) == wanted]
    partial = [
        entry
        for entry in candidates
        if normalise(entry.name) != wanted and wanted in normalise(entry.name)
    ]
    return exact + partial

place

place(places: tuple[Place, ...], query: str, *, country: str | None = None) -> Place

The single region query names, or a LookupError saying why there is not one.

An ambiguous query is an error, and the message lists the candidates with their countries. Silently taking the first would put a run over the wrong continent and record a manifest that looks entirely correct — there are two Cambridges and three Londons in this table, and nothing about a bbox afterwards would say which one was meant.

Source code in src/lczkit/places.py
def place(places: tuple[Place, ...], query: str, *, country: str | None = None) -> Place:
    """The single region `query` names, or a `LookupError` saying why there is not one.

    An ambiguous query **is an error**, and the message lists the candidates with their countries.
    Silently taking the first would put a run over the wrong continent and record a manifest that
    looks entirely correct — there are two Cambridges and three Londons in this table, and nothing
    about a bbox afterwards would say which one was meant.
    """
    matches = find(places, query, country=country)
    if not matches:
        where = f" in {country}" if country else ""
        raise LookupError(
            f"no urban region called {query!r}{where} in GUPPD. Names come from the JRC's own "
            "gazetteer, so a local spelling may differ; `lczkit cities <part of the name>` "
            "searches, and --bbox takes an explicit window."
        )
    # One exact match ahead of substring matches is not ambiguity: `london` means London, not
    # East London. Only a tie between regions of the *same* name needs the caller to choose, and
    # only those are listed — naming East London in the error would suggest it was a candidate.
    wanted = normalise(query)
    tied = [entry for entry in matches if normalise(entry.name) == wanted] or matches
    if len(tied) > 1:
        listed = "; ".join(entry.label for entry in tied[:8])
        more = "" if len(tied) <= 8 else f"; and {len(tied) - 8} more"
        raise LookupError(
            f"{query!r} names {len(tied)} urban regions: {listed}{more}. "
            "Add --country to choose one."
        )
    return matches[0]

City registry — the comparable-extent locator

The 28 study cities the validation sweeps run over — those with enough hand-drawn So2Sat LCZ42 labels to validate against — with the window resolution used to pick a comparable extent in each. Reached by lczkit run --city ... --so2sat-window, which is a flag rather than a fallback: this window and the GUPPD region of the same city are different ground, and only this one makes a run comparable with a published agreement figure.

lczkit.cities

The So2Sat study cities, and how each one's window is found.

A run needs an extent. --bbox is the general answer and needs nothing on disk; this module is the convenience that lets a caller say --city berlin and get the same 30 km window the published agreement figures were measured over, so a run stays comparable with them.

It reads input/So2Sat-LCZ42/, so it is the one locator that needs DATA_DIR populated. --bbox does not, and lczkit.places covers every other city.

WINDOW_KM module-attribute

WINDOW_KM = 30.0

Side of the square window, in kilometres. ~900 km2, matching the measured Berlin extent.

City dataclass

City(key: str, so2sat: str, region: str, iso: str)

One So2Sat city: its labels, its country, and the region it speaks for.

so2sat instance-attribute

so2sat: str

Directory name under input/So2Sat-LCZ42/v4/cities/.

iso instance-attribute

iso: str

ISO 3166-1 alpha-3, so this registry can be matched against the GUPPD gazetteer.

Added because the two locators otherwise collided on a name. Three of these keys name a city that exists in more than one country — London (GBR and CAN), Santiago (CHL and PHL) and Los Angeles (USA and Chile's Los Ángeles) — so lczkit cities marked rows that carry no So2Sat window at all, and --city london --country CAN --so2sat-window would have run London, UK's window while the caller asked for Canada. Silent wrong ground, which is the one failure the two-locator design exists to prevent.

city

city(key: str) -> City

The city called key, or a KeyError naming the ones that exist.

Source code in src/lczkit/cities.py
def city(key: str) -> City:
    """The city called `key`, or a `KeyError` naming the ones that exist."""
    try:
        return BY_KEY[key]
    except KeyError:
        raise KeyError(f"unknown city {key!r}; choose from {sorted(BY_KEY)}") from None

densest_window

densest_window(patches: GeoDataFrame, side_km: float = WINDOW_KM) -> BBox

The side_km square holding the most labelled patch centres, as a lon/lat bbox.

Searched over a quantile grid of candidate centres rather than optimised: the objective is piecewise constant and this is deterministic, which matters more here than optimality — a window that moved between runs would make two runs of the same city incomparable.

Centres, not areas. So2Sat patches are 320 m squares on a 100 m stride and overlap about sevenfold, so counting area would measure the sampling density rather than the city; the same reason lczkit.validation.labelled anchors each label on its patch centre.

Source code in src/lczkit/cities.py
def densest_window(patches: gpd.GeoDataFrame, side_km: float = WINDOW_KM) -> BBox:
    """The `side_km` square holding the most labelled patch centres, as a lon/lat bbox.

    Searched over a quantile grid of candidate centres rather than optimised: the objective is
    piecewise constant and this is deterministic, which matters more here than optimality — a
    window that moved between runs would make two runs of the same city incomparable.

    Centres, not areas. So2Sat patches are 320 m squares on a 100 m stride and overlap about
    sevenfold, so counting area would measure the sampling density rather than the city; the same
    reason `lczkit.validation.labelled` anchors each label on its patch centre.
    """
    utm = patches.estimate_utm_crs()
    centres = patches.to_crs(utm).geometry.centroid
    x, y = centres.x.to_numpy(), centres.y.to_numpy()
    half = side_km * 1000.0 / 2.0

    best = (-1, float(np.median(x)), float(np.median(y)))
    grid = np.linspace(0.05, 0.95, 19)
    for candidate_x in np.quantile(x, grid):
        for candidate_y in np.quantile(y, grid):
            n = int(((np.abs(x - candidate_x) <= half) & (np.abs(y - candidate_y) <= half)).sum())
            if n > best[0]:
                best = (n, float(candidate_x), float(candidate_y))
    _, cx, cy = best

    centre = gpd.GeoSeries(gpd.points_from_xy([cx], [cy]), crs=utm).to_crs("EPSG:4326")
    lon, lat = float(centre.x.iloc[0]), float(centre.y.iloc[0])
    half_lat = side_km / 2.0 / 111.0
    half_lon = half_lat / max(math.cos(math.radians(lat)), 0.01)
    return (lon - half_lon, lat - half_lat, lon + half_lon, lat + half_lat)

patches_path

patches_path(target: City, settings: Settings) -> Path

Where target's labelled patches live under input/.

Source code in src/lczkit/cities.py
def patches_path(target: City, settings: Settings) -> Path:
    """Where `target`'s labelled patches live under `input/`."""
    source = settings.source_dir(SO2SAT_SOURCE_DIR_NAME) / SO2SAT_CITIES / target.so2sat
    return source / f"patches_reference_{target.so2sat}.gpkg"

so2sat_window

so2sat_window(target: City, settings: Settings, side_km: float = WINDOW_KM) -> BBox

target's densest labelled window — the extent the published figures were measured over.

Raises FileNotFoundError naming the path when So2Sat is not on disk, because the alternative is a pyogrio error several frames down that does not say which city or which directory.

Source code in src/lczkit/cities.py
def so2sat_window(target: City, settings: Settings, side_km: float = WINDOW_KM) -> BBox:
    """`target`'s densest labelled window — the extent the published figures were measured over.

    Raises `FileNotFoundError` naming the path when So2Sat is not on disk, because the alternative
    is a `pyogrio` error several frames down that does not say which city or which directory.
    """
    path = patches_path(target, settings)
    if not path.exists():
        raise FileNotFoundError(
            f"no So2Sat patches for {target.key} at {path}. The --city locator reads "
            f"input/{SO2SAT_SOURCE_DIR_NAME}/; pass --bbox instead if it is not on disk."
        )
    return densest_window(gpd.read_file(path), side_km)

shrink

shrink(bbox: BBox, extent_km: float) -> BBox

A concentric window of roughly extent_km on a side.

Kept because a full 30 km window is a multi-hour run, and the first thing anyone does with a new command line is try it on something small.

Source code in src/lczkit/cities.py
def shrink(bbox: BBox, extent_km: float) -> BBox:
    """A concentric window of roughly `extent_km` on a side.

    Kept because a full 30 km window is a multi-hour run, and the first thing anyone does with a
    new command line is try it on something small.
    """
    minx, miny, maxx, maxy = bbox
    cx, cy = (minx + maxx) / 2, (miny + maxy) / 2
    half_lat = extent_km / 2 / 111.0
    half_lon = half_lat / max(math.cos(math.radians(cy)), 0.01)
    return (cx - half_lon, cy - half_lat, cx + half_lon, cy + half_lat)