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 current working directory.

NodataPolicy module-attribute

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

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

"exclude" — the product made no observation here, so the cell leaves the denominator entirely and the remaining fractions still sum to 1.0. "assign" — the product deliberately masks this surface, and the mask itself carries meaning, so the cell counts towards a named class.

The distinction is not cosmetic. Lang et al. (2023), 10.1038/s41559-023-02206-6, mask built-up areas, snow, ice and permanent water out of the ETH canopy height product and set those cells to 255. That is a deliberate removal of surfaces known to carry no canopy, not a gap in coverage: over the Berlin test fixture 93% of built-up cells and 78% of the whole tile are 255, and reading them 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" is the default: an unmapped value means the configured mapping does not match the product actually on disk, and silently dropping or lumping those cells would produce a quietly wrong map.

GeeAssetType module-attribute

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

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

Both occur among the MVP datasets: ESA WorldCover is a catalogued ImageCollection, while ETH canopy height is a single Image published as a user asset. Loading one as the other is an immediate EEException, so the kind is declared rather than probed.

OvertureConfig

Bases: BaseModel

Configuration for OvertureSource, the Overture Maps vector source.

release class-attribute instance-attribute

release: str | None = None

Pinned Overture release string, 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'

Name of the subdirectory under input/ that OvertureSource caches into. Matches the directory already used by other projects sharing DATA_DIR, rather than the plain "Overture".

CleaningConfig

Bases: BaseModel

Configurable thresholds for the building-cleaning pipeline.

None of these have a literature-derived default — Majer & Fleischmann (arXiv:2603.00132) Supplementary D, the cleaning specification these operations follow, describes them only qualitatively. They are left unset here; the cleaning pipeline raises if used before being explicitly configured.

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

Footprints smaller than this are dissolved into a touching larger neighbour on buildings_topo. 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.

Derived from the fixtures rather than the literature. At 4.0 m the overlap fraction separates perimeter blocks from structures standing in the roadway; at 2.0 m the distribution is too compressed to separate anything (p95 = 0.46) and at 8.0 m it swallows the blocks (p90 = 0.98).

building_road_overlap_limit class-attribute instance-attribute

building_road_overlap_limit: float | None = None

Fraction of a footprint inside the road buffer above which it is dropped rather than trimmed.

Also fixture-derived: 0.5 is where the median dropped footprint falls below the median building (230 m² on Berlin), recovering 95% of the area the old centreline rule destroyed. Both this and building_road_buffer_m were measured on two European cities and should be re-derived for a city whose fabric or road-centreline 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 (0-1 ratio) — polygons larger than building_merge_limit_m2 are merged 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

Edge length of the tiles street simplification is chunked into, in metres.

None runs neatnet over the whole extent. Set it to engage the tiled path, which is required above roughly 50 km2, where whole-extent simplification stops completing in usable time (100 km2 measured at 50 minutes on one core, 256 km2 abandoned after 4h12m).

2000.0 m is the fixture-derived working value: tiles small enough to keep the largest face- artifact component tractable, large enough that seam handling stays a small share of the work.

street_tile_buffer_m class-attribute instance-attribute

street_tile_buffer_m: float | None = None

Working margin added around each tile before simplification, in metres.

Each tile is simplified over core + buffer and contributes only its core, so a feature at the seam is decided with its neighbourhood present. Measured on Berlin: raising this from 300 m to 600 m cut spurious linework — geometry the tiled run invents that the whole-extent run does not have — from 1.23 km to 0.11 km, and 900 m bought nothing further. Below about 300 m the buffer stops containing a dual carriageway's artifact and seams degrade sharply.

street_tile_workers class-attribute instance-attribute

street_tile_workers: int | None = None

Processes to run tiles across. None uses every core the process is allowed.

street_artifact_threshold class-attribute instance-attribute

street_artifact_threshold: float | None = None

Face-artifact index above which a face is ordinary fabric rather than a road artifact.

None derives it from the data — pooled across tiles on the tiled path, whole-network on the untiled one. Setting it pins the value, which is what an A/B between two thresholds needs, and lets a recorded run restate its threshold instead of re-deriving it.

A threshold on a neatnet index, not a fallback: neatnet's own fallback for a distribution with no valley stays in lczkit.cleaning.streets.ARTIFACT_THRESHOLD_FALLBACK.

ArealTierConfig

Bases: BaseModel

One areal raster tier of the height cascade (tiers 2-4).

Areal products assign a neighbourhood mean to individual buildings, which is a categorically weaker measurement than a per-building height. Everything product-specific lives here rather than in code: none of these three products is present on this system, and none of their documentation is in docs/references/datasets/, so hardcoding a band number, a unit scale or a nodata value would be guessing at a product nobody has read the manual for.

name instance-attribute

name: str

The tier's height_source tag, e.g. "ghsl". Must be unique within a cascade.

source_dir_name instance-attribute

source_dir_name: str

Subdirectory under input/ holding this product, resolved via settings.source_dir().

enabled class-attribute instance-attribute

enabled: bool = True

Whether this tier takes part in the default cascade.

Deliberately distinct from filename is None, which says the product is not there. This says it is available and switched off, and the two must stay distinguishable in the manifest: a tier that never fired because nobody placed the data is a different fact from one that never fired because it was measured and rejected.

filename class-attribute instance-attribute

filename: str | None = None

COG filename within input/<source_dir_name>/. None means the product is not available and the tier is skipped entirely — the cascade is shorter, not broken.

band class-attribute instance-attribute

band: int = 1

1-based raster band carrying height.

scale class-attribute instance-attribute

scale: float = 1.0

Multiplier converting raw raster values to metres (e.g. 0.1 for a decimetre product).

nodata class-attribute instance-attribute

nodata: float | None = None

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

min_height_m class-attribute instance-attribute

min_height_m: float = 0.0

