Skip to content

Protocols

The five seams of the package, as typing.Protocol definitions. Implementations are structural — nothing subclasses these — so the behavioural contract is stated once, here, and each implementation's own docstring says what it does differently rather than restating the interface.

One implementation per protocol is the right number for now. The seam is the point, not the number of implementations.

lczkit.protocols

Pluggable-source protocols for the lczkit pipeline.

Every stage after spatial-unit generation exchanges data as a GeoDataFrame indexed by a stable string unit_id. These five protocols are the seams between the pipeline and its data sources. There is one implementation of each; the seam is the point, not the number of implementations.

No implementations live here. Each protocol states the contract once, and implementations satisfy it structurally rather than by subclassing.

BBox module-attribute

BBox = tuple[float, float, float, float]

A bounding box as (minx, miny, maxx, maxy) in EPSG:4326.

VectorSource

Bases: Protocol

Supplies cleaned vector layers for a bounding box.

Implementations own their cache under settings.source_dir(<name>) and must pin any upstream release/version explicitly rather than tracking "latest".

buildings

buildings(bbox: BBox) -> GeoDataFrame

Return building footprints intersecting bbox.

Columns include at least geometry, height (nullable), num_floors (nullable), subtype and class (usage type), and sources (upstream provenance metadata).

height and num_floors are nullable by design — upstream vector sources conflate footprints from several datasets and only some of them carry height at all. A null height is never an error at this layer; the height cascade owns it. subtype, class and sources must survive cleaning: class is the only route to LCZ 10, and sources drives the source-availability diagnostic.

Source code in src/lczkit/protocols.py
def buildings(self, bbox: BBox) -> gpd.GeoDataFrame:
    """Return building footprints intersecting `bbox`.

    Columns include at least `geometry`, `height` (nullable), `num_floors` (nullable),
    `subtype` and `class` (usage type), and `sources` (upstream provenance metadata).

    `height` and `num_floors` are nullable *by design* — upstream vector sources conflate
    footprints from several datasets and only some of them carry height at all. A null
    height is never an error at this layer; the height cascade owns it. `subtype`, `class` and
    `sources` must survive cleaning: `class` is the only route to LCZ 10, and `sources` drives
    the source-availability diagnostic.
    """
    ...

streets

streets(bbox: BBox) -> GeoDataFrame

Return road-network segments intersecting bbox, as LineStrings.

Source code in src/lczkit/protocols.py
def streets(self, bbox: BBox) -> gpd.GeoDataFrame:
    """Return road-network segments intersecting `bbox`, as LineStrings."""
    ...

water

water(bbox: BBox) -> tuple[GeoDataFrame, GeoDataFrame]

Return (waterlines, waterbodies) intersecting bbox.

Waterlines are LineStrings, waterbodies are Polygons.

Source code in src/lczkit/protocols.py
def water(self, bbox: BBox) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
    """Return `(waterlines, waterbodies)` intersecting `bbox`.

    Waterlines are LineStrings, waterbodies are Polygons.
    """
    ...

rail

rail(bbox: BBox) -> GeoDataFrame

Return rail-network segments intersecting bbox, as LineStrings.

EnclosureUnits needs rail as a barrier layer alongside streets and waterbodies. Nothing else in the pipeline reads it.

Source code in src/lczkit/protocols.py
def rail(self, bbox: BBox) -> gpd.GeoDataFrame:
    """Return rail-network segments intersecting `bbox`, as LineStrings.

    `EnclosureUnits` needs rail as a barrier layer alongside streets and waterbodies. Nothing
    else in the pipeline reads it.
    """
    ...

land_use

land_use(bbox: BBox) -> GeoDataFrame

Return land-use polygons intersecting bbox, retaining subtype and class.

Functional semantics only. This layer supplies the industrial share of a unit's area — industrial_fraction, which the LCZ 8/10 rule reads — and nothing else. It is not a barrier for spatial-unit generation and not a land-cover source — rasters own land cover.

Source code in src/lczkit/protocols.py
def land_use(self, bbox: BBox) -> gpd.GeoDataFrame:
    """Return land-use polygons intersecting `bbox`, retaining `subtype` and `class`.

    Functional semantics only. This layer supplies the industrial share of a unit's area —
    `industrial_fraction`, which the LCZ 8/10 rule reads — and nothing else. It is **not** a
    barrier for spatial-unit generation and **not** a
    land-cover source — rasters own land cover.
    """
    ...

HeightSource

Bases: Protocol

One tier of the building-height cascade.

A cascade runs a sequence of HeightSource tiers over the same buildings layer; each tier fills height only for the rows it can resolve, tagging every row it touches with height_source and height_confidence.

name property

name: str

Short identifier for this tier, used to label it in the cascade report.

height_sources property

height_sources: tuple[str, ...]

Every height_source tag this tier can write.

Usually one, matching name — but a tier resolving a row by more than one route distinguishes them here, so the per-unit tier fractions can report a fixed set of columns determined by the configured cascade rather than by which routes happened to fire on a given city.

fill

fill(buildings: GeoDataFrame) -> GeoDataFrame

Populate height, height_source and height_confidence where this tier can.

Returns buildings with those three columns filled for every row it resolves.

