Skip to content

Land cover

Land cover: zonal class fractions per unit_id, never pixels.

Two interchangeable backends behind the RasterSource protocol — LocalRasterSource over a COG on disk, EarthEngineSource over reduceRegions — returning schema-identical tables because both reduce the same class-index mapping declared once in LandCoverDatasetConfig.

Classes within one dataset are disjoint and their fractions sum to 1.0 over the cells that count. That matters for one thing in particular: the default WorldCover mapping carves tree out of pervious, whereas Stewart & Oke (2012) count trees within the pervious surface fraction (LCZ A, dense trees, is 90%+ pervious). A consumer reproducing their parameter must add frac_tree back into frac_pervious.

RasterSource returns a table of land-cover shares per spatial unit — never pixels. Two implementations sit behind one interface: a local cloud-optimised GeoTIFF read with exactextract, which is what continuous integration tests against, and a Google Earth Engine reduction computed on Google's servers.

The class-to-fraction mapping is config, never hardcoded. Reading a product's own class definitions and putting them in config is the difference between a reproducible run and a plausible-looking wrong one.

This layer is also what supplies every parameter separating the natural classes A–G, so a raster that does not cover the whole window is not a cosmetic problem: it produces missing values rather than an error.

lczkit.landcover.local

LocalRasterSource: land-cover fractions from a COG on disk.

The offline RasterSource implementation, and the one continuous integration tests against — Earth Engine authentication in CI is not worth the pain, so the offline path is the one that has to be right.

The reduction is exactextract, which weights each cell by the exact fraction of it the unit polygon covers. That is a real improvement over the all_touched rasterization the height cascade uses: a 100 m unit against a 10 m product has ~40 boundary cells out of ~100, and counting each of those as either wholly in or wholly out would be a several-percent error on every unit.

The user supplies the COG; nothing here downloads, and nothing here writes under input/.

LocalRasterSource

LocalRasterSource(config: LandCoverDatasetConfig, path: Path, *, max_raster_cells: int = 200000000)

Zonal land-cover fractions for a units layer, from one local raster.

Bind the source to one raster and the class mapping to read it through.

max_raster_cells caps how much of the raster a single read may pull into memory; it is a guard against a units layer whose bounds quietly span a continental product, not a tuning knob.

Source code in src/lczkit/landcover/local.py
def __init__(
    self,
    config: LandCoverDatasetConfig,
    path: Path,
    *,
    max_raster_cells: int = 200_000_000,
) -> None:
    """Bind the source to one raster and the class mapping to read it through.

    `max_raster_cells` caps how much of the raster a single read may pull into memory; it is
    a guard against a units layer whose bounds quietly span a continental product, not a
    tuning knob.
    """
    self.config = config
    self.path = path
    self.max_raster_cells = max_raster_cells
    self._classes = ClassIndex(config)

name property

name: str

The dataset name, which is what the fraction columns are prefixed with downstream.

from_settings classmethod

from_settings(settings: Settings, name: str) -> LocalRasterSource

Build the source for the configured dataset name.

Raises the same way build_cascade does for a height tier: an unset filename means the product is simply not available and there is nothing to build; a filename naming a file that is not there is a misconfiguration and says so.

Source code in src/lczkit/landcover/local.py
@classmethod
def from_settings(cls, settings: Settings, name: str) -> LocalRasterSource:
    """Build the source for the configured dataset `name`.

    Raises the same way `build_cascade` does for a height tier: an unset `filename` means the
    product is simply not available and there is nothing to build; a `filename` naming a file
    that is not there is a misconfiguration and says so.
    """
    config = settings.land_cover.dataset(name)
    if config.filename is None:
        raise ValueError(
            f"Land-cover dataset {name!r} has no filename configured, so no local raster "
            f"exists to read. Place the product under "
            f"{settings.source_dir(config.source_dir_name)} and set "
            f"`settings.land_cover.dataset({name!r}).filename`."
        )
    path = settings.source_dir(config.source_dir_name) / config.filename
    if not path.is_file():
        raise FileNotFoundError(
            f"Land-cover dataset {name!r} is configured to read {path}, which does not exist."
        )
    return cls(config, path, max_raster_cells=settings.land_cover.max_raster_cells)

