Skip to content

Command line

lczkit cities, lczkit run, lczkit site build|serve, and lczkit export. Installed as the lczkit console script.

The command line is deliberately thin. It configures a run through apply_preset rather than by restating any setting of its own, so there is one definition of what a preset means and the command line cannot drift from it.

lczkit cities cambridge                     # find an extent, and what it will cost
lczkit run --city cambridge --country GBR
lczkit run --city berlin --so2sat-window    # the extent the recorded figures were measured over
lczkit run --bbox 13.29,52.45,13.52,52.59   # needs nothing on disk
lczkit run --city berlin --dry-run          # resolve the config, create nothing
lczkit site build output/lczkit/<run_id>
lczkit site serve output/lczkit/<run_id>
lczkit export output/lczkit/<run_id>        # add units.gpkg to a run already on disk

Three locators, and they mean different ground. --city names one of GUPPD's 5 558 urban regions and covers it. --so2sat-window takes the densest 30 km window of that city's So2Sat labels instead — the extent the published agreement figures were measured over — and works for 28 cities. The second is a flag rather than a fallback: a run that reached it by accident, or silently failed to, would look comparable with a published figure while covering different ground. Whichever was used is recorded in the run manifest's extent.

lczkit export reads only what a run already wrote, adds units.gpkg, and backfills the manifest's crs, crs_wkt and extent — the last reconstructed from the units' own bounds and tagged kind="recovered", never presented as the window the run was asked for. It edits the manifest as JSON rather than through RunManifest: revalidating an archived run against today's model would fill in defaults for fields that run never had, and make it look like it came from code that did not produce it. It is idempotent, and the archival GeoParquet is byte-identical afterwards.

lczkit.cli

The lczkit command line.

Everything here is a wrapper. run calls lczkit.pipeline.run_pipeline, site build calls lczkit.viz.build_site, site serve calls lczkit.viz.serve, export calls lczkit.output.export_gis. No command computes anything, and none of them is the only way to reach what it wraps: the library API is unchanged and the experiment drivers in scripts/ still call it directly.

main_callback

main_callback(version: Annotated[bool, Option('--version', callback=_version, is_eager=True, help='Show the version.')] = False) -> None

Map cities into Local Climate Zones from open vector and raster data.

Every run records the Overture release, the height cascade, the classifier weights and the resolved package versions in its manifest, so a result can be traced back to what produced it. lczkit cities <name> finds an extent and lczkit run --city <name> maps it; --dry-run resolves the config without acting.

Source code in src/lczkit/cli/__init__.py
@app.callback()
def main_callback(
    version: Annotated[
        bool,
        typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
    ] = False,
) -> None:
    """Map cities into Local Climate Zones from open vector and raster data.

    Every run records the Overture release, the height cascade, the classifier weights and the
    resolved package versions in its manifest, so a result can be traced back to what produced
    it. `lczkit cities <name>` finds an extent and `lczkit run --city <name>` maps it;
    `--dry-run` resolves the config without acting.
    """

main

main() -> None

Console-script entry point, named in [project.scripts].

Source code in src/lczkit/cli/__init__.py
def main() -> None:
    """Console-script entry point, named in `[project.scripts]`."""
    app()

Run

lczkit.cli.run

lczkit run — a bbox or a city in, a run directory and a map site out.

Two city locators, and the distinction is load-bearing. --city names one of GUPPD's 5 558 urban regions and covers it; --city ... --so2sat-window takes the densest 30 km window of that city's So2Sat labels instead, which is the extent the published agreement figures were measured over. They are different ground, so a run says which one it used in its manifest rather than leaving a reader to infer it from a bbox.

The default is the general one. Reproducing a recorded figure is the specialist case and asks for itself; getting a map of a city is what the command is for.

run

