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
name
property
¶
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
fractions
¶
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
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
¶
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
¶
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
name
property
¶
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
cache_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
fractions
¶
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
place_by_row
¶
(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
batched
¶
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
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
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
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
¶
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
¶
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
config
property
¶
config: LandCoverDatasetConfig
The dataset config this index was compiled from, for the run manifest to record.
index_of
¶
remap_pairs
¶
(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
apply
¶
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
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.