fractions

fractions(units: GeoDataFrame) -> DataFrame

Class fractions per unit_id, one column per class in config.classes.

Fractions sum to 1.0 for every unit with counted cells; units the raster cannot answer for — outside its extent, or covering only nodata — come out all-NaN.

Coverage is computed in the raster's CRS rather than the units' projected one, because exactextract does not reproject and warping a categorical raster would resample classes. That is safe here specifically because the output is a ratio of coverage within a single unit: cell area varies with latitude, but negligibly across the span of one unit, so it cancels. It would not be safe if this returned areas.

Source code in src/lczkit/landcover/local.py
def fractions(self, units: gpd.GeoDataFrame) -> pd.DataFrame:
    """Class fractions per `unit_id`, one column per class in `config.classes`.

    Fractions sum to 1.0 for every unit with counted cells; units the raster cannot answer for
    — outside its extent, or covering only nodata — come out all-`NaN`.

    Coverage is computed in the *raster's* CRS rather than the units' projected one, because
    `exactextract` does not reproject and warping a categorical raster would resample classes.
    That is safe here specifically because the output is a ratio of coverage within a single
    unit: cell area varies with latitude, but negligibly across the span of one unit, so it
    cancels. It would not be safe if this returned areas.
    """
    check_units(units)
    empty = fractions_table(pd.DataFrame(), self.config, units.index)
    if units.empty:
        return empty

    with rasterio.open(self.path) as src:
        if src.crs is None:
            raise ValueError(f"{self.path} declares no CRS; cannot align it to the units.")
        projected = gpd.GeoSeries(units.geometry).to_crs(CRS.from_user_input(src.crs.to_wkt()))
        # A null or empty unit geometry is not an error — it simply has no coverage, and
        # `exactextract` refuses to parse it — so it is dropped here and reappears as an
        # all-null row when `fractions_table` reindexes back onto `units.index`.
        usable = gpd.GeoSeries(projected.loc[projected.notna() & ~projected.is_empty])
        if usable.empty:
            return empty

        window = covering_window(src, np.asarray(usable.total_bounds))
        if window is None:
            return empty
        cells = int(window.width) * int(window.height)
        if cells > self.max_raster_cells:
            raise ValueError(
                f"Reading {self.path} over these units needs a {window.width}x{window.height} "
                f"= {cells:,} cell window, over the {self.max_raster_cells:,} cell limit. "
                "Raise `settings.land_cover.max_raster_cells` or process fewer units at once."
            )
        values = src.read(self.config.band, window=window)
        nodata = self.config.nodata
        if nodata is None:
            nodata = src.nodatavals[self.config.band - 1]
        bounds = src.window_bounds(window)
        srs_wkt = src.crs.to_wkt()

    indices = self._classes.apply(values, nodata=nodata)
    raster = NumPyRasterSource(indices, *bounds, nodata=EXCLUDED, srs_wkt=srs_wkt)
    extracted = exact_extract(
        raster,
        gpd.GeoDataFrame(geometry=usable),
        ["unique", "frac"],
        output="pandas",
    )
    return fractions_table(
        _counts_from_extract(extracted, usable.index, len(self._classes.names)),
        self.config,
        units.index,
    )

Earth Engine

Computed server-side with reduceRegions, returning tables rather than pixels. Units are chunked into batches to stay under Earth Engine's element-count and payload limits, and results are cached on a hash of the unit geometries, collection ID, date range and reducer together.

lczkit.landcover.earthengine

EarthEngineSource: the same land-cover fractions, computed server-side.

Identical interface and identical output schema to LocalRasterSource. The two agree because they reduce the same LandCoverDatasetConfig: the class mapping is applied here as a server-side remap() or threshold chain built from ClassIndex, not written out a second time.