run(bbox: Annotated[str | None, Option('--bbox', metavar='W,S,E,N', help='Extent in lon/lat degrees. Needs nothing on disk.')] = None, city: Annotated[str | None, Option('--city', metavar='NAME', help="Any of GUPPD's 5 558 urban regions. `lczkit cities` searches them.")] = None, country: Annotated[str | None, Option('--country', metavar='ISO', help='Disambiguate --city, e.g. GBR. 149 GUPPD names are shared.')] = None, so2sat_window: Annotated[bool, Option('--so2sat-window', help="Use the city's densest 30 km So2Sat window instead of its GUPPD extent.")] = False, extent_km: Annotated[float | None, Option('--extent-km', help='Shrink the extent to a concentric square of this side. Use it to try a run.')] = None, run_id: Annotated[str | None, Option('--run-id', help='Name the output directory. Defaults to a UTC timestamp.')] = None, preset: Annotated[str, Option('--preset', help=f"Run configuration. One of: {join(sorted(PRESETS))}.")] = DEFAULT_PRESET, config: Annotated[Path | None, Option('--config', exists=True, dir_okay=False, help='JSON overriding any settings section. A run manifest works here.')] = None, site: Annotated[bool, Option('--site/--no-site', help='Build the map site after the run.')] = True, buildings: Annotated[bool, Option('--buildings/--no-buildings', help='Tile building footprints for the 3D layer. Roughly triples the site.')] = False, basemap: Annotated[list[str] | None, Option('--basemap', metavar='KEY', help=BASEMAP_HELP)] = None, dry_run: Annotated[bool, Option('--dry-run', help='Resolve and print the configuration, then stop.')] = False, quiet: Annotated[bool, Option('--quiet', '-q', help='Suppress per-stage progress.')] = False) -> None

Run the whole pipeline over one extent.

Give it either an explicit window or a city:

lczkit run --bbox 13.29,52.45,13.52,52.59
lczkit run --city nairobi
lczkit run --city cambridge --country GBR --extent-km 3

Writes $DATA_DIR/output/lczkit/<run_id>/, plus the caches the Overture and height-product sources own under input/. Nothing existing under input/ is modified.