Sampled values at or below this are treated as "no building here" rather than as a height, so the next tier gets a chance. Zero is the neutral choice: an areal height product reports 0 for cells with no built-up volume.

It stays zero for every shipped tier, including Open Buildings 2.5D, whose sampled heights are 19% below 2 m against about 1% for the coarse products. A floor tuned until that stopped hurting would be a threshold no documentation supports, and it would be copied into other pipelines and outlive its justification. It also treats the wrong thing: the measured damage is within-unit dispersion (CV 0.441 against reality's 0.195), and clipping one side of an over-wide distribution leaves the other side where it was. The route to rescuing a fine-resolution product is shrinkage toward the unit mean, which is not implemented.

confidence class-attribute instance-attribute

confidence: float | None = None

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

Wsf3dConfig

Bases: BaseModel

Tier 3 — WSF-3D V02 building height (DLR, TanDEM-X derived), CC-BY-4.0.

Parameters from DLR's own README_BuildingHeight.txt: 2.8 arcsec (~90 m at the equator), "provided in meter with a gain factor of 0.1 for storage optimization (Int16)", nodata -32767. The gain is why the tier carries scale=0.1; reading it as metres would report a ten-storey block as a kerbstone.

url class-attribute instance-attribute

url: str = 'https://download.geoservice.dlr.de/WSF3D/files/global/WSF3D_V02_BuildingHeight.tif'

The global product, a tiled GeoTIFF with overviews — read by window, never clipped.

GhslProductConfig

Bases: BaseModel

Tier 4 — GHS-BUILT-H ANBH R2023A (JRC), free reuse with attribution.

ANBH, not the AGBH published beside it: the GHSL Data Package 2023 (p. 26) defines ANBH = BUVOL / BUSURF, building volume over built-up surface, so it is the mean height of the built fabric rather than a height averaged over open ground. Float32 metres, nodata 255, 100 m World Mollweide (p. 36).

OpenBuildings25dConfig

Bases: BaseModel

Tier 2 — Google Open Buildings 2.5D Temporal v1, CC-BY-4.0, via Earth Engine.

The highest-value tier for exactly the regions where tier 1 fails, and the only one with no public bucket behind it. building_height is metres above terrain in [0, 100] at an effective 4 m resolution, annual 2016-2023, over Africa, South and South-East Asia, Latin America and the Caribbean.

The two request caps are Earth Engine's, and they are here rather than in code because they are a property of the service, not of this package — when they move, this moves.

year class-attribute instance-attribute

year: int = 2023

Latest annual epoch. Pinned rather than "latest" for the same reason the Overture release is: a run that silently changes epoch is a run nobody can reproduce.

scale_m class-attribute instance-attribute

scale_m: float = 4.0

The product's effective resolution. Requesting the underlying 0.5 m grid would multiply the payload sixty-four-fold for detail the model does not carry.

HeightProductsConfig

Bases: BaseModel

Where the areal height products come from, pinned for the manifest.

Separate from HeightConfig, which says how a tier reads a raster. This says how the raster gets onto disk — release strings, URLs, the Earth Engine collection and epoch. Both are serialised into the run manifest; only together do they make a cascade reproducible.

HeightConfig

Bases: BaseModel

Configuration for the building-height cascade.

Tiers run in the order they appear: Overture attributes first, then areal_tiers in list order. Adding a fifth areal product is an entry in that list, not a code change.

The three *_confidence values have no default for the same reason CleaningConfig's thresholds have none: no published number defines them. height_confidence is an ordinal ranking of measurement quality, not a calibrated probability, and a plausible-looking invented default is the worst failure mode available here — nothing would crash, and the map would carry a quietly wrong quality claim. Set them explicitly and they are serialised into the run manifest, where the choice is visible and reproducible.

storey_height_m class-attribute instance-attribute

storey_height_m: float = 3.0

Metres per storey for the num_floors fallback. Varies regionally and is a real error source; 3.0 m is a conventional default and should be set per city where one is known.

overture_height_confidence class-attribute instance-attribute

overture_height_confidence: float | None = None

height_confidence for buildings resolved from Overture's height attribute — except where Overture itself supplies a per-building confidence, which is preferred over this.

overture_num_floors_confidence class-attribute instance-attribute

overture_num_floors_confidence: float | None = None

height_confidence for buildings resolved from num_floors x storey_height_m.

areal_tiers class-attribute instance-attribute

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

Tiers 2-4, in cascade order. The default is coarse — see _default_areal_tiers.

GeeAssetConfig

Bases: BaseModel

Earth Engine coordinates for one land-cover dataset.

Serialised verbatim into the run manifest, so the collection ID and date range a run used are part of its record.

collection_id class-attribute instance-attribute

collection_id: str | None = None

Full asset ID, e.g. "ESA/WorldCover/v200". None means no Earth Engine asset has been verified for this dataset and EarthEngineSource refuses to guess at one.

asset_type class-attribute instance-attribute

asset_type: GeeAssetType = 'image_collection'

See GeeAssetType.

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 passed to filterDate. Required for an image_collection; recorded but not applied for a single image, which has no collection to filter.

end_date class-attribute instance-attribute

end_date: str | None = None

Exclusive ISO date passed to filterDate. Same caveat as start_date.

scale_m class-attribute instance-attribute

scale_m: float | None = None

Reduction scale in metres. Should match the product's native resolution — a coarser scale silently resamples and changes every fraction.

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.

Everything product-specific lives here rather than in code: the class-to-fraction mapping is config, never hardcoded. LocalRasterSource and EarthEngineSource read the same instance, which is what makes the two backends return schema-identical tables.

A dataset is either categorical (value_classes) or binned (bins + bin_classes), never both. Classes are disjoint and their fractions sum to 1.0 over the cells that count.

name instance-attribute

name: str

Short identifier, e.g. "worldcover". Used in cache filenames and error messages, and must be unique within LandCoverConfig.datasets.

source_dir_name instance-attribute

source_dir_name: str

Subdirectory under input/ holding this product, resolved via settings.source_dir().

filename class-attribute instance-attribute

filename: str | None = None

COG filename within input/<source_dir_name>/. None means the product is not available locally; LocalRasterSource.from_settings refuses to build a source for it.

band class-attribute instance-attribute

band: int = 1

1-based raster band to read from the local COG.

column_prefix class-attribute instance-attribute

column_prefix: str = 'frac_'

Prefixed to every class name to form the output column names. Distinct prefixes are how two datasets that both emit a tree class stay joinable on unit_id without collision.

classes instance-attribute

classes: list[str]

The full, ordered output class list. Fixes the output schema to config rather than to whichever classes happen to occur in a given city, so a class with no cells still gets a column holding 0.0 and two cities produce the same columns. The height-tier fractions are fixed to the configured cascade for the same reason.

value_classes class-attribute instance-attribute

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

Categorical mapping from raw raster value to class name.

bins class-attribute instance-attribute

bins: list[float] | None = None

Ascending breakpoints for a continuous product. A value v falls in bin i where bins[i-1] <= v < bins[i], giving len(bins) + 1 bins.

bin_classes class-attribute instance-attribute

bin_classes: list[str] | None = None

Class name per bin, lowest first. Must be len(bins) + 1 long.

nodata class-attribute instance-attribute

nodata: float | None = None

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

nodata_policy class-attribute instance-attribute

nodata_policy: NodataPolicy = 'exclude'

See NodataPolicy.

nodata_class class-attribute instance-attribute

nodata_class: str | None = None

Class nodata cells count towards. Required when nodata_policy is "assign", and forbidden otherwise.

unmapped_policy class-attribute instance-attribute

unmapped_policy: UnmappedPolicy = 'raise'

See UnmappedPolicy.

unmapped_class class-attribute instance-attribute

unmapped_class: str | None = None

Class unmapped values count towards. Required when unmapped_policy is "assign", and forbidden otherwise.

gee class-attribute instance-attribute

gee: GeeAssetConfig = Field(default_factory=GeeAssetConfig)

Earth Engine coordinates for the same product.

LandCoverConfig

Bases: BaseModel

Configuration for the land-cover fraction sources.

Datasets are an ordered list so adding a third product is a config entry, not a code change — the same shape as HeightConfig.areal_tiers.

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 by Settings.load().

gee_batch_size class-attribute instance-attribute

gee_batch_size: int = 2000

Units per reduceRegions call. A few thousand keeps a request under Earth Engine's element-count and payload limits.

gee_max_units class-attribute instance-attribute

gee_max_units: int | None = None

Refuse an Earth Engine run covering 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 covering window exceeds this many cells, rather than exhausting memory. 200M cells is ~200 MB for a uint8 product, or a ~450 x 450 km extent 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 which LCZ class it is evidence for.

Transcribed from docs/references/tables/overture_lcz_semantic_mapping.md, which tests/test_ucp_semantics.py parses and asserts against cell for cell, in the same way the Stewart & Oke property table and the Bechtel similarity matrix are. Every value in it was taken from what is present in the pinned release rather than from the schema documentation.

A group matches a building on subtype or class — the two are independently nullable and a feature carrying only one is still classifiable. Groups are not a partition and their fractions do not sum to one: retail is genuinely evidence for both large-low-rise form and commercial function, and appears in both.

lcz_hint class-attribute instance-attribute

lcz_hint: str = ''

Which LCZ class this group is evidence for, for the manifest and the docs. Documentation only — nothing keys behaviour off it, because a group is evidence and not a label.

UcpConfig

Bases: BaseModel

Configuration for the urban canopy parameters.

Two kinds of value live here. The *_classes lists are vocabulary — which land-cover class feeds which Stewart & Oke surface fraction, and which Overture attribute values count as industrial — and they belong in config rather than in code. The two street_profile_* values are momepy's own defaults, restated so they reach the run manifest instead of staying implicit in a library signature.

street_profile_distance_m class-attribute instance-attribute

street_profile_distance_m: float = 10.0

Spacing between the perpendicular ticks momepy.street_profile() measures along. momepy's own default.

street_profile_tick_length_m class-attribute instance-attribute

street_profile_tick_length_m: float = 50.0

Length of each tick. A tick reaching no building reports this as the street width, so it is also the assumed width of an open street. momepy's own default.

min_building_height_m class-attribute instance-attribute

min_building_height_m: float = 0.1

Lower bound applied to building height before taking logs for the geometric mean.

A numerical guard, not a scientific threshold: log(0) is negative infinity and would take a whole unit's height of roughness elements to zero on the strength of one bad row. Overture heights are not validated upstream and a zero or negative value does occur. 0.1 m sits below any plausible building, so the floor changes nothing except in the case it exists to catch — but it is the value such a building is then counted as, which is why it is configurable rather than buried in the code.

measure_on class-attribute instance-attribute

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

Which units the parameters are measured on, as distinct from classified on.

"units" measures on whatever UnitsConfig.strategy produced, and is what every published figure was computed with. "enclosures" measures on street-bounded enclosures and moves the result onto the target units, area-weighted.

The reason is aspect_ratio. A street canyon has to be measured against streets, and a 100 m grid cell is not bounded by any — so H/W, which is 3 of the 17 applied weight units and the only dimension separating LCZ 8 from LCZ 3 and 6, is null on 10.8% of one Istanbul extent's built grid cells against 0.9% of its enclosures. On the densest decile the enclosures also put 82.2% of cells inside LCZ 2's published H/W band against the grid's 70.2%.

Defaults to "units" because no accuracy claim is attached: this has not been calibrated against a reference, so turning it on makes a run incomparable with one at the defaults.

land_cover_dataset class-attribute instance-attribute

land_cover_dataset: str = 'worldcover'

Which LandCoverConfig.datasets entry supplies the surface fractions. The ETH canopy dataset is a second, competing tree estimate rather than a full land-cover product and reads high, so WorldCover is the default.

tree_classes class-attribute instance-attribute

tree_classes: list[str] = Field(default_factory=lambda: ['tree'])

Land-cover classes counting as tree cover.

pervious_classes class-attribute instance-attribute

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

Land-cover classes counting as 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'])

Land-cover classes counting as impervious, buildings included. The building share is subtracted in lczkit.ucp.surface, since a raster's built-up class contains the roofs.

water_classes class-attribute instance-attribute

water_classes: list[str] = Field(default_factory=lambda: ['water'])

Land-cover classes counting as water.

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 deliberately absent. The problem this parameter exists to solve is that a distribution warehouse and a refinery are geometrically identical — the warehouse being the LCZ 8 case and the refinery the LCZ 10 one. Counting warehouses as industrial would push exactly the units the rule is meant to keep apart towards LCZ 10.

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 by default: Overture files industrial parcels under subtype='developed', which also covers commercial and retail, so the subtype alone carries no industrial signal.

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 — disused industrial land — is the obvious candidate to add and is deliberately left out: it describes what a parcel was, and a brownfield site has no heat output, no industrial buildings and often no buildings at all. Add it only for a city where derelict industry is still the dominant surface.

semantic_groups class-attribute instance-attribute

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

Functional groups read out of Overture's subtype and class.

The four industrial_* vocabularies above are not replaced by this and are not derived from it. They feed industrial_fraction_of_building_area, which is the column the calibrated LCZ 10 threshold of 0.45 was swept against, and repointing that at a differently-scoped group would silently invalidate the calibration. heavy_industry here is the same idea with a slightly wider vocabulary (storage_tank, silo, land-use works) and it is reported beside the original rather than in place of it.

Set to [] to skip the semantic layer entirely, which costs one overlay per group.

SemanticRuleConfig

Bases: BaseModel

One functional assignment rule keyed on a semantic evidence column.

The same mechanism as the LCZ 10 rule, generalised: a unit over min_fraction takes lcz whatever the distance metric said, and the displaced answer is kept as lcz_secondary.

All of these ship disabled, and that is deliberate rather than cautious. A threshold here is calibrated against a reference and chosen at an operating point, never picked because it looks reasonable. The values below are placeholders marking where a calibrated number goes, and enabling one before that would put an invented number into a published label.

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.

Available to a rule although it is not a metric dimension: mean_building_area_m2 carries zero weight in every preset, so the distance metric cannot act on it. A rule is not the metric, and "large low-rise" is a claim about building size that the semantic evidence alone cannot make.

reason class-attribute instance-attribute

reason: str = ''

Why this rule exists, for the manifest. A rule with no recorded reason is one nobody can audit later.

ClassificationConfig

Bases: BaseModel

Configuration for the prototype-distance classifier.

Every threshold the classifier applies lives here and is serialised into the run manifest. Two of them - natural_dominant_fraction and natural_negligible_fraction - move the prototype table itself, because the tree and water ranges they generate are lczkit's own rather than Stewart & Oke's and there is no published value to defer to.

weight_preset class-attribute instance-attribute

weight_preset: str = 'bernard2024_partial'

Named entry in lczkit.classify.weights.PRESETS. "bernard2024_partial" is Bernard et al. (2024)'s published built-type default with the dimensions this package cannot compute left out; "equal" is the uniform comparison.

The _partial is load-bearing rather than decorative: SVF (weight 4) and z0 (weight 0.5) are deferred and FI/FP are zero-weighted, so 17 of the published 21.5 weight units are applied and the effective metric has three non-zero dimensions, of which FB is roughly 47%. Calling it bernard2024 would claim a metric this package does not implement. The unapplied dimensions are recorded in the run manifest.

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 against the built prototypes rather than the natural ones.

Not an invented number. Every built class in Stewart & Oke's table has a building surface fraction of at least 10%, and every natural class at most 10%, so 10% is the boundary the published table draws itself. It is configurable because a city's footprint completeness can shift the measured fraction even where the real one is unchanged.

reachable_natural_classes class-attribute instance-attribute

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

Which natural classes the gate may assign, by Stewart & Oke label.

C (bush, scrub), D (low plants) and F (bare soil or sand) are mutually indistinguishable in open ground with the parameters this package computes: the published table separates them only by sky view factor, aspect ratio and height of roughness elements, all building-derived and all null where nothing is built, and the default WorldCover mapping folds shrubland, grassland and bare ground into a single pervious class. Rather than let tied prototypes be resolved by index order, C and F are excluded and the exclusion is recorded in the manifest. Distances to the excluded classes are still computed and reported.

The two exclusions are not the same kind of thing, and only one of them is a policy choice.

C is genuinely excluded by this setting: its box differs from D's on aspect ratio (0.25-1.0 against at most 0.1) and on Hr (at most 2 m against at most 1 m), both of which carry weight 1.0 in the natural vector. Where those two are non-null C wins outright, so adding it back here changes labels. The tie is real only where both are null, which for a buildingless unit is the common case - hence the exclusion.

F is excluded by arithmetic, not by this list. D's box contains F's in every dimension - identical on aspect ratio, building, impervious, pervious, tree and water, and wider on Hr (at most 1 m against at most 0.25 m) - so d(F) >= d(D) for every possible unit, and ties break to the lower code. Adding "F" here cannot make F reachable. Reaching it needs a land-cover mapping that emits bare ground separately and a surface fraction carrying it, not a config change. The manifest records F as dominated rather than excluded, so the distinction survives into the run record.

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 LCZ G. See docs/references/tables/lczkit_natural_class_ranges.md - lczkit's own, not Tier 1.

semantic_rules class-attribute instance-attribute

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

Functional rules read off Overture's semantic attributes, all shipped disabled pending calibration. See SemanticRuleConfig, and lczkit.classify.rules.apply_semantic_rules.

natural_negligible_fraction class-attribute instance-attribute

natural_negligible_fraction: float = 0.1

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

lcz10_industrial_column class-attribute instance-attribute

lcz10_industrial_column: str = 'industrial_fraction_of_building_area'

Which industrial share the LCZ 10 rule reads.

Named explicitly because the two are not interchangeable and the threshold below is calibrated against one of them.

industrial_fraction_of_building_area is Bernard et al. (2024)'s FIND/B, so their published 0.33 transfers to it directly. It is the default on both theory and measurement: on the Rotterdam fixture it selects 95 cells against a reference of 88, where the unit-area share selects 196 at its own best operating point. Getting the rate right matters when precision cannot be improved (see the threshold below), because the remaining choice is how much of the map to label.

The unit-area share is the alternative, and is the more saturated of the two at a 100 m cell — 42.6% of cells holding any industrial ground read exactly 1.0, against 12.6% for FIND/B.

lcz10_min_industrial_fraction class-attribute instance-attribute

lcz10_min_industrial_fraction: float = 0.45

lcz10_industrial_column above which a unit is assigned LCZ 10.

Deliberately set to under-trigger: Overture cannot distinguish heavy from light industry, so a missing LCZ 10 is a visible gap while a light-industrial estate mislabelled as heavy industry is an invisible error that propagates into any model consuming the map.

Calibrated, not picked. It was swept over nineteen settings against the Rotterdam reference, and the precision/recall curve is written into the run record. 0.45 is that sweep's operating point, the precision maximum over FIND/B. Bernard's published 0.33 sits just below it and performs comparably (22.4% precision, 27.3% recall against 23.2% and 25.0%), so this is not a number in tension with the paper it comes from.

Read the curve before trusting it. Precision is roughly flat — 16.7% to 23.2% across the whole range — so this threshold governs how much of the map carries LCZ 10 and not how often that label is right. The rule fires plausibly, not accurately, and it is scored against lcz_v3, a comparator carrying its own error and coarser than the ground in this very city.

lcz1_min_height_m class-attribute instance-attribute

lcz1_min_height_m: float | None = None

Optional floor on Hr below which the LCZ 1 (compact high-rise) distance is discarded.

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. lczkit has no reliable per-unit storey count, so the hook is exposed against Hr instead and defaults to off - an untested constraint applied by default would be a worse failure than the over-prediction it guards against.

modal_filter class-attribute instance-attribute

modal_filter: bool = False

Whether to replace an isolated unit's label with the modal label of its neighbours.

Off, and that is deliberate rather than cautious. Every unit is classified independently of its neighbours, so a cell whose parameters wobble across a prototype boundary takes a different label from the fabric it sits in — salt-and-pepper at a grain Stewart & Oke never intended a class to be read at, given an LCZ patch is a neighbourhood and a So2Sat patch is 320 m across. A spatial filter is the standard answer in this literature.

It stays off because modal_filter_min_like_neighbours has not been calibrated against a reference, and because every published figure was measured without one. A run with it on is not comparable with a run at the defaults.

See lczkit.classify.smoothing.

modal_filter_min_like_neighbours class-attribute instance-attribute

modal_filter_min_like_neighbours: int = 2

A unit with fewer than this many contiguous neighbours sharing its label is isolated.

A placeholder marking where a swept number goes, not a calibrated value. Two is the weakest setting that does anything at all on a Queen-contiguous grid, chosen so a caller who enables the filter without sweeping it does the smallest thing rather than the boldest.

OutputConfig

Bases: BaseModel

Configuration for what a run writes into output/lczkit/<run_id>/.

break_count class-attribute instance-attribute

break_count: int = 7

Number of classification breaks precomputed per continuous variable. The map site renders choropleths from these and never recomputes a quantile at site-build time.

break_method class-attribute instance-attribute

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

How the breaks are derived. Only quantiles are implemented; the field exists so the manifest states the method rather than leaving a consumer to assume one.

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-way distance vector before it is stored as int16 in units_viz.parquet. Distances are small positive reals, so 1000 keeps three decimal places inside the int16 range.

gis_format class-attribute instance-attribute

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

A second copy of the unit table in a format every GDAL build reads, written beside the GeoParquet rather than instead of it.

units.parquet is not the problem this solves. It is valid GeoParquet 1.0.0 and carries the run's CRS as PROJJSON with an EPSG authority code — verified on the published Bogota and Nairobi runs, both EPSG:32618/EPSG:32737. What varies is the reader: GDAL's Parquet driver is an optional build component, so a QGIS built without it opens the file as a non-spatial table or not at all, and the failure looks like a missing CRS rather than a missing driver. GeoPackage has no such conditional — it is SQLite, in GDAL's core, and its CRS lives in a table rather than in file metadata a driver has to know how to parse.

Cost, measured on the 116 491-unit Bogota run at four extents before this was made the default: 0.22 s / 5.6 MB at 10 000 units to 1.83 s / 67.3 MB at 116 491, exponent 0.86 — sublinear, and 0.3% of a run that takes ten minutes. Set "none" to skip it.

Only the unit table. The context layers under layers/ stay GeoParquet-only: they are the site's basemap material, they carry the same CRS, and buildings.parquet alone is 477 MB on that run.

VizConfig

Bases: BaseModel

Configuration for the static map site lczkit.viz.build_site writes into a run.

Zoom ranges are here rather than derived because they have to be chosen deliberately and recorded — a tileset is only reproducible if the zooms that made it are written down, and tippecanoe's cost and the site's size both scale with the range.

unit_max_zoom class-attribute instance-attribute

unit_max_zoom: int = 14

Zooms for the unit tileset. A 100 m cell is about 21 px at z14 and 1 px at z10, so z14 is where the grid stops gaining detail and z10 is where a whole metropolitan extent fits on a screen. MapLibre overzooms past the maximum for free, so z14 is not a limit on how far in a reader can go.

basemap_max_zoom class-attribute instance-attribute

basemap_max_zoom: int = 13

The basemap stops one zoom short of the units. It is a flat wash drawn under a translucent unit fill, so its detail at z14 is invisible and expensive — dropping the top level halved a measured land-use tileset, 2.66 MB to 1.45 MB, because the maximum-zoom level is the one tippecanoe keeps at full precision. MapLibre overzooms it for free past the maximum.

basemap_simplification class-attribute instance-attribute

basemap_simplification: int = 10

tippecanoe's --simplification for the basemap only. Context geometry is the one thing in the site that carries no measurement, so it is the only thing that may be simplified for display; the 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'])