Reaching Earth Engine needs credentials and a billable project (GEE_PROJECT_NAME in .env), so every test that makes a live call is marked network and skipped by default; CI stays offline.

The chunking, cache key, row placement and histogram normalisation are module-level pure functions, so the logic that decides whether a live call is correct is testable without credentials — and the live path itself is covered by tests/test_landcover_earthengine_live.py, which checks it against LocalRasterSource on the same units.

REDUCER module-attribute

REDUCER = 'frequencyHistogram'

The only reducer this backend uses. Named explicitly because it is part of the cache key, which is (unit geometries, collection ID, date range, reducer).

ROW_PROPERTY module-attribute

ROW_PROPERTY = 'lczkit_row'

Feature property carrying a unit's row position out to Earth Engine and back.

Earth Engine does not document reduceRegions as order-preserving, so results are placed by this rather than by arrival order. A silently permuted result would attach every unit's land cover to a different unit, and nothing downstream would notice — every fraction would still sum to 1.0.

EarthEngineSource

EarthEngineSource(config: LandCoverDatasetConfig, *, project: str | None, cache_dir: Path, batch_size: int = 2000, max_units: int | None = None)

Zonal land-cover fractions for a units layer, computed by Earth Engine.

Bind to a dataset, project and cache directory, and initialise Earth Engine.

Refuses both an absent project and a dataset with no asset configured, rather than guessing an asset ID — the failure mode of a wrong one is a plausible fractions table computed over the wrong imagery. batch_size keeps reduceRegions under Earth Engine's element-count and payload caps; max_units bounds a request that would exceed them anyway.

Source code in src/lczkit/landcover/earthengine.py
def __init__(
    self,
    config: LandCoverDatasetConfig,
    *,
    project: str | None,
    cache_dir: Path,
    batch_size: int = 2000,
    max_units: int | None = None,
) -> None:
    """Bind to a dataset, project and cache directory, and initialise Earth Engine.

    Refuses both an absent project and a dataset with no asset configured, rather than
    guessing an asset ID — the failure mode of a wrong one is a plausible fractions table
    computed over the wrong imagery. `batch_size` keeps `reduceRegions` under Earth Engine's
    element-count and payload caps; `max_units` bounds a request that would exceed them
    anyway.
    """
    self.config = config
    self.cache_dir = cache_dir
    self.batch_size = batch_size
    self.max_units = max_units
    self._classes = ClassIndex(config)
    self._ee = _import_ee()

    if project is None:
        raise ValueError(
            "No Earth Engine project is set. Put GEE_PROJECT_NAME in .env, or set "
            "`settings.land_cover.gee_project` explicitly."
        )
    gee = config.gee
    missing = [field for field in gee.required_fields() if getattr(gee, field) is None]
    if missing:
        raise ValueError(
            f"Land-cover dataset {config.name!r} has no Earth Engine asset configured "
            f"(missing: {', '.join(missing)}). Set them on "
            f"`settings.land_cover.dataset({config.name!r}).gee`. This package will not guess "
            "an asset ID."
        )
    self.project = project
    self._ee.Initialize(project=project)

name property

name: str

The dataset name, which is what the fraction columns are prefixed with downstream.

from_settings classmethod

from_settings(settings: Settings, name: str) -> EarthEngineSource

Build the source for the configured dataset name, caching under input/GEE/.

Source code in src/lczkit/landcover/earthengine.py
@classmethod
def from_settings(cls, settings: Settings, name: str) -> EarthEngineSource:
    """Build the source for the configured dataset `name`, caching under `input/GEE/`."""
    land_cover = settings.land_cover
    return cls(
        land_cover.dataset(name),
        project=land_cover.gee_project,
        cache_dir=settings.source_dir("GEE"),
        batch_size=land_cover.gee_batch_size,
        max_units=land_cover.gee_max_units,
    )

cache_path

cache_path(units: GeoDataFrame) -> Path

Where this reduction's result lives under input/GEE/.

Keyed on the unit geometries, asset, date range, reducer and class mapping together, so a run that changes any one of them cannot be served a result computed under the others.

Source code in src/lczkit/landcover/earthengine.py
def cache_path(self, units: gpd.GeoDataFrame) -> Path:
    """Where this reduction's result lives under `input/GEE/`.

    Keyed on the unit geometries, asset, date range, reducer and class mapping together, so
    a run that changes any one of them cannot be served a result computed under the others.
    """
    return self.cache_dir / f"{self.config.name}_{cache_key(units, self.config)}.parquet"

fractions

fractions(units: GeoDataFrame) -> DataFrame

Class fractions per unit_id, schema-identical to LocalRasterSource.fractions().

A cached result for these exact units, asset, date range, reducer and class mapping is returned without touching Earth Engine — a cache hit is just a file that is already there. Cached files are written once and never rewritten; input/ is shared with other projects.

Source code in src/lczkit/landcover/earthengine.py
def fractions(self, units: gpd.GeoDataFrame) -> pd.DataFrame:
    """Class fractions per `unit_id`, schema-identical to `LocalRasterSource.fractions()`.

    A cached result for these exact units, asset, date range, reducer and class mapping is
    returned without touching Earth Engine — a cache hit is just a file that is already there.
    Cached files are written once and never rewritten; `input/` is shared with other projects.
    """
    check_units(units)
    if units.empty:
        return fractions_table(pd.DataFrame(), self.config, units.index)
    if self.max_units is not None and len(units) > self.max_units:
        raise ValueError(
            f"{len(units)} units exceeds settings.land_cover.gee_max_units "
            f"({self.max_units}). Raise the ceiling or reduce the study area — an unbounded "
            "reduceRegions run is exactly what that setting exists to prevent."
        )

    path = self.cache_path(units)
    if path.exists():
        cached = pd.read_parquet(path)
        return cached.reindex(index=units.index)

    histograms = self._reduce(units)
    result = fractions_table(
        counts_from_histograms(
            histograms,
            units.index,
            len(self._classes.names),
            dataset_name=self.config.name,
        ),
        self.config,
        units.index,
    )
    path.parent.mkdir(parents=True, exist_ok=True)
    result.to_parquet(path)
    return result

place_by_row

place_by_row(payload: dict[str, Any]) -> list[tuple[int, dict[str, Any] | None]]

(row, histogram) pairs from a reduceRegions getInfo() payload.

Raises if a feature comes back without its ROW_PROPERTY, which would mean Earth Engine dropped the property and positional recovery is no longer possible.

Source code in src/lczkit/landcover/earthengine.py
def place_by_row(payload: dict[str, Any]) -> list[tuple[int, dict[str, Any] | None]]:
    """`(row, histogram)` pairs from a `reduceRegions` `getInfo()` payload.

    Raises if a feature comes back without its `ROW_PROPERTY`, which would mean Earth Engine
    dropped the property and positional recovery is no longer possible.
    """
    placed: list[tuple[int, dict[str, Any] | None]] = []
    for feature in payload.get("features", []):
        properties = feature.get("properties", {})
        if ROW_PROPERTY not in properties:
            raise RuntimeError(
                f"Earth Engine returned a feature with no {ROW_PROPERTY!r} property, so its "
                "result cannot be matched back to a unit."
            )
        placed.append((int(properties[ROW_PROPERTY]), properties.get("histogram")))
    return placed

batched

batched(items: Sequence[int], size: int) -> Iterator[Sequence[int]]

Split items into consecutive chunks of at most size, preserving order.

A few thousand units per request keeps it under Earth Engine's element-count and payload limits. Order is preserved so a batch's results align positionally with its inputs.

Source code in src/lczkit/landcover/earthengine.py
def batched(items: Sequence[int], size: int) -> Iterator[Sequence[int]]:
    """Split `items` into consecutive chunks of at most `size`, preserving order.

    A few thousand units per request keeps it under Earth Engine's element-count and payload
    limits. Order is preserved so a batch's results align positionally with its inputs.
    """
    if size < 1:
        raise ValueError(f"batch size must be at least 1, got {size}")
    for start in range(0, len(items), size):
        yield items[start : start + size]