Source code in src/lczkit/cli/run.py
def run(
    bbox: Annotated[
        str | None,
        typer.Option(
            "--bbox",
            metavar="W,S,E,N",
            help="Extent in lon/lat degrees. Needs nothing on disk.",
        ),
    ] = None,
    city: Annotated[
        str | None,
        typer.Option(
            "--city",
            metavar="NAME",
            help="Any of GUPPD's 5 558 urban regions. `lczkit cities` searches them.",
        ),
    ] = None,
    country: Annotated[
        str | None,
        typer.Option(
            "--country",
            metavar="ISO",
            help="Disambiguate --city, e.g. GBR. 149 GUPPD names are shared.",
        ),
    ] = None,
    so2sat_window: Annotated[
        bool,
        typer.Option(
            "--so2sat-window",
            help="Use the city's densest 30 km So2Sat window instead of its GUPPD extent.",
        ),
    ] = False,
    extent_km: Annotated[
        float | None,
        typer.Option(
            "--extent-km",
            help="Shrink the extent to a concentric square of this side. Use it to try a run.",
        ),
    ] = None,
    run_id: Annotated[
        str | None,
        typer.Option("--run-id", help="Name the output directory. Defaults to a UTC timestamp."),
    ] = None,
    preset: Annotated[
        str,
        typer.Option("--preset", help=f"Run configuration. One of: {', '.join(sorted(PRESETS))}."),
    ] = DEFAULT_PRESET,
    config: Annotated[
        Path | None,
        typer.Option(
            "--config",
            exists=True,
            dir_okay=False,
            help="JSON overriding any settings section. A run manifest works here.",
        ),
    ] = None,
    site: Annotated[
        bool, typer.Option("--site/--no-site", help="Build the map site after the run.")
    ] = True,
    buildings: Annotated[
        bool,
        typer.Option(
            "--buildings/--no-buildings",
            help="Tile building footprints for the 3D layer. Roughly triples the site.",
        ),
    ] = False,
    basemap: Annotated[
        list[str] | None,
        typer.Option("--basemap", metavar="KEY", help=BASEMAP_HELP),
    ] = None,
    dry_run: Annotated[
        bool,
        typer.Option("--dry-run", help="Resolve and print the configuration, then stop."),
    ] = False,
    quiet: Annotated[
        bool, typer.Option("--quiet", "-q", help="Suppress per-stage progress.")
    ] = False,
) -> None:
    """Run the whole pipeline over one extent.

    Give it either an explicit window or a city:

        lczkit run --bbox 13.29,52.45,13.52,52.59
        lczkit run --city nairobi
        lczkit run --city cambridge --country GBR --extent-km 3

    Writes `$DATA_DIR/output/lczkit/<run_id>/`, plus the caches the Overture and height-product
    sources own under `input/`. Nothing existing under `input/` is modified.
    """
    # Everything that can be judged from the command line alone comes first, because
    # `_load_settings` needs `DATA_DIR` and a caller who has not set one yet is exactly the caller
    # most likely to mistype an argument. Loading first answered "--bbox 1,2,3" with "DATA_DIR is
    # not set", which blames the environment for a typo and buries the fixable half. `site build`
    # has always split it this way; this is `run` catching up.
    if (bbox is None) == (city is None):
        fail("give exactly one of --bbox or --city (see --help for the forms)")
    if city is None and (country is not None or so2sat_window):
        fail("--country and --so2sat-window only apply to --city")
    if extent_km is not None and extent_km <= 0:
        fail(f"--extent-km must be positive, got {extent_km}")
    basemap_keys = parse_basemaps(basemap)

    parsed: BBox
    extent: ExtentRecord
    located: tuple[BBox, ExtentRecord] | None = None
    if bbox is not None:
        parsed = parse_bbox(bbox)
        located = (parsed, ExtentRecord(kind="bbox", bbox=parsed))

    settings = _load_settings(run_id=run_id, create=not dry_run)
    try:
        apply_preset(settings, preset)
    except KeyError as error:
        fail(str(error.args[0]))
    if config is not None:
        apply_config_file(settings, config)
    settings.viz.include_buildings = buildings
    apply_basemaps(settings.viz, basemap_keys)

    # The city locators stay here on purpose: they read `guppd_bounds.csv` and the So2Sat archive
    # through `settings.source_dir`, so unlike a bbox they genuinely cannot answer without one.
    if located is not None:
        parsed, extent = located
    elif so2sat_window:
        parsed, extent = _so2sat_extent(city, country, settings)
    else:
        parsed, extent = _guppd_extent(city, country, settings)

    if extent_km is not None:
        parsed = shrink(parsed, extent_km)
        extent = extent.shrunk(parsed, extent_km)

    label = extent.label
    if dry_run:
        _print_plan(settings, extent, label=label, preset=preset, site=site)
        return

    console.print(f"run [bold]{settings.run_id}[/bold] over {label} {_format_bbox(parsed)}")
    _report_extent(extent)
    result = run_pipeline(
        settings,
        parsed,
        build_site_after=site,
        observer=StageProgress(quiet=quiet),
        extent=extent,
    )

    if not quiet:
        out.print(render_stages(result))
    console.print(f"  wrote [bold]{result.run_dir}[/bold]")
    _report_gis(result)
    _report_site(result)

Site

lczkit.cli.site

lczkit site — build a map site from a finished run, and serve it.

Both commands take a run directory, not a site directory. The run directory is what a run produces and what gets archived; site/ is a thing inside it. Taking the run directory in both places means a user never has to remember which level they are at.

build

build(run_dir: Annotated[Path, Argument(exists=True, file_okay=False, help='A run directory, i.e. output/lczkit/<run_id>/.')], buildings: Annotated[bool | None, Option('--buildings/--no-buildings', help='Tile building footprints. Defaults to whatever the run recorded.')] = None, basemap: Annotated[list[str] | None, Option('--basemap', metavar='KEY', help=BASEMAP_HELP)] = None) -> None

Build <run_dir>/site/ from the run's own outputs.

A pure transform: everything drawn was decided by the run and written into its manifest and its persisted layers. Rebuilding an archived run needs no access to input/.

