Skip to content

Writing layers

A layer is defined in a small Python script that imports openwater, describes the layer, and exports it into the viewer's data directory. Layers that are shown together form a project, also defined in the script: the viewer shows one project at a time, with the project's layers in the project's order. The Kiel study is in layers/kiel.py; use it as a template.

A minimal script

import openwater as ow
from openwater import ops, osm

KIEL = ow.Region("kiel", "Kiel", '["boundary"="administrative"]["de:amtlicher_gemeindeschluessel"="01002000"]')

benches = ow.Layer(
    id="kiel-benches",
    title="Benches (Kiel)",
    description="Public benches from OpenStreetMap.",
    region=KIEL,
    source=osm.select('nwr["amenity"="bench"]'),
    keep_osm_tags=True,
    attributes=[
        ow.Attribute("osm_id", "string", "OSM object", ops.osm_id()),
        ow.Attribute("backrest", "string", "Backrest", ops.tag("backrest", default="unknown"),
                     values={"yes": "With backrest", "no": "Without backrest", "unknown": "Not tagged"}),
    ],
    sources=[ow.Source("osm", "OpenStreetMap", "ODbL 1.0", attribution="© OpenStreetMap contributors")],
)

project = ow.Project(
    id="kiel-benches",
    title="Benches in Kiel",
    description="Where to sit down in Kiel.",
    layers=[benches],          # drawing order: the first is drawn on top
)

if __name__ == "__main__":
    ow.run([project])          # exports the layers and projects/kiel-benches.json

Run it with make layers SCRIPTS=layers/benches.py, or without Docker after pip install -e openwater:

python layers/benches.py                # export into data/viewer, using the download cache
python layers/benches.py --check        # only check the definitions
python layers/benches.py --refresh      # download OSM data again
python layers/benches.py --only kiel-benches --data-dir /srv/viewer-data
Option Default Meaning
--data-dir env OPENWATER_DATA_DIR, else data/viewer The viewer's data directory
--cache-dir env OPENWATER_CACHE_DIR, else data/cache Download cache, shared by all scripts
--refresh off Download again instead of using the cache
--only ID all Export only these layers (dependencies are still built)
--check off Validate the definitions, no download, nothing written

The new project appears in the viewer's Project list (and under Settings → Projects). ow.run([layer_a, layer_b]) without a project exports the layers only; the map can't show them then. A layer may belong to several projects, e.g. a project that compares two regions can list layers of both: define it in a script that imports the other scripts' layers.

The study template

The Offene Quelle study (boundary, drinking water, attractors, pedestrian potential, hydrants) is defined once in layers/_study.py. A region script only names the region and its population and transit data, and gets one project with the five layers:

import openwater as ow
import _study

REGION = _study.german_city("flensburg", "Flensburg", ags="01001000")
PROJECT = _study.project(REGION, slug="flensburg", population=_study.ZENSUS, transit=_study.GTFS_DE)

if __name__ == "__main__":
    ow.run([PROJECT])

slug becomes the project id and the start of the layer ids (flensburg-hydrants). The project draws drinking water and hydrants on top, then the attractors (off at start), the pedestrian potential and the boundary. _study.layers(...) returns the five layers alone, for projects of your own. Outside Germany, define a _study.Population (a population grid dataset, the radius that assigns its points to the 100 m cells, a description) and a _study.Transit (a GTFS dataset), see layers/zuerich.py. Files starting with _ are helpers: make layers and make check skip them. The regions and their results are compared in Regions compared.

Building blocks

Class Purpose
Project(id, title, description, layers, hidden=[]) Layers shown together. layers in drawing order (first on top); hidden are listed but switched off at start. Exported as projects/<id>.json
Region(id, name, osm_area) The area: an Overpass filter that selects the boundary relation
Layer(...) One exported layer: id (lowercase, hyphens), title, description, region, source (where the features come from), attributes, rule sets, parameters, assumptions, planned items, sources. keep_osm_tags=True adds all raw tags as osm:<key>; keep={...} exports only features matching these conditions (e.g. grid cells with a value)
Attribute(id, type, label, op, ...) One property of every feature. type: string, integer, number, boolean. Optional: description, unit, values (known values with labels), closed=True (only those values), optional
Value(label, description) Label and explanation of a value; a plain string means label only
RuleSet(id, title, description, rules) / Rule(id, title, description, where, source, status) Named conditions; status is assumption, verified or rejected
Parameter(value, description, unit) A value the layer is based on, e.g. a distance; exported so viewers can use it (e.g. as a circle radius)
Statement(id, title, description, status) An assumption behind the layer
Planned(id, title, description, step) Something defined in the method but not implemented yet
Source(id, title, license, ...) A data source with license and attribution

Feature sources (source=):

Source Features
osm.select('nwr[...]', ...) OSM elements inside the region, as points (ways and relations become their centre point)
osm.region_boundary() The region's outline as a line
grid.cells(size_m=100) Square cells covering the region (clipped to its boundary), in EPSG:3035 and aligned with the Zensus 2022 grid. Cell ids look like CRS3035RES100mN5967800E4337500

Datasets are external point data that operations can use (not features of a layer themselves). Files are downloaded once into the cache (data/cache/downloads/), extracted points are cached in data/cache/datasets/:

