Classify OpenStreetMap ways by Level of Traffic Stress (LTS) using the Furth methodology.
Project description
osm-lts
Classify OpenStreetMap ways by Level of Traffic Stress (LTS) using the Furth methodology.
LTS is a 1–4 scale from "kid-comfortable" (1) to "strong-and-fearless only" (4). It's the standard advocacy and planning input for "where is the bike network actually rideable for a typical adult" — far more honest than miles of "bike infrastructure" because it captures whether that infrastructure is on a calm street or a six-lane arterial.
Install
pip install osm-lts
Pure Python, no dependencies, Python 3.9+.
Use
from osm_lts import classify
classify({"highway": "residential", "maxspeed": "25 mph"})
# <LTS.MOST_ADULTS: 2>
classify({"highway": "primary"})
# <LTS.STRONG_AND_FEARLESS: 4>
classify({"highway": "cycleway"})
# <LTS.KID_COMFORTABLE: 1>
classify({"highway": "footway"})
# None — outside scope (not relevant to cyclist stress)
The function takes any Mapping[str, str] of OSM tags. Numeric tags
(maxspeed, lanes) tolerate units ("25 mph", "50 km/h", "4;3") —
only the leading digits are read. The result is an IntEnum, so
int(classify(tags)) gives you the bare LTS value for serialization.
CLI
The package ships with an osm-lts command for batch jobs:
echo '{"highway": "residential", "maxspeed": "25 mph"}' | osm-lts classify
# {"tags": {"highway": "residential", "maxspeed": "25 mph"}, "lts": 2}
osm-lts classify --in ways.jsonl --out lts.jsonl
Input is JSON, JSONL, or a single JSON array — auto-detected.
How it works
The classifier mirrors Furth's published rules:
| Tier | Description | Example triggers |
|---|---|---|
| LTS 1 | Suitable for children | highway=cycleway, living_street, cycleway=track |
| LTS 2 | Most adults will tolerate | residential ≤25 mph, bike lane on a slow street |
| LTS 3 | Experienced cyclists only | tertiary, fast residential, bike lane on a faster street |
| LTS 4 | Strong-and-fearless only | primary / trunk, >35 mph, ≥3 lanes and >30 mph |
The branches evaluate top-to-bottom and short-circuit on the first match.
Order matters — a cycleway=track on a 40 mph arterial returns LTS 1
because separation wins over speed. Highways outside scope (motorway,
footway, sidewalk, steps, pedestrian) return None.
When maxspeed or lanes are missing, highway-typical defaults fill in:
from osm_lts import (
DEFAULT_SPEED_MPH_BY_HIGHWAY,
DEFAULT_LANE_COUNT_BY_HIGHWAY,
EXCLUDED_HIGHWAYS,
)
These are public so callers can read them in their own UIs (e.g. "we assumed 25 mph because the way was untagged").
Scope and limitations
The classifier treats each OSM way in isolation — it reads that way's tags and returns a tier. That's the foundational input to a network LTS analysis, but it's not the whole methodology:
- No intersection effects. A calm residential that crosses a hostile arterial is still LTS 2 here; full Furth would penalize the unsignalized crossing.
- No adjacent-way context. Parking presence, buffer width, and sidewalk separation aren't read from neighboring ways. A bike lane next to a parking lane is scored the same as one without.
- No network-level analysis. The output is a per-way score, not "low-stress islands" or connectivity. Building a routing or coverage tool means doing that analysis on top of the per-way scores this library returns.
SQL form (PostgreSQL / osm2pgsql)
The same rules are also available as a PostgreSQL CASE expression
via osm_lts.sql. The SQL emitter and the Python classifier share
their constants — change a default in one place and both forms move
together.
from osm_lts.sql import lts_case_expression
sql = f"""
SELECT
id,
({lts_case_expression()}) AS lts
FROM planet_osm_ways
WHERE tags ? 'highway'
"""
Defaults assume osm2pgsql slim mode with --hstore-all —
tags live as hstore on planet_osm_ways.tags and are cast to
jsonb for ->> extraction. Different schemas (osm2pgsql flex,
imposm3, custom DDL) work via per-input keyword overrides:
lts_case_expression(
tags_jsonb="pw.tags::jsonb", # custom alias
highway_sql="pol.highway", # pre-extracted column
)
Classifier overrides flow into the SQL too — a city-specific
tuning produces SQL with the new defaults baked in:
clf = Classifier(speed_mph_fallback=20)
lts_case_expression(classifier=clf) # emits "ELSE 20" in the speed CASE
The submodule also exposes the building blocks individually
(speed_mph_expression, lane_count_expression,
cycleway_kind_expression, excluded_highways_in_list) for use in
custom queries.
Recipes for different OSM database layouts
1. osm2pgsql slim + hstore (the default) — tags lives as hstore
on planet_osm_ways.tags; no extra columns. The defaults work as-is:
from osm_lts.sql import lts_case_expression
sql = f"""
SELECT id, ({lts_case_expression()}) AS lts
FROM planet_osm_ways
WHERE tags ? 'highway'
"""
2. osm2pgsql slim, classifying alongside planet_osm_line geometry —
planet_osm_line materializes highway, name, etc. as columns, but
the full tag bag is back on planet_osm_ways.tags. Use the cheap column
where it's available, fall back to the tag bag for everything else:
sql = f"""
SELECT
pol.osm_id,
pol.way AS geom,
({lts_case_expression(
tags_jsonb="pw.tags::jsonb",
highway_sql="pol.highway",
maxspeed_sql="(pw.tags::jsonb)->>'maxspeed'",
)}) AS lts
FROM planet_osm_line pol
JOIN planet_osm_ways pw ON pw.id = pol.osm_id
WHERE pol.highway IS NOT NULL
"""
(Pre-filtering on pol.highway IS NOT NULL is much faster than
post-filtering on the LTS NULL result.)
3. osm2pgsql flex output with native jsonb tags — flex Lua often
defines a single table with tags jsonb (no hstore cast needed). Drop
the ::jsonb and the rest works:
sql = f"""
SELECT id, ({lts_case_expression(tags_jsonb="tags")}) AS lts
FROM osm_ways -- whatever your flex script named it
"""
4. imposm3 — different schema again. Tags live as hstore on
osm_roads.tags, but most fields you care about are already broken
out into columns. Use the materialized columns and convert the hstore
to jsonb only for cycleway sub-tags:
sql = f"""
SELECT
osm_id,
geometry,
({lts_case_expression(
tags_jsonb="hstore_to_jsonb(tags)", # for cycleway COALESCE
highway_sql="type", # imposm3 names it 'type'
maxspeed_sql="tags->'maxspeed'", # raw hstore -> text
bicycle_sql="tags->'bicycle'",
)}) AS lts
FROM osm_roads
"""
(tags->'k' is the hstore text accessor — analogous to JSON ->>.)
5. Custom schema with a pre-resolved cycleway column — if your ETL
already collapsed cycleway / cycleway:right / cycleway:left /
cycleway:both down to a single cycleway_kind column, skip the
COALESCE entirely:
sql = f"""
SELECT id, ({lts_case_expression(
cycleway_kind_sql="cycleway_kind", # plain column reference
)}) AS lts
FROM ways_with_resolved_cycleway
"""
6. Pre-filtering at the source — for tile servers and other hot paths, drop excluded highways at the row source so the CASE never sees them:
from osm_lts.sql import excluded_highways_in_list, lts_case_expression
sql = f"""
SELECT id, ({lts_case_expression()}) AS lts
FROM planet_osm_ways
WHERE tags->'highway' IS NOT NULL
AND tags->'highway' NOT IN ({excluded_highways_in_list()})
"""
Customizing the rules
Wrap a Classifier instance to override any of the defaults. Useful for
modeling a city or country whose posted-speed conventions or in-scope
highway set differ from the US-centric defaults the package ships with.
from osm_lts import Classifier, EXCLUDED_HIGHWAYS
# Stricter unknown-speed default:
strict = Classifier(speed_mph_fallback=20)
strict({"highway": "residential"}) # <LTS.MOST_ADULTS: 2>
# Drop pedestrian-priority paths out of scope entirely:
narrower = Classifier(excluded_highways=EXCLUDED_HIGHWAYS | {"path"})
narrower({"highway": "path", "bicycle": "designated"}) # None
# Per-highway speed overrides:
slower_residential = Classifier(speed_mph_by_highway={"residential": 20})
Classifier is a frozen dataclass — instances are hashable and
thread-safe to share. Use dataclasses.replace(clf, ...) for tweaked
copies.
Origin
Extracted from the Bike Streets city-mapping platform.
Development
pip install -e '.[test]'
pytest
License
MIT. See LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file osm_lts-0.4.1.tar.gz.
File metadata
- Download URL: osm_lts-0.4.1.tar.gz
- Upload date:
- Size: 21.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9363526577f0bbca52eda5eb2c409bcab6eed93574ab866985af02114f1dd5f9
|
|
| MD5 |
96acad9f180cc5c68b8b747b52dbf6ab
|
|
| BLAKE2b-256 |
f677bc3f90e7169260120307d18a3eeb90d276c07dccd7461978a1bd24ef0427
|
Provenance
The following attestation bundles were made for osm_lts-0.4.1.tar.gz:
Publisher:
release.yml on bikestreets/osm-lts
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
osm_lts-0.4.1.tar.gz -
Subject digest:
9363526577f0bbca52eda5eb2c409bcab6eed93574ab866985af02114f1dd5f9 - Sigstore transparency entry: 1570662943
- Sigstore integration time:
-
Permalink:
bikestreets/osm-lts@811c59aaeb9955afcf16e84b2ae7ad210fff04d9 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/bikestreets
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@811c59aaeb9955afcf16e84b2ae7ad210fff04d9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file osm_lts-0.4.1-py3-none-any.whl.
File metadata
- Download URL: osm_lts-0.4.1-py3-none-any.whl
- Upload date:
- Size: 17.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8eb9658df7f705b586ec87c15c7c1f34ea3ee79fa0491f13cb26decc7cd24a5f
|
|
| MD5 |
010b3ae40b2157502dac00c0ea4c2130
|
|
| BLAKE2b-256 |
fdceb3de3782890f0d55e1ae37ecbe804eeda0acee3b350750709bdff7099f1b
|
Provenance
The following attestation bundles were made for osm_lts-0.4.1-py3-none-any.whl:
Publisher:
release.yml on bikestreets/osm-lts
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
osm_lts-0.4.1-py3-none-any.whl -
Subject digest:
8eb9658df7f705b586ec87c15c7c1f34ea3ee79fa0491f13cb26decc7cd24a5f - Sigstore transparency entry: 1570662978
- Sigstore integration time:
-
Permalink:
bikestreets/osm-lts@811c59aaeb9955afcf16e84b2ae7ad210fff04d9 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/bikestreets
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@811c59aaeb9955afcf16e84b2ae7ad210fff04d9 -
Trigger Event:
push
-
Statement type: