Output¶
What a run writes into output/lczkit/<run_id>/.
units.parquet is the archival record, units_viz.parquet the rounded table a map renders from,
and manifest.json everything needed to read either of them and to reproduce the run - including
the CRS, which is derived from the extent rather than configured and so is not in config.
units.gpkg is the same unit table in a format whose reader is unconditional. It is written by
default and is not the archival record; see OutputConfig.gis_format.
A run directory is written to output/lczkit/<run_id>/ and contains:
| Artefact | What it is |
|---|---|
units.parquet |
the archival table with geometry attached, in the run's projected coordinate system |
units.gpkg |
the same table as a GeoPackage, written beside it and never instead |
units_viz.parquet |
the display-ready attribute table the map site reads |
manifest.json |
the full serialised config, source versions and every report |
Why both Parquet and GeoPackage
Every run's GeoParquet is valid 1.0.0 and carries the extent's coordinate reference system with an EPSG authority code. But the Parquet driver in GDAL — the format layer under most geographic software — is an optional build component, so a copy of QGIS built without it opens a correct file as a plain table and reports "this layer has no CRS". GeoPackage is SQLite and is in GDAL's core, so it opens everywhere.
The manifest also carries crs and crs_wkt. The CRS is derived from the extent via
estimate_utm_crs(), so it appears in no config and no argument — without these two fields a run
directory could state its own CRS only through the file format the reader who needs telling cannot
open.
lczkit.output.writer
¶
Writing a run: units.parquet, units_viz.parquet and manifest.json.
Three files, into output/lczkit/<run_id>/ and nowhere else. A run never writes outside its own
directory, and the viz table exists so that the map site is a pure transform of run outputs rather
than a second analysis.
The split between the two tables is deliberate. units.parquet is the archival record: full
precision, geometry attached, every column any stage produced. units_viz.parquet is what a map
renders from: no geometry (the tiles carry it), floats rounded to three significant figures, and
the seventeen distances stored as scaled integers. The rounding is not cosmetic - at full float64
precision the distance vector alone triples the size of what a browser has to parse, and no
choropleth or sidebar reads past the third digit.
GPKG_LAYER
module-attribute
¶
The GeoPackage copy of the unit table, written when output.gis_format asks for it.
Not a replacement for units.parquet, which stays the archival record and stays what the site
build reads. It exists because GeoParquet's reader is conditional in a way its format is not:
GDAL's Parquet driver is an optional build component, so a correct file with an EPSG code in its
geo metadata can still land in a GIS as a table with no CRS. See OutputConfig.gis_format for
the measurement and the cost.
RunOutputs
dataclass
¶
RunOutputs(run_dir: Path, units: Path, units_viz: Path, manifest_path: Path, manifest: RunManifest, units_gpkg: Path | None = None, layers: dict[str, Path] = dict())
What a run wrote, and the manifest describing it.
write_run
¶
write_run(settings: Settings, units: GeoDataFrame, parameters: DataFrame, classification: DataFrame, classifier: PrototypeClassifier, *, extras: DataFrame | None = None, cleaning: CleaningReport | None = None, extent: ExtentRecord | None = None, units_report: PatchReport | None = None, height_fill: HeightFillReport | None = None, height_source_availability: SourceAvailability | None = None, tag_availability: TagAvailability | None = None, smoothing: SmoothingReport | None = None, validation: AgreementReport | None = None, layers: Mapping[str, GeoDataFrame] | None = None) -> RunOutputs
Write one run's three files into settings.run_dir, returning their paths.
extras is anything else keyed on unit_id that belongs in the output - the height
provenance and the land-cover fractions, typically. It is joined verbatim, so a
column appearing in two inputs is an error rather than a silent overwrite.
layers persists the run's context geometry - the cleaned streets, water, land use and
buildings - under layers/<name>.parquet. It exists so that the site build is a pure
transform of run outputs: without it the only way to draw a basemap
or extrude buildings would be to re-read input/ at site-build time, which would make the site
depend on data the run does not carry and could not be rebuilt from an archived run directory.
Names outside CONTEXT_LAYERS are rejected rather than written.
Nothing outside run_dir is touched, and nothing under input/ is read.
Source code in src/lczkit/output/writer.py
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | |
write_gpkg
¶
Write the unit table as units.gpkg, returning its path.
unit_id is reset into a column first. A GeoPackage has no index, so leaving it as one would
drop the join key every other artefact in the run is keyed on — the single thing that would
make this copy useless rather than merely redundant.
Called before the manifest is built, so a failure raises rather than leaving a manifest advertising a file that is not there.
Source code in src/lczkit/output/writer.py
classification_summary
¶
What the classifier did to this city, for the manifest.
The LCZ 10 firing count is the reason this exists. A rule that never fires is indistinguishable, from the output alone, from one that was never configured, and the concern about LCZ 10 is precisely that it can go silently unemitted — so the count belongs in every run's manifest rather than in whatever investigation happens to look for it.
Source code in src/lczkit/output/writer.py
viz_table
¶
viz_table(attributes: DataFrame, settings: Settings) -> DataFrame
The rounded, integer-scaled attribute table the map site renders from.
Exposed separately from write_run so the transformation is testable without a filesystem,
and so a caller can inspect exactly what a viewer will see.
Source code in src/lczkit/output/writer.py
Manifest¶
lczkit.output.manifest
¶
The run manifest: everything needed to read a run's output and to reproduce it.
Reproducibility is a feature of this package rather than an afterthought, so the manifest carries the full serialised config, the pinned Overture release, the Earth Engine collection IDs and date ranges, the resolved package versions, a run timestamp, and the cleaning report. Later work added to that list - the height source-availability diagnostic, the parameter registry with its units and references, the two deferred Stewart & Oke properties, and the Overture heavy/light industry limitation, each required to be data rather than prose.
The result is a single JSON file that answers, without the code in hand: what was measured, in what units, from which sources, under which thresholds, with which parameters missing, and how well it agreed with an independent map.
TRACKED_PACKAGES
module-attribute
¶
TRACKED_PACKAGES: tuple[str, ...] = ('lczkit', 'geopandas', 'shapely', 'pandas', 'numpy', 'pyarrow', 'pyogrio', 'momepy', 'libpysal', 'neatnet', 'geoplanar', 'duckdb', 'exactextract', 'rasterio', 'pydantic', 'earthengine-api')
Packages whose version changes could change a run's numbers. Every one of them performs a geometric or zonal computation whose result this package reports as a measurement.
RunManifest
¶
Bases: BaseModel
One run, described completely enough to be read and repeated.
overture_release
instance-attribute
¶
The pinned release the vector layers came from, never "latest".
earth_engine_assets
class-attribute
instance-attribute
¶
Per land-cover dataset, the collection ID, asset type, band, date range and scale.
parameters
class-attribute
instance-attribute
¶
The parameter registry: every emitted column with its unit, description and source.
not_computed
class-attribute
instance-attribute
¶
Stewart & Oke properties this package does not compute, and why.
limitations
class-attribute
instance-attribute
¶
Known limitations of specific parameters, in the parameters' own terms.
unused_lcz_properties
class-attribute
instance-attribute
¶
Properties present in the published prototype table but absent from the distance metric. Five of the ten, which is a material caveat on every label a run emits.
unapplied_weights
class-attribute
instance-attribute
¶
Weights the active preset publishes for properties this package cannot compute. Under
bernard2024 this is 4.5 of a published 21.5 total, so a reader comparing against a
GeoClimate run knows the metric is not the same one.
classification
class-attribute
instance-attribute
¶
Active weights, normalisation, the full prototype table, every threshold, and which classes could not be assigned.
classification_summary
class-attribute
instance-attribute
¶
What the classifier actually did to this city: the label distribution, how many units each route produced, and how many times the LCZ 10 rule fired.
The firing count is here because a rule that never fires is indistinguishable, from the output alone, from one that was never configured — and the whole concern about LCZ 10 is that it can go silently unemitted. A run over an industrial port reporting zero firings is a finding about the rule, and it should not take a separate investigation to notice it.
breaks
class-attribute
instance-attribute
¶
breaks: list[VariableBreaks] = Field(default_factory=list)
Precomputed classification breaks. The map site reads these and never recomputes a quantile.
extent
class-attribute
instance-attribute
¶
extent: ExtentRecord | None = None
The ground this run covered, and the locator that chose it.
Derived, like crs, and for the same reason absent until it was asked for. The extent is an
argument to run_pipeline, so it appears in no Settings field and therefore in none of the
config block below — leaving a run directory unable to say which city it was, which is not a
recoverable question from a bbox once --city reaches 5 558 named regions.
None on runs written before this field existed. lczkit export backfills those from the
units' own bounds, under kind="recovered" so a reconstruction is never read as a record.
units
class-attribute
instance-attribute
¶
units: PatchReport | None = None
What the patch merge did, where units.strategy is "patch".
The choice of strategy already reaches the manifest through config; this is the outcome,
and the two are different things. A run recording patch_min_area_m2=50000 says what was asked
for, and seed_area_quantiles beside patch_area_quantiles says what was got — which is the
only way to tell a city where the merge worked from one where isolates or the area ceiling
stopped it.
height_dispersion
class-attribute
instance-attribute
¶
height_dispersion: DispersionReport | None = None
Within-unit height spread per tier — what the cascade did to the shape of the height distribution, not just to its coverage.
height_fill and height_completeness say where a height came from. Neither says whether the
substitute resolves anything inside a unit, and Hr is a geometric mean, so it is depressed by
spread and rises as spread collapses. Open Buildings 2.5D was rejected for having too much
within-unit spread (0.441 against reality's 0.195); the tiers that shipped have too little —
measured at a median CV of 0.192 for WSF-3D in Nairobi and 0.112 for GHS-BUILT-H in Bogota,
against 0.266 for real Overture heights in Berlin, with 23.6% of the GHSL units carrying a
single height throughout. Same mechanism, opposite sign. See lczkit.heights.dispersion.
tag_availability
class-attribute
instance-attribute
¶
tag_availability: TagAvailability | None = None
Overture attribute availability by upstream dataset — the counterpart of
height_source_availability, and the same finding on a second attribute: building tags are
48.6% of building area is tagged across Europe and North America against 13.6% elsewhere.
Read every sem_* column against tagged_area_fraction. Without it a semantic fraction of 0.0
cannot be told from an untagged city, which is the same mistake height_tier_fractions exists
to prevent for the cascade.
smoothing
class-attribute
instance-attribute
¶
smoothing: SmoothingReport | None = None
What the modal filter did, if it was enabled.
Off by default, and the report is written either way: a run has to be able to say the filter
did not fire as distinct from never having been configured. Every stored figure in this
project was measured with no filter at all, so a run reporting enabled: true is not
comparable to a recorded result until the sweep says how far one moves them.
validation
class-attribute
instance-attribute
¶
validation: AgreementReport | None = None
Agreement against the Demuzere global map. A comparator, not ground truth - read it against
reference_ceiling.
validation_ground_truth
class-attribute
instance-attribute
¶
validation_ground_truth: AgreementReport | None = None
Agreement against hand-labelled LCZ polygons (So2Sat LCZ42 / DFC2017) where they exist.
This is the primary validation figure; validation is secondary.
reference_ceiling
class-attribute
instance-attribute
¶
reference_ceiling: AgreementReport | None = None
Agreement between the Demuzere map and the labelled polygons, on the same units.
The bound on what any run can score against validation, and the number that has to exist
before a residual there is called a defect. Measured at 53.2% on the Berlin fixture, which is
inside the 50-60% band lczkit was being compared against as though it were a target.
crs
class-attribute
instance-attribute
¶
The CRS every geometry in this run is written in, as an authority code — "EPSG:32618".
It is derived, not configured. Internal computation happens in whatever projected CRS
estimate_utm_crs() returns for the extent, so the answer
depends on the bbox and appears nowhere in config. Until this field existed a run directory
could not say what CRS it was in without a GeoParquet reader — which is precisely the tool a
reader who cannot open GeoParquet does not have.
None only where the CRS carries no authority code; crs_wkt is the fallback and is always
present. units_viz.parquet has no geometry at all and so has no CRS.
crs_wkt
class-attribute
instance-attribute
¶
The same CRS as WKT2, so it is recoverable when no authority code applies.
outputs
class-attribute
instance-attribute
¶
Files written into the run directory, by name.
package_versions
¶
Resolved version of every tracked package.
An absent one is recorded as such rather than omitted, because "not installed" is itself a
fact about the run - earthengine-api missing means the Earth Engine path could not have
been used.
Source code in src/lczkit/output/manifest.py
build_manifest
¶
build_manifest(settings: Settings, classifier: PrototypeClassifier, *, breaks: list[VariableBreaks] | None = None, classification_summary: dict[str, Any] | None = None, cleaning: CleaningReport | None = None, extent: ExtentRecord | None = None, units: PatchReport | None = None, height_fill: HeightFillReport | None = None, height_dispersion: DispersionReport | None = None, height_source_availability: SourceAvailability | None = None, tag_availability: TagAvailability | None = None, smoothing: SmoothingReport | None = None, validation: AgreementReport | None = None, validation_ground_truth: AgreementReport | None = None, reference_ceiling: AgreementReport | None = None, crs: CRS | None = None, outputs: list[str] | None = None) -> RunManifest
Assemble the manifest for one run.
Every argument beyond the first two is optional because the stages are independently usable - a run that classified a parameter table it was handed has no cleaning report to record, and saying so is better than fabricating one.
Source code in src/lczkit/output/manifest.py
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
GIS export¶
lczkit.output.gis
¶
Making an already-written run openable in a GIS, without re-running it.
write_run emits units.gpkg and records the CRS in the manifest, but every run written before
that did neither — and a metropolitan run is ten minutes, so re-running one to change how it is
packaged would be the wrong trade. This converts a run directory in place.
It reads only what the run already wrote and adds only what was missing. No parameter is
recomputed, no geometry is moved, and nothing that exists is overwritten except a units.gpkg
this function itself produced. The manifest gains crs, crs_wkt, an extent and a mention in
outputs; every other field is left exactly as the run wrote it, because a manifest is the record
of what was measured and this is not a re-measurement.
The extent is reconstructed from the units' own bounds and tagged kind="recovered" rather than
presented as what the run was asked for. A reconstruction is bounded by the units that were
written, which is not the same rectangle as the window that was requested — a grid overhangs its
bbox by up to a cell on each side — and there is nothing on disk that says which city was named.
Recording it under a distinct kind is what stops a later reader treating the two as equivalent.
GisExport
dataclass
¶
GisExport(run_dir: Path, units_gpkg: Path, crs: str | None, n_units: int, extent: ExtentRecord | None, manifest_updated: bool)
What export_gis found and what it wrote.
crs
instance-attribute
¶
The authority code, or None where the CRS carries none — see RunManifest.crs.
extent
instance-attribute
¶
extent: ExtentRecord | None
The window recovered from the units' bounds, or None where the manifest already had one.
Never overwrites a recorded extent: a run that stated where it was knows better than a reconstruction from the units it happened to write.
manifest_updated
instance-attribute
¶
False where the manifest already carried the CRS and the extent, or where there is no manifest.
export_gis
¶
export_gis(run_dir: Path) -> GisExport
Write units.gpkg beside an existing run's units.parquet and record its CRS.
Raises FileNotFoundError naming the path when the run has no units.parquet, because the
alternative is a pyarrow error several frames down that does not say which directory was
wrong — and the likeliest mistake here is pointing at output/lczkit/ rather than at one run.
Source code in src/lczkit/output/gis.py
Classification breaks¶
Precomputed here and written into the manifest, so the map site is a pure transform of run outputs. The site build must never recompute a parameter or a quantile.
lczkit.output.breaks
¶
Classification breaks, precomputed once at run time so the map site never recomputes them.
The static site is a pure transform of run outputs: it never recomputes a parameter or a quantile. Breaks are the only quantity a choropleth needs beyond the values themselves, so computing them here is what makes that possible — and it also means two viewers of the same run see the same class boundaries, which they would not if each rendering derived its own.
Quantiles, not natural breaks. Jenks would mean a mapclassify dependency to save a one-line call.
The method name is recorded alongside the values so a consumer is never left inferring it.
VariableBreaks
¶
Bases: BaseModel
Break points for one continuous variable, plus what they were computed from.
breaks
instance-attribute
¶
The k + 1 boundaries, ascending, from the minimum to the maximum. Fewer than k + 1
distinct values collapse to however many distinct boundaries exist - a variable that is
constant over a run gets a single-element list, which is the truthful answer.
quantile_breaks
¶
quantile_breaks(values: Series, k: int) -> VariableBreaks
k-quantile breaks over the non-null values of values.
An all-null or empty variable returns no breaks rather than raising: a run over a small extent can legitimately produce a parameter nothing measured, and a missing choropleth is a better outcome than a failed run.
Source code in src/lczkit/output/breaks.py
breaks_for
¶
breaks_for(frame: DataFrame, columns: Iterable[str], k: int) -> list[VariableBreaks]
Breaks for each named column of frame, in the order given.
Extent¶
What ground a run covered, and how that ground was chosen. Derived rather than configured: the
extent is an argument to run_pipeline and reaches no Settings field, so it needs a manifest slot
of its own, as the run CRS does.
lczkit.output.extent
¶
What ground a run covered, and how that ground was chosen.
A run directory could not say where it was. Checked across every manifest written before this
module existed: no bbox, no place name, no extent of any kind. The reason is structural rather than
an oversight — the extent is an argument to run_pipeline, so it is in no Settings field, no
preset and no command-line default, and Settings.model_dump() is what the manifest serialises. It
is the same shape as the run CRS, and the same rule closes it: a derived property has to be
recorded somewhere the derivation is not.
It matters more now that --city reaches 5 558 urban regions rather than 28. Two runs of "Berlin"
can legitimately mean the GUPPD urban region or the densest 30 km window of its So2Sat labels;
those are different ground, and a bbox alone does not say which was asked for or why.
ExtentKind
module-attribute
¶
How an extent was arrived at.
recovered is reserved for lczkit export, which reconstructs an extent from an archived run's
own geometry. It is deliberately a distinct value rather than a best guess at the original: a
reconstruction is bounded by the units that were written, not by what was requested, and the two
differ wherever the unit grid overhangs or falls short of the window.
ExtentRecord
¶
Bases: BaseModel
The window a run covered, with the locator that produced it.
bbox
instance-attribute
¶
The window actually run, in lon/lat degrees, after any extent_km shrink.
name
class-attribute
instance-attribute
¶
The place as the gazetteer spells it — "São Paulo", not the query that found it.
query
class-attribute
instance-attribute
¶
What the caller typed, kept beside name because they differ under normalisation and the
query is what has to be retyped to reproduce the run.
smod_id
class-attribute
instance-attribute
¶
GUPPD's own identifier for the region. Unambiguous where the name is not — 149 of the 5 558 names are shared — so this is what identifies the extent when the record is read back.
city_key
class-attribute
instance-attribute
¶
The lczkit.cities registry key, where the extent came from a So2Sat window.
side_km
class-attribute
instance-attribute
¶
Side of the So2Sat search window, in kilometres. 30 for every recorded sweep.
extent_km
class-attribute
instance-attribute
¶
The --extent-km shrink applied, if any. source_bbox is what it was applied to.
source_bbox
class-attribute
instance-attribute
¶
The window before the shrink, so a trimmed trial run still records the whole region it was trimmed from.
area_km2
class-attribute
instance-attribute
¶
Area of bbox, precomputed so a reader needs no geodesy to size the run.
label
property
¶
A short name for this extent, for progress output and the run's own log line.
model_post_init
¶
Fill area_km2 from bbox, so the two cannot disagree.
shrunk
¶
shrunk(bbox: BBox, extent_km: float) -> ExtentRecord
The same locator over a concentric extent_km window of it.
The locator is preserved rather than replaced by a bare bbox: a 3 km trial over Cambridge is still a run about Cambridge, and losing that on the way into the manifest is how a directory full of trial runs becomes unreadable.
Source code in src/lczkit/output/extent.py
bbox_area_km2
¶
bbox_area_km2(bbox: BBox) -> float
Roughly how much ground a lon/lat window covers.
A cosine-corrected rectangle, matching lczkit.places.Place.area_km2, because both feed the
same decision — whether this is a minutes run or an hours one — and a projected area would
disagree with the figure lczkit cities printed for the same place.