Dataset Points
datasets.csv_points(id, title, url, x, y, value, crs=…, member=…, delimiter=…, offset=0) Rows of a CSV file (also inside a ZIP; member defaults to its only CSV file), with coordinates in any CRS and one numeric value. delimiter=None detects it from the header. offset=50 moves lower-left corners of 100 m cells to their centre. Examples: Zensus 2022 (Germany), BFS STATPOP (Switzerland)
datasets.inspire_grid(id, title, url, member=…) An INSPIRE Population Distribution file (GML, also inside a ZIP): one point per grid cell (…CRS3035RES100mN…E…) at its centre, with its value. Example: Statistik Austria's 100 m population grid
datasets.gtfs_departures(id, title, url, day=None, weekday=1) Stops of a GTFS timetable, valued by their departures on one day (default: the next Tuesday the feed covers). Reading a Germany-wide feed takes about a minute

Operations

Every attribute gets its value from one operation. Operations work for any layer; there is no layer-specific code.

Operation Value
ops.osm_id() OSM element reference, e.g. node/123 (every layer needs an osm_id attribute)
ops.tag(key, default=None) The OSM tag's value, cast to the attribute type; default if the tag is missing (without a default the attribute is absent then)
ops.matching_rules(rule_set) Comma-separated ids of the rules in the set that match; empty if none
ops.none_matched(rule_set) True if no rule in the set matches
ops.nearest(layer, where=None) Straight-line distance in metres to the nearest feature of another point layer, optionally only features matching where; absent if there is none
ops.classify([(value, where), ...], default) The value of the first case whose conditions hold, else default
ops.feature_id() The feature's id: OSM reference or grid cell id
ops.sum_within(source, value=None, radius_m=300, decay="linear", where=None) Sum of value over the points of a layer or dataset within radius_m (each point counts 1 without value). decay="linear": a point counts fully at the feature and not at all at radius_m; "none": all count fully
ops.index({attribute: weight, ...}, cap_percentile=95, scale=100) Combines several numbers into one index: each attribute is divided by its 95th percentile over the layer (non-zero values, capped at 1), then the weighted mean is scaled to 0–100
ops.value_at(layer, value) The value of an attribute in the polygon of another layer that contains the feature, e.g. the grid cell a hydrant lies in; absent outside

Attributes are computed in the order they are listed, each over all features before the next. An operation may only use attributes listed before it; --check reports violations. So list tag attributes first, then rule results, then distances, sums, indices and classifications.

Conditions

Used in rules (where=), in ops.nearest(where=...) and in ops.classify cases. Each key is an attribute id; all conditions must hold.

Condition Holds if the attribute…
"underground" equals the value
["pipe", "pond"] equals one of the values
ops.not_empty() is present and not ""
ops.empty() is absent or ""
ops.at_least(x) / ops.at_most(x) is a number ≥ x / ≤ x

A condition on an absent attribute is false (except empty). With default="unknown" on tag attributes, a missing tag never matches a rule, so features with incomplete tags are not excluded by accident.

Example: classification in three steps

From layers/kiel.py:

ow.Attribute("excluded_by", "string", "Excluded by", ops.matching_rules("exclusion")),
ow.Attribute("dist_public_water_m", "integer", "Nearest public drinking water (straight line)",
             ops.nearest(drinking_water, where={"public": True}), unit="m"),
ow.Attribute("status", "string", "Potential status",
             ops.classify([
                 ("excluded", {"excluded_by": ops.not_empty()}),
                 ("candidate_covered", {"dist_public_water_m": ops.at_most(WALK_M)}),
             ], default="candidate_gap"),
             closed=True, values={...}),

drinking_water is another Layer object in the same script; openwater builds it first.

Example: a grid index from several sources

From layers/kiel.py (pedestrian potential):

ZENSUS = datasets.csv_points(id="zensus2022-population-100m", title="Zensus 2022 population, 100 m grid",
    url="https://www.destatis.de/static/DE/zensus/gitterdaten/Zensus2022_Bevoelkerungszahl.zip",
    member="Zensus2022_Bevoelkerungszahl_100m-Gitter.csv", delimiter=";",
    x="x_mp_100m", y="y_mp_100m", value="Einwohner", crs="EPSG:3035")
GTFS = datasets.gtfs_departures(id="gtfs-de-nv", title="Public transport departures",
    url="https://download.gtfs.de/germany/nv_free/latest.zip")

potential = ow.Layer(
    id="kiel-pedestrian-potential", ..., source=grid.cells(100), keep={"potential": ops.at_least(1)},
    attributes=[
        ow.Attribute("cell_id", "string", "Grid cell", ops.feature_id()),
        ow.Attribute("attractors", "number", "Attractors nearby", ops.sum_within(attractors, value="weight", radius_m=300)),
        ow.Attribute("transit", "number", "Departures nearby", ops.sum_within(GTFS, radius_m=400)),
        ow.Attribute("residents", "number", "Residents", ops.sum_within(ZENSUS, radius_m=60, decay="none")),
        ow.Attribute("potential", "number", "Pedestrian potential",
                     ops.index({"attractors": 0.5, "transit": 0.3, "residents": 0.2})),
        ...
    ])

attractors is another layer of the same script (points with a weight per category). The hydrants then get ops.value_at(potential, "potential").

When a layer needs something new

If an analysis step can't be expressed with these operations, add a new operation to openwater/src/openwater/ops.py (or a new source or dataset type) that works for any layer, e.g. "value of a raster at the feature", never code for one specific layer. Add it to the layer format's list of sources (openwater/spec/layer.schema.json) and a test in openwater/tests/.