cache_key

cache_key(units: GeoDataFrame, config: LandCoverDatasetConfig) -> str

Stable hash of everything that changes the answer.

The key is (unit geometries, collection ID, date range, reducer). The class mapping is folded in as well: without it, editing value_classes would return a stale table computed under the previous mapping, and nothing would look wrong.

Geometries are hashed in unit_id order, so the same units in a different row order hit the same cache entry.

Source code in src/lczkit/landcover/earthengine.py
def cache_key(units: gpd.GeoDataFrame, config: LandCoverDatasetConfig) -> str:
    """Stable hash of everything that changes the answer.

    The key is `(unit geometries, collection ID, date range, reducer)`. The class mapping is
    folded in as well: without it, editing `value_classes` would return a stale table computed
    under the previous mapping, and nothing would look wrong.

    Geometries are hashed in `unit_id` order, so the same units in a different row order hit the
    same cache entry.
    """
    ordered = units.sort_index()
    payload = {
        "unit_ids": [str(unit_id) for unit_id in ordered.index],
        "geometries": [geom.wkb_hex if geom is not None else None for geom in ordered.geometry],
        "crs": ordered.crs.to_string() if ordered.crs is not None else None,
        "collection_id": config.gee.collection_id,
        "band": config.gee.band,
        "start_date": config.gee.start_date,
        "end_date": config.gee.end_date,
        "scale_m": config.gee.scale_m,
        "reducer": REDUCER,
        "classes": config.classes,
        "value_classes": config.value_classes,
        "bins": config.bins,
        "bin_classes": config.bin_classes,
        "nodata": config.nodata,
        "nodata_policy": config.nodata_policy,
        "nodata_class": config.nodata_class,
        "unmapped_policy": config.unmapped_policy,
        "unmapped_class": config.unmapped_class,
    }
    encoded = json.dumps(payload, sort_keys=True, default=str).encode()
    return hashlib.sha256(encoded).hexdigest()[:16]

counts_from_histograms

counts_from_histograms(histograms: Sequence[dict[str, Any] | None], index: Index, n_classes: int, *, dataset_name: str = '') -> DataFrame

Earth Engine frequency histograms to the frame fractions_table consumes.

That is a unit_id x class-index frame.

Histogram keys are class indices as strings, values are pixel counts. EXCLUDED keys are dropped — they are the cells that must not reach the denominator — and an _UNMAPPED_SENTINEL key raises, which is how unmapped_policy="raise" is honoured on a server-side reduction.

Source code in src/lczkit/landcover/earthengine.py
def counts_from_histograms(
    histograms: Sequence[dict[str, Any] | None],
    index: pd.Index,
    n_classes: int,
    *,
    dataset_name: str = "",
) -> pd.DataFrame:
    """Earth Engine frequency histograms to the frame `fractions_table` consumes.

    That is a `unit_id` x class-index frame.

    Histogram keys are class indices as strings, values are pixel counts. `EXCLUDED` keys are
    dropped — they are the cells that must not reach the denominator — and an
    `_UNMAPPED_SENTINEL` key raises, which is how `unmapped_policy="raise"` is honoured on a
    server-side reduction.
    """
    counts = np.zeros((len(index), n_classes), dtype="float64")
    for row, histogram in enumerate(histograms):
        for key, value in (histogram or {}).items():
            class_index = int(key)
            if class_index == _UNMAPPED_SENTINEL:
                raise ValueError(
                    f"{dataset_name}: the Earth Engine collection holds values not covered by "
                    "value_classes. Either the configured mapping does not match the asset, or "
                    "extend it — this package will not guess which class an unknown value "
                    "belongs to."
                )
            if class_index == EXCLUDED:
                continue
            counts[row, class_index] = float(value)
    return pd.DataFrame(counts, index=index, columns=pd.Index(range(n_classes)))