Source code in src/lczkit/cli/site.py
@app.command("build")
def build(
    run_dir: Annotated[
        Path,
        typer.Argument(
            exists=True,
            file_okay=False,
            help="A run directory, i.e. output/lczkit/<run_id>/.",
        ),
    ],
    buildings: Annotated[
        bool | None,
        typer.Option(
            "--buildings/--no-buildings",
            help="Tile building footprints. Defaults to whatever the run recorded.",
        ),
    ] = None,
    basemap: Annotated[
        list[str] | None,
        typer.Option("--basemap", metavar="KEY", help=BASEMAP_HELP),
    ] = None,
) -> None:
    """Build `<run_dir>/site/` from the run's own outputs.

    A pure transform: everything drawn was decided by the run and written into its manifest and
    its persisted layers. Rebuilding an archived run needs no access to `input/`.
    """
    manifest = run_dir / MANIFEST_FILE
    if not manifest.exists():
        fail(
            f"{run_dir} has no {MANIFEST_FILE}, so it is not a run directory. "
            "Pass the directory a run wrote, not its site/ subdirectory."
        )

    keys = parse_basemaps(basemap)
    # Always the run's own settings, because the base maps are now resolved on every build: an
    # older run recorded no grounds at all, and rebuilding it is exactly when a reader wants them.
    config = _viz_config(run_dir)
    if buildings is not None:
        config.include_buildings = buildings

    console.print(f"building site for [bold]{run_dir.name}[/bold]")
    apply_basemaps(config, keys)
    try:
        report = build_site(run_dir, config=config)
    except TippecanoeMissingError as error:
        fail(str(error), EXIT_MISSING_TOOL)
    report_site(report)

serve_site

serve_site(run_dir: Annotated[Path, Argument(exists=True, file_okay=False, help='A run directory with a built site/.')], port: Annotated[int, Option('--port', help='Port to bind.')] = 8000, bind: Annotated[str, Option('--bind', help='Address to bind.')] = '127.0.0.1') -> None

Serve a built site over loopback until interrupted.

A server is required rather than preferred: PMTiles reads byte ranges through fetch, and the Fetch standard leaves file: URLs unhandled, so opening index.html fails in both Chrome and Firefox. This reaches no network — it is the same standard-library server the site ships with.

Source code in src/lczkit/cli/site.py
@app.command("serve")
def serve_site(
    run_dir: Annotated[
        Path,
        typer.Argument(exists=True, file_okay=False, help="A run directory with a built site/."),
    ],
    port: Annotated[int, typer.Option("--port", help="Port to bind.")] = 8000,
    bind: Annotated[str, typer.Option("--bind", help="Address to bind.")] = "127.0.0.1",
) -> None:
    """Serve a built site over loopback until interrupted.

    A server is required rather than preferred: PMTiles reads byte ranges through `fetch`, and the
    Fetch standard leaves `file:` URLs unhandled, so opening `index.html` fails in both Chrome and
    Firefox. This reaches no network — it is the same standard-library server the site ships with.
    """
    site_dir = _resolve_site_dir(run_dir)
    try:
        serve(site_dir, port=port, bind=bind)
    except KeyboardInterrupt:
        console.print("\nstopped")
    except OSError as error:
        fail(f"cannot serve on {bind}:{port}: {error}")

Export

lczkit.cli.export

lczkit export — make a finished run openable in a GIS.

Takes a run directory, like lczkit site build, for the same reason: that is the level a user archives and the level everything else in the CLI already speaks.

A current run needs this only if it was written with output.gis_format = "none". It exists for older runs, which carry a correct GeoParquet and no GeoPackage — and for which re-running a ten-minute city to change how it is packaged would be the wrong trade.

export

export(run_dir: Annotated[Path, Argument(exists=True, file_okay=False, help='A run directory, i.e. output/lczkit/<run_id>/.')]) -> None

Write units.gpkg beside a run's units.parquet, and record its CRS and extent.

The extent is recovered from the units' own bounds, not from the run's own record of what it was asked for — an archived run has no such record, which is why the field exists. It is tagged kind="recovered" so the two are never confused, and a run that already states its extent keeps what it states.