Which persisted context layers to draw, in draw order.

Land use is available and off by default, on a measurement. At 9 km² it was 1 864 polygons carrying 401 162 vertices — 94% of the basemap's bytes and most of its build time — for a dark wash underneath an 82%-opacity unit fill. Water and streets are what actually orient a reader on a city map: rivers and arterials. Add "land_use" back if a run wants it.

building_max_zoom class-attribute instance-attribute

building_max_zoom: int = 16

Buildings are only ever seen extruded, which is a high-zoom view; tiling them from z10 would triple the tileset for pixels nobody looks at.

include_buildings class-attribute instance-attribute

include_buildings: bool = False

Whether to tile the building footprints. Off by default, and the default is measured: 891 994 Berlin footprints tile to tens of megabytes, two to three times the rest of the site combined. A reader who wants extrusions can turn it on and pay for it knowingly.

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 carried at every zoom, because a choropleth needs them while the map is zoomed out. Everything else in the viz table rides in a second tileset built at the maximum zoom only.

The split exists because MVT repeats the whole attribute table in every tile at every zoom: at metropolitan scale a 38-column unit table costs more tiled than 892 000 building footprints do. Columns named here that a run did not produce are skipped rather than raising — a run over a small extent can legitimately lack a parameter.

render_column_prefixes class-attribute instance-attribute

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