Class mapping and assembly

lczkit.landcover.classify

Raw raster values to class indices, and the policies that govern the awkward cells.

Pure numpy with no I/O, so nodata and unmapped handling — the two things most likely to make a land-cover map quietly wrong — are unit-testable without a raster anywhere in sight.

LocalRasterSource applies this array-wise. EarthEngineSource sends the same mapping to the server as a remap(); ClassIndex.remap_pairs() exists so the two are built from one source of truth rather than being written out twice and drifting.

EXCLUDED module-attribute

EXCLUDED = -1

Class index for a cell that must not count towards a unit's denominator.

Distinct from "unobserved" only in intent: a cell is excluded either because the product made no observation there, or because the configured policy says so. Both leave the fractions of the remaining cells summing to 1.0.

INDEX_DTYPE module-attribute

INDEX_DTYPE = 'int16'

Class indices are small and signed — signed because EXCLUDED is negative.

ClassIndex

ClassIndex(config: LandCoverDatasetConfig)

The class mapping for one dataset, compiled once and applied per raster window.

Holds no raster state, so a single instance is safe to reuse across windows and units.

Compile config's class list into the positional index the reducers count in.

The nodata and unmapped policies are resolved here, once, into either a class position or EXCLUDED — so the hot path per window is a lookup rather than a branch on policy.

Source code in src/lczkit/landcover/classify.py
def __init__(self, config: LandCoverDatasetConfig) -> None:
    """Compile `config`'s class list into the positional index the reducers count in.

    The nodata and unmapped policies are resolved here, once, into either a class position
    or `EXCLUDED` — so the hot path per window is a lookup rather than a branch on policy.
    """
    self._config = config
    self.names: tuple[str, ...] = tuple(config.classes)
    self._position = {name: index for index, name in enumerate(self.names)}
    self._nodata_index = (
        self._position[config.nodata_class]
        if config.nodata_policy == "assign" and config.nodata_class is not None
        else EXCLUDED
    )
    self._unmapped_index = (
        self._position[config.unmapped_class]
        if config.unmapped_policy == "assign" and config.unmapped_class is not None
        else EXCLUDED
    )

config property

The dataset config this index was compiled from, for the run manifest to record.

index_of

index_of(name: str) -> int

Position of name in names, i.e. the class index the reducers count.

Source code in src/lczkit/landcover/classify.py
def index_of(self, name: str) -> int:
    """Position of `name` in `names`, i.e. the class index the reducers count."""
    return self._position[name]

remap_pairs

remap_pairs() -> tuple[list[float], list[int]]

(from_values, to_indices) for a categorical mapping, for Earth Engine's remap().

Raises for a binned dataset, which has no finite value list to enumerate — the Earth Engine backend expresses those as a threshold chain instead.

Source code in src/lczkit/landcover/classify.py
def remap_pairs(self) -> tuple[list[float], list[int]]:
    """`(from_values, to_indices)` for a categorical mapping, for Earth Engine's `remap()`.

    Raises for a binned dataset, which has no finite value list to enumerate — the Earth
    Engine backend expresses those as a threshold chain instead.
    """
    if self._config.value_classes is None:
        raise ValueError(
            f"{self._config.name}: remap_pairs() is only defined for a categorical dataset; "
            "this one is binned."
        )
    items = sorted(self._config.value_classes.items())
    return [float(value) for value, _ in items], [self._position[name] for _, name in items]

apply

apply(values: ndarray, *, nodata: float | None) -> ndarray

Map raw raster values to class indices, as an int16 array of the same shape.

nodata is the value the raster declares, already overridden by LandCoverDatasetConfig.nodata if that is set. Pass None when there is none.

Nodata is resolved before the class mapping, so a nodata value that also appears in value_classes is treated as nodata. That ordering is deliberate: the raster's own declaration of "this cell is not a measurement" outranks a mapping entry that happens to collide with the sentinel.