Rows this tier cannot resolve are returned unchanged (still nullable) for the next tier in the cascade.

Source code in src/lczkit/protocols.py
def fill(self, buildings: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Populate `height`, `height_source` and `height_confidence` where this tier can.

    Returns `buildings` with those three columns filled for every row it resolves.

    Rows this tier cannot resolve are returned unchanged (still nullable) for the next
    tier in the cascade.
    """
    ...

RasterSource

Bases: Protocol

Supplies zonal land-cover fractions keyed by unit_id — never raw pixels.

fractions

fractions(units: GeoDataFrame) -> DataFrame

Return a table indexed by unit_id with one fraction column per land-cover class.

Fractions must sum to ~1.0 per unit. The class-to-fraction mapping is a config value, never hardcoded.

Source code in src/lczkit/protocols.py
def fractions(self, units: gpd.GeoDataFrame) -> pd.DataFrame:
    """Return a table indexed by `unit_id` with one fraction column per land-cover class.

    Fractions must sum to ~1.0 per unit. The class-to-fraction mapping is a config value,
    never hardcoded.
    """
    ...

SpatialUnitStrategy

Bases: Protocol

Partitions a city into the spatial units the rest of the pipeline joins on.

generate

generate(bbox: BBox, barriers: GeoDataFrame | None = None) -> GeoDataFrame

Return unit polygons indexed by a stable string unit_id, covering bbox.

barriers (streets, rail, waterbodies, large vegetation patches) constrain enclosure-based strategies; grid-based strategies may ignore it.

Source code in src/lczkit/protocols.py
def generate(self, bbox: BBox, barriers: gpd.GeoDataFrame | None = None) -> gpd.GeoDataFrame:
    """Return unit polygons indexed by a stable string `unit_id`, covering `bbox`.

    `barriers` (streets, rail, waterbodies, large vegetation patches) constrain
    enclosure-based strategies; grid-based strategies may ignore it.
    """
    ...

Classifier

Bases: Protocol

Classifies spatial units into Local Climate Zones by distance to LCZ prototypes.

classify

classify(parameters: DataFrame) -> DataFrame

Return a table indexed by unit_id carrying the full 17-way distance vector.

One distance per LCZ prototype, plus lcz_primary, lcz_secondary, and uniqueness.

Never collapse to a bare integer label here — that is a downstream convenience function, not part of the core classification output.

Source code in src/lczkit/protocols.py
def classify(self, parameters: pd.DataFrame) -> pd.DataFrame:
    """Return a table indexed by `unit_id` carrying the full 17-way distance vector.

    One distance per LCZ prototype, plus `lcz_primary`, `lcz_secondary`, and `uniqueness`.

    Never collapse to a bare integer label here — that is a downstream convenience
    function, not part of the core classification output.
    """
    ...

Coordinate reference system

All internal computation happens in a projected CRS. This is checked on entry to each stage rather than left as a convention, because a geographic CRS makes every area statistic meaningless without raising anything.

lczkit.crs

CRS enforcement for lczkit's internal computation.

All internal computation happens in a projected CRS obtained via gdf.estimate_utm_crs(). Lat/lon appears only at ingestion and export boundaries — this helper is the single place that enforces it, rather than relying on a docstring convention.

local_utm_crs

local_utm_crs(bbox: BBox) -> CRS

The single UTM CRS to use for all internal computation over bbox.

Computed from bbox itself (in EPSG:4326), not from any individual layer — estimate_utm_crs() can differ between layers covering nearly the same area (e.g. near a UTM zone boundary), which would silently break cross-layer operations if each layer picked its own zone. Every stage working over the same bbox should call this once and share the result rather than re-deriving it.

Source code in src/lczkit/crs.py
def local_utm_crs(bbox: BBox) -> CRS:
    """The single UTM CRS to use for all internal computation over `bbox`.

    Computed from `bbox` itself (in EPSG:4326), not from any individual layer —
    `estimate_utm_crs()` can differ between layers covering nearly the same area (e.g. near a
    UTM zone boundary), which would silently break cross-layer operations if each layer picked
    its own zone. Every stage working over the same bbox should call this once and share the
    result rather than re-deriving it.
    """
    return gpd.GeoSeries([box(*bbox)], crs="EPSG:4326").estimate_utm_crs()

assert_projected_crs

assert_projected_crs(gdf: GeoDataFrame, name: str = 'gdf') -> None

Raise ValueError unless gdf has a projected CRS.

Call this at the entry point of any function that performs internal geometric computation (areas, lengths, buffers, distances) — those are meaningless in a geographic CRS.

Source code in src/lczkit/crs.py
def assert_projected_crs(gdf: gpd.GeoDataFrame, name: str = "gdf") -> None:
    """Raise `ValueError` unless `gdf` has a projected CRS.

    Call this at the entry point of any function that performs internal geometric
    computation (areas, lengths, buffers, distances) — those are meaningless in a
    geographic CRS.
    """
    if gdf.crs is None:
        raise ValueError(f"{name} has no CRS set; expected a projected CRS.")
    if not gdf.crs.is_projected:
        raise ValueError(
            f"{name} has geographic CRS {gdf.crs.to_string()!r}; expected a projected CRS "
            "(e.g. via gdf.estimate_utm_crs())."
        )