Column families carried at every zoom, named by prefix because their members are not known until a run has chosen a cascade.

The height tier fractions are height_frac_<source> for whichever sources fired — wsf3d, ghsl, unresolved, and the two Overture tiers — so render_columns cannot name them without naming a cascade. They belong at every zoom rather than in the click-detail tileset because they are first-class layers and not diagnostics: a selectable layer must paint while the map is zoomed out, and it must paint from tiles already in memory, since a view change that refetched would make the site slow.

detail_max_features class-attribute instance-attribute

detail_max_features: int = 200000

Above this many units the click-detail tileset is skipped and the sidebar falls back to the render attributes. A guard on the one part of the site whose size is unbounded in extent.

online_basemaps class-attribute instance-attribute

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

Remote raster grounds the site offers, by key from lczkit.viz.basemaps.PROVIDERS.

Empty by default, and that default is load-bearing rather than cautious. A built site opens with no network and stays valid years from now, which is what makes one archivable beside a paper; a remote tile source breaks both of those, and outlives the run only as long as the provider chooses to serve it. With this empty the emitted site contains no external reference at all, and a test asserts it.

Name one or several and the site gains a base-map picker: each is a selectable ground under the units, drawn beneath everything else, hidden until chosen, with the provider's attribution shown and its tiles treated as fallible — a failure leaves every other layer working and says so. The run's own Overture water and streets stay in the site and stay switched on, so an archived copy opened offline still shows the linework the classification was computed from.