Source code in src/lczkit/landcover/classify.py
def apply(self, values: np.ndarray, *, nodata: float | None) -> np.ndarray:
    """Map raw raster `values` to class indices, as an `int16` array of the same shape.

    `nodata` is the value the raster declares, already overridden by
    `LandCoverDatasetConfig.nodata` if that is set. Pass `None` when there is none.

    Nodata is resolved before the class mapping, so a nodata value that also appears in
    `value_classes` is treated as nodata. That ordering is deliberate: the raster's own
    declaration of "this cell is not a measurement" outranks a mapping entry that happens to
    collide with the sentinel.
    """
    out = np.full(values.shape, EXCLUDED, dtype=INDEX_DTYPE)
    if values.size == 0:
        return out

    as_float = values.astype("float64", copy=False)
    is_nodata = _matches(as_float, nodata)
    todo = ~is_nodata

    if self._config.bins is not None:
        # np.digitize with right=False returns 0 for v < bins[0], len(bins) for v >= bins[-1],
        # which is exactly the bin_classes ordering: lowest bin first.
        bin_classes = self._config.bin_classes or []
        bin_index = np.digitize(as_float[todo], np.asarray(self._config.bins, dtype="float64"))
        lookup = np.array([self._position[name] for name in bin_classes], dtype=INDEX_DTYPE)
        out[todo] = lookup[bin_index]
    else:
        out[todo] = self._apply_categorical(as_float[todo])

    out[is_nodata] = self._nodata_index
    return out

lczkit.landcover.table

The fractions table both land-cover backends return, and the entry checks both make.

LocalRasterSource and EarthEngineSource must return schema-identical tables. Routing both through fractions_table makes that true by construction rather than by inspection: the backends differ only in how they produce per-unit cell counts, and everything after that — column order, absent classes, normalisation, the empty-unit convention — happens once, here.

fractions_table

fractions_table(counts: DataFrame, config: LandCoverDatasetConfig, index: Index) -> DataFrame

Normalise per-unit class counts into the fractions table, indexed to match index.

counts is indexed by unit_id with one column per class index (position in config.classes), holding whatever the backend counts in — exact cell-coverage area locally, pixel counts server-side. Only ratios survive, so the two units of measurement agree.

Every class in config.classes gets a column whether or not it occurs in the data, so the output schema is fixed by config rather than by which classes a given city happens to contain. Units with no counted cells come out all-NaN, not all-zero: "the raster does not cover this unit" and "0% of this unit is tree" are different statements, and the same call height_metrics() makes for a unit holding no buildings.

Source code in src/lczkit/landcover/table.py
def fractions_table(
    counts: pd.DataFrame,
    config: LandCoverDatasetConfig,
    index: pd.Index,
) -> pd.DataFrame:
    """Normalise per-unit class `counts` into the fractions table, indexed to match `index`.

    `counts` is indexed by `unit_id` with one column per *class index* (position in
    `config.classes`), holding whatever the backend counts in — exact cell-coverage area locally,
    pixel counts server-side. Only ratios survive, so the two units of measurement agree.

    Every class in `config.classes` gets a column whether or not it occurs in the data, so the
    output schema is fixed by config rather than by which classes a given city happens to contain.
    Units with no counted cells come out all-`NaN`, not all-zero: "the raster does not cover this
    unit" and "0% of this unit is tree" are different statements, and the same call
    `height_metrics()` makes for a unit holding no buildings.
    """
    columns = [f"{config.column_prefix}{name}" for name in config.classes]
    positions = pd.Index(range(len(config.classes)))

    if counts.empty:
        return _all_null(index, columns)

    aligned = (
        counts.reindex(columns=positions, fill_value=0.0)
        .reindex(index=index, fill_value=0.0)
        .astype("float64")
    )
    totals = aligned.sum(axis=1)
    fractions = aligned.div(totals.where(totals > 0), axis=0)
    fractions.columns = pd.Index(columns)
    fractions.index.name = "unit_id"
    return fractions