Source code in src/lczkit/cli/export.py
def export(
    run_dir: Annotated[
        Path,
        typer.Argument(
            exists=True,
            file_okay=False,
            help="A run directory, i.e. output/lczkit/<run_id>/.",
        ),
    ],
) -> None:
    """Write units.gpkg beside a run's units.parquet, and record its CRS and extent.

    The extent is **recovered from the units' own bounds**, not from the run's own record of what
    it was asked for — an archived run has no such record, which is why the field exists. It is
    tagged `kind="recovered"` so the two are never confused, and a run that already states its
    extent keeps what it states.
    """
    try:
        result = export_gis(run_dir)
    except FileNotFoundError as error:
        fail(str(error))

    crs = result.crs or "an unnamed projected CRS"
    console.print(f"  wrote [bold]{result.units_gpkg}[/bold]", soft_wrap=True)
    console.print(f"  {result.n_units:,} units in [bold]{crs}[/bold]")
    if not result.manifest_updated:
        return
    # Named rather than summarised as "the CRS". The two fields are backfilled independently — an
    # older run may already carry one — and a message that says CRS while writing an extent is the
    # kind of small untruth that makes a reader distrust the rest of the output.
    if result.extent is None:
        console.print("  manifest now records the CRS")
        return
    console.print(
        f"  manifest now records the CRS and a recovered extent ({result.extent.area_km2:,.1f} km2)"
    )

lczkit cities

lczkit.cli.cities

lczkit cities — find the urban region a run should cover, before spending a download.

The command that makes --city usable. GUPPD names 5 558 regions and 149 of those names are shared by more than one, so a caller needs to see what they are about to ask for: which country, how much ground, and therefore roughly what it will cost. Printing the area is the point — a 64 km² region is a few minutes and a 17 661 km² one is not, and nothing else in the interface says so.

Reads one 564 KB table under input/NASA/. No pipeline, no network, no label archive.

cities

cities(query: Annotated[str | None, Argument(help='Part of a city name. Omit to list the largest regions.')] = None, country: Annotated[str | None, Option('--country', metavar='ISO', help="ISO code or country name, e.g. GBR or 'united kingdom'.")] = None, limit: Annotated[int, Option('--limit', help='Most rows to print.')] = 20) -> None

Search the GUPPD urban regions --city resolves against.

lczkit cities cambridge
lczkit cities london --country gb

Prints each match's bounding box and its area, so the extent a run would cover is visible before the run starts. Pass any of these names to lczkit run --city.

Source code in src/lczkit/cli/cities.py
def cities(
    query: Annotated[
        str | None,
        typer.Argument(help="Part of a city name. Omit to list the largest regions."),
    ] = None,
    country: Annotated[
        str | None,
        typer.Option(
            "--country",
            metavar="ISO",
            help="ISO code or country name, e.g. GBR or 'united kingdom'.",
        ),
    ] = None,
    limit: Annotated[int, typer.Option("--limit", help="Most rows to print.")] = 20,
) -> None:
    """Search the GUPPD urban regions `--city` resolves against.

        lczkit cities cambridge
        lczkit cities london --country gb

    Prints each match's bounding box and its area, so the extent a run would cover is visible
    before the run starts. Pass any of these names to `lczkit run --city`.
    """
    settings = _settings()
    try:
        places = load_places(settings)
    except (FileNotFoundError, ValueError) as error:
        fail(str(error))

    matches = find(places, query or "", country=country)
    if not matches:
        where = f" in {country}" if country else ""
        fail(f"no urban region matching {query!r}{where}. Names come from the JRC gazetteer.")

    if query is None:
        # Nothing to rank by, so show the ones a reader is most likely to be looking for.
        matches = sorted(matches, key=lambda entry: entry.area_km2, reverse=True)

    out.print(_table(matches[:limit]))
    if len(matches) > limit:
        console.print(f"  {len(matches) - limit:,} more; narrow with --country or raise --limit")
    _note_large(matches[:limit])