Order is the order they appear in the picker. Selecting a provider takes on its tile usage policy, and two of them need an API key — see each one's terms.

online_basemap class-attribute instance-attribute

online_basemap: str | None = None

Deprecated single-value spelling of online_basemaps, folded into the front of it.

Kept because it is what runs before this field existed recorded in their manifests, and build_site re-validates an archived manifest to rebuild a site. Pydantic ignores unknown fields by default, so dropping this would make an archived run's configured ground disappear on rebuild with nothing raised — a silent change to an artefact, which is worse than a stale name. Read basemap_keys, never this field.

maptiler_key class-attribute instance-attribute

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

The MapTiler API key, resolved from MAPTILER_API_KEY by Settings.load().

exclude=True is the whole point of the field's shape. The manifest is settings.model_dump() verbatim, and lczkit.viz.build_site copies the manifest into the published site, so an ordinary field would put the key in two shipped files rather than the one that needs it. Excluded, it reaches style.json's tile URLs and nothing else, and a test asserts the manifest never carries it.

That bounds the exposure; it does not remove it. MapLibre fetches tiles from the browser, so the key is in plain text in any site built with a MapTiler ground, and anyone holding the directory holds the key. Restrict it by origin at the provider, or hand out a site built without one.

basemap_keys property

basemap_keys: list[str]

The configured provider keys in picker order, with the deprecated singular folded in.

The single place the two spellings are reconciled, so nothing downstream has to know that online_basemap exists.

UnitsConfig

Bases: BaseModel

Which spatial units the pipeline computes on.

There is no auto-selection, and in particular none by region. Enclosures lead the grid on both criteria outside Europe and North America and lose inside it, but region is not the mechanism — natural-class share and patch heterogeneity are — so a rule keyed on continent would be wrong at every boundary. The trade-off is documented, the choice is the caller's, and which strategy 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 published LCZ map, validation dataset and WRF workflow uses, and what every published figure here is measured on.
  • enclosure — street-, rail- and water-bounded blocks. Measured against the grid over fifteen cities and not adopted: built-class agreement +3.8 (12/15) but overall −0.2 (8/15), and adoption required a lead on both.
  • patch — enclosure seeds merged to LCZ-patch scale. See lczkit.units.patches; the block is a much smaller object than a patch (median 0.04 ha against WUDAPT's 2.2–52 ha), and this is the strategy that addresses that rather than assuming a thinner barrier set will.

cell_size_m class-attribute instance-attribute

cell_size_m: float = 100.0

Grid cell side. 100 m is not arbitrary — it is what the validation references and the downstream WRF tooling assume — so moving it makes a run incomparable with a published figure.

patch_min_area_m2 class-attribute instance-attribute

patch_min_area_m2: float = 50000.0

strategy="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

strategy="patch" only. None removes the ceiling, which lets a merge chain swallow a whole estate into one unit.

patch_merge_on_morphology class-attribute instance-attribute

patch_merge_on_morphology: bool = True

Whether the patch merge compares neighbours on building surface fraction and height, or merges on size alone. On costs one overlay against the building layer and is what makes a patch homogeneous rather than merely large; off is supported and worse, and needs no buildings.

drop_pedestrian_barriers class-attribute instance-attribute

drop_pedestrian_barriers: bool = True

Exclude footway/steps/path/cycleway/bridleway from the barrier set for enclosure and patch. On by default: these are 50–73% of the mapped network in Berlin, Hong Kong and Milan and 3.5–7.5% elsewhere, so leaving them in makes the partition largely a measure of how thoroughly a city's footpaths have been surveyed. Ignored by grid, which takes no barriers.

WudaptConfig

Bases: BaseModel

The WUDAPT LCZ training areas, the third reference and the only globally available one.

A vector product rather than a raster, so unlike _default_reference_dataset it is not a LandCoverDatasetConfig — there is no exactextract path to reuse. See lczkit.validation.wudapt for what each of these gates costs.

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 LCZ Generator export, e.g. LCZ-Generator_training_areas_2024-10-01.gpkg.

Unset by default and deliberately not defaulted to whatever is on disk, for the same reason OvertureConfig.release refuses to track "latest": the export is dated, contributors keep adding to it, and a validation figure that does not name the export it was measured against cannot be reproduced. A caller pins it.

layer class-attribute instance-attribute

layer: str | None = None

GeoPackage layer. None takes the first, which is correct for the published export — its only other layer is a QGIS layer_styles table carrying no geometry.

class_column class-attribute instance-attribute

class_column: str = 'class'

WUDAPT's own LCZ column. Integer, agreeing with Demuzere's coding over 1-17.

require_qc class-attribute instance-attribute

require_qc: bool = False

Keep only polygons passing all three LCZ Generator QC flags.

Off by default, and measured rather than assumed. The gate is expensive — over 200 000 polygons the three flags pass individually at 62.3% / 84.0% / 81.5% and jointly at 48.2%, so it halves the reference — and what it buys, measured against So2Sat labels on the 30 km windows, is Cairo 26.3% → 26.7%, Mumbai 47.4% → 50.3%, Jakarta 70.7% → 68.8%. Inert on two cities and harmful on the third, for half the labelled ground.

WudaptSelection.qc_pass_fraction reports the rate whether or not the gate is on, so the cost of changing this is visible without a second run.

min_oa class-attribute instance-attribute

min_oa: float | None = None

Minimum LCZ Generator overall accuracy for the polygon's submission. None disables.

A property of the submission, not of the polygon — see wudapt.priority_order — and it does not select for what a validation reference needs. Gating at 0.7 moves agreement with So2Sat by Cairo 26.3% → 19.1%, Mumbai 47.4% → 47.3%, Jakarta 70.7% → 69.6%: worse on all three, and much worse on the city that needed help most. The LCZ Generator's cross-validated accuracy scores a submission against itself, so a high oa means a self-consistent contributor rather than one who agrees with an independent expert.

min_area_m2 class-attribute instance-attribute

min_area_m2: float = 0.0

Drop polygons below this. 4.7% of the file is under 1000 m², smaller than a 100 m cell, and five features have exactly zero area.

max_area_m2 class-attribute instance-attribute

max_area_m2: float | None = None

Drop polygons above this. The largest in the file is 18 680 km² — a whole sea digitised as one LCZ G polygon — which is a legitimate label and a poor reference for a city window.

citation class-attribute instance-attribute

citation: str = '10.3390/ijgi4010199'

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

Recorded apart from reference_citation and ground_truth_citation because which file filled the reference role is part of the measurement. WUDAPT is additionally not independent of lcz_v3 — these training areas are the training data behind the Demuzere global map — so agreement between the two is not a ceiling in the sense So2Sat gives one.

ValidationConfig

Bases: BaseModel

Configuration for agreement against a reference LCZ map.

Agreement is reported in the style of the lczexplore package: per-class figures and a confusion matrix, never a single headline number.

reference class-attribute instance-attribute

reference: LandCoverDatasetConfig = Field(default_factory=_default_reference_dataset)

The reference map, described as a categorical raster. See _default_reference_dataset.

reference_citation class-attribute instance-attribute

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

Demuzere et al. (2022), ESSD 14, 3835-3873. Recorded separately from the file actually read: the copy on this system is version 3 of the map and the paper describes an earlier one, so conflating the two in the manifest would misstate what a run was validated against.

ground_truth_citation class-attribute instance-attribute

ground_truth_citation: str = '10.1109/MGRS.2020.2964708'

Zhu et al. (2020), IEEE GRSM 8(3), 76-89. So2Sat LCZ42, the hand-labelled reference.

Recorded apart from reference_citation because the two are not the same kind of thing: lcz_v3 is a model output with its own error, these are human labels. Where both are available the labels are primary and lcz_v3 is a comparator whose agreement with them is the ceiling on any score against it.

wudapt class-attribute instance-attribute

wudapt: WudaptConfig = Field(default_factory=WudaptConfig)

The WUDAPT training areas — hand labels too, but a different kind of object from So2Sat: irregular, overlapping, spanning four decades, and covering every city this package has been run on rather than the 51 So2Sat sampled. See WudaptConfig and lczkit.validation.wudapt.

min_reference_coverage class-attribute instance-attribute

min_reference_coverage: float = 0.5

Fraction of a unit the reference map must actually cover for that unit to enter the agreement statistics. A unit half outside the map's extent would otherwise contribute a majority computed from a corner of itself.

height_completeness_deciles class-attribute instance-attribute

height_completeness_deciles: int = 10

Strata for the height-completeness breakdown. Deciles by default, configurable so a run with few units can widen the bins rather than report noise.

Settings

Bases: BaseModel

Resolved configuration for a single lczkit run.

Construct via Settings.load(), not directly — that is what resolves DATA_DIR from the environment and creates the run's output directory.

input_dir property

input_dir: Path

$DATA_DIR/input/ — organised by data origin, owned by other projects too.

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 output directory.

tile_cache_dir property

tile_cache_dir: Path

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

Not under input/, deliberately. Writes to input/ are confined to the source implementation owning each subdirectory, and nothing else in the package writes there at all. A simplified tile is derived by lczkit's own cleaning from data a source already fetched — it is not source data, and input/ may be shared with other projects that must not have to reason about lczkit's intermediates.

The cache therefore lives in lczkit's own output tree, a sibling of the run directories rather than inside any one of them, since a tile outlives the run that computed it. Cache keys carry the same discipline OvertureSource uses — see lczkit.cleaning.pipeline.tile_fingerprint.

source_dir

source_dir(name: str) -> Path

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

Only the source implementation for name writes here; nothing else in the package writes under input/ at all.

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

    Only the source implementation for `name` writes here; nothing else in the package
    writes under `input/` at all.
    """
    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>/ if absent.

Also picks up GEE_PROJECT_NAME into land_cover.gee_project, and MAPTILER_API_KEY into viz.maptiler_key. Unlike DATA_DIR both are optional — only the Earth Engine backend and the MapTiler base maps need them, and each raises its own message when it is missing — so an absent value is not an error here. An absent variable leaves whatever is already configured alone: assigning os.environ.get(...) unconditionally would overwrite a value supplied by a config file with None, which is a silent discard rather than a precedence rule.

create_run_dir=False resolves everything and touches nothing, for callers that only want to read the resolved configuration — lczkit run --dry-run. The default creates it, because every caller that goes on to run a pipeline needs it to exist.

Never creates or modifies anything under input/. Raises ValueError with a clear message if DATA_DIR is unset; raises a pydantic.ValidationError (also with a clear message) if it is set but 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>/` if absent.

    Also picks up `GEE_PROJECT_NAME` into `land_cover.gee_project`, and `MAPTILER_API_KEY` into
    `viz.maptiler_key`. Unlike `DATA_DIR` both are optional — only the Earth Engine backend and
    the MapTiler base maps need them, and each raises its own message when it is missing — so an
    absent value is not an error here. **An absent variable leaves whatever is already
    configured alone**: assigning `os.environ.get(...)` unconditionally would overwrite a value
    supplied by a config file with `None`, which is a silent discard rather than a precedence
    rule.

    `create_run_dir=False` resolves everything and touches nothing, for callers that only want
    to *read* the resolved configuration — `lczkit run --dry-run`. The default creates it,
    because every caller that goes on to run a pipeline needs it to exist.

    Never creates or modifies anything under `input/`. Raises `ValueError` with a clear
    message if `DATA_DIR` is unset; raises a `pydantic.ValidationError` (also with a
    clear message) if it is set but 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 it is unset or blank.

Separate from Settings.load because lczkit site build needs the key and does not need DATA_DIR: it works off a run directory given on the command line, and Settings.load raises without DATA_DIR. Requiring one to get the other would make rebuilding an archived site depend on an unrelated variable. Settings.load calls this too, so there is one definition of where the key comes from.

The strip is not cosmetic. A trailing space in a .env line is invisible in an editor and survives into the tile URL, where it makes every request 403 — a broken base map whose cause is unreadable from the symptom.

It reads the environment, which the rest of the package must not do: os.environ is touched in this module and nowhere else.

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 it is unset or blank.

    Separate from `Settings.load` because `lczkit site build` needs the key and does **not** need
    `DATA_DIR`: it works off a run directory given on the command line, and `Settings.load` raises
    without `DATA_DIR`. Requiring one to get the other would make rebuilding an archived site
    depend on an unrelated variable. `Settings.load` calls this too, so there is one definition of
    where the key comes from.

    The strip is not cosmetic. A trailing space in a `.env` line is invisible in an editor and
    survives into the tile URL, where it makes every request 403 — a broken base map whose cause is
    unreadable from the symptom.

    It reads the environment, which the rest of the package must not do: `os.environ` is touched
    in this module and nowhere else.
    """
    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 settings a run needs that have no safe default.

Settings.load() cannot produce a runnable configuration on its own, and that is deliberate. CleaningConfig's eight numeric fields and HeightConfig's two confidences all default to None and raise at call time, because each is a threshold someone measured and an invented default would travel into every run's manifest looking like a measurement. See those models for the argument.

A preset is where those values live, so that lczkit run and the published sites cannot drift apart. Modelled on lczkit.classify.weights, which has the same shape for the weight vectors.

One preset, and that is the honest number. published is what the three published sites were built with. A second name would imply a second measured configuration exists.

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 — the same standing as the two Overture confidences beside them, and set here rather than defaulted in lczkit.config for exactly the reason HeightConfig gives: an invented default would travel into every run's manifest as if it were measured. The choice is recorded in the manifest where it is visible.

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.

Each section is copied rather than shared, so two runs configured from one preset cannot mutate each other's settings through it.

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

    Each section is copied rather than shared, so two runs configured from one preset cannot
    mutate each other's settings through it.
    """
    settings.overture.release = self.overture_release
    settings.cleaning = self.cleaning.model_copy(deep=True)
    settings.heights = self.heights.model_copy(deep=True)
    settings.land_cover = self.land_cover.model_copy(deep=True)
    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)