Skip to content

Repository files navigation

LTSBikePlan

Level of Traffic Stress bike-network pipeline for city-scale planning.

LTSBikePlan computes bike network stress from OpenStreetMap + terrain data, then generates maps and analysis outputs to support safer cycling infrastructure decisions.

Citation

If you use this project, please cite:

Venturoso, L., Usmani, M., Nanni, R., & Napolitano, M. (2026). LTS-BikePlan: A Data-Driven Tool for Enhancing Cycling Infrastructure and Safety. Journal of Urban Technology, 1-42. https://doi.org/10.1080/10630732.2026.2639290

Key Features

  • Modular CLI pipeline (fetch, compute-lts, maps, report, run, run-full, doctor).
  • Works anywhere via --area/--city (both resolved through osmnx/Nominatim/Overpass by default, matching the original paper's tool) - pass --osmit-estratti to resolve --area against osmit-estratti's pre-built Italian region/province/comune index instead, faster for large areas but Italy-only.
  • DEM is fetched automatically per area from Mapterhorn (open terrain tiles, 10m over Italy) - no more manual TINITALY download unless you want a higher-resolution local override, regardless of which area-fetch source you use.
  • LTS classification engine for edges and nodes with explicit decision-rule mapping (including highway=steps: not bikeable - LTS 0 - unless the way carries a bicycle ramp, ramp=yes or ramp:bicycle=yes, which makes it LTS 1; a stressful street running alongside its own separated cycleway for most of its length is downgraded rather than flagged as a gap; highway=trunk/motorroad=yes are excluded as not legally bikeable; ways tagged sac_scale/mtb:scale above a walking/easy threshold are excluded as mountain trails, not bikeable infrastructure; access values that mean "not open to the general public" - private, permit, customers, delivery, agricultural, forestry, destination, military - are excluded too, and so is highway=service (driveways, parking aisles, alleys), unless a more specific bicycle=yes/designated/permissive/official/dismount tag explicitly overrides it; highway=footway is excluded outright unless one of those same tags is present - OSM convention treats a footway as foot traffic only by default, unlike highway=path which stays open by default - except footway=crossing, always kept as a mixed-traffic street crossing; bicycle=dismount on a footway/path is kept in the network but downgraded to LTS 2, not the LTS 1 a plain bicycle=yes gets, since it means walking the bike rather than riding it; highway=living_street is fixed at LTS 1, matching its legally traffic-calmed, pedestrian/cyclist-priority speed limit by definition rather than falling through to the generic maxspeed-based scoring; a way with no maxspeed tag but a historic-centre paving surface (sett/cobblestone/unhewn_cobblestone/paving_stones - Italian centri storici, piazzas) assumes 30 km/h instead of its highway class's generic default, since the paving itself physically caps real traffic speed; a cycleway=lane/cycleway:*=lane painted bike lane with no adjacent parking is scored against its own, more lenient width/speed thresholds (<1.7m width, <=65 km/h low-stress ceiling) rather than the ones meant for a lane squeezed against parked cars, which need a wider buffer for opening car doors; a tertiary/unclassified/service way is treated as residential-equivalent (quiet local connector) unless its ref/old_ref starts with "SS" (a real national state highway) - an "SP"/"SR" ref alone no longer disqualifies it, since that denotes only which body maintains the road, not its actual character, and Italy's rural provincial network covers everything from a real arterial down to a single-lane farm lane; highway=pedestrian/living_street stay on their own unconditional LTS 1 rule even when tagged cycleway=yes - that tag there almost always means "cyclists are additionally allowed here," not "there's a demarcated lane with its own width/speed profile to analyze," so it no longer gets diverted into the bike-lane scoring above; a secondary/primary way with a bike lane is no longer automatically bucketed as "not residential" (b9/c7) when it's paved in historic-centre stone AND actually sits in a built-up area (context == "urban") - plenty of small Italian towns route a real secondary/primary road straight through the historic piazza, where it's locally no more stressful than a residential street despite the functional classification; the same paving out in the countryside doesn't get this leniency, since it says nothing about traffic calm there; a shared, non-physically-separated cycle path/track (segregated=no) is downgraded from LTS 1 to LTS 2 only when it also sits at a genuine multi-street convergence point - top quartile of betweenness centrality among OTHER segregated=no separated paths in the same area, not the area's whole street network, since an ordinary well-used cycleway routinely lands in the generic "very high" centrality bucket already - a quiet segregated=no path away from any real hub is left at LTS 1, since sharing space with an occasional pedestrian is not on its own a source of real stress; note that this last rule inherits a pre-existing limitation of annotate_edge_centrality's betweenness calculation - it's sampled, not exact, for performance on comune/province-sized graphs, and the sample isn't perfectly stable across separate fetches of the same area, since pyrosm/osmnx don't guarantee identical node ordering run to run even with a fixed random seed; a real hub's edge (comfortably above the threshold) is unaffected, but an edge whose centrality happens to sit right at the quartile boundary can occasionally flip between LTS 1 and LTS 2 across two regenerations of the same, unchanged OSM data - confirmed live on Bologna's own reported case, way 1171562902).
  • Isolated-bikeable-component pruning (domain/network_pruning.py::drop_isolated_bikeable_components, run after LTS classification): drops a short (<500m) connected component of the bikeable network (lts > 0) that doesn't come within 50m of the comune's own administrative boundary - a real disconnected fragment (an unconnected network of paths inside a park, a knot of Venice sottoporteghi only reachable from the rest of the city via unramped bridge steps) rather than a road cut short by the comune extract's own edge, which keeps flowing into the neighbouring comune's extract in reality. Checks connectivity through bikeable edges only, not the raw physical network - a component reachable only via an excluded link (unramped steps, a private road, a mountain trail too technical to ride) still counts as isolated, since a cyclist can't actually use that link either way.
  • DEM-based slope integration with selectable slope strategies. A segment's slope is the mean of whichever ~10m DEM cells its geometry crosses, which isn't reliable on a short segment - too few cells for that mean to mean anything (measured directly against a real batch of short (<40m) mis-scored edges: median 17.8m crossed a median of only 3.5 cells, with a per-edge cell-to-cell spread up to ~6°, i.e. genuine measurement noise, not a real grade). Standard error of that mean shrinks as σ/√n_cells; solving for n_cells against the observed σ (~1-3°) to keep the residual error under ~1° (half a slope-class band's width) needs roughly 4-36 cells, i.e. ~40-360m depending on how conservative you want to be - domain/lts_rules.py::BikePathAnalysis.slope_penalty's MIN_RELIABLE_SLOPE_LENGTH_M (500m) clears even the conservative end with margin, and gates every slope class this way (not just the "5-8: medium" one - "8-10: hard" and steeper used to apply with no length floor at all). Fragments sharing the same osmid are grouped so a long way cut short by driveway/footway crossings still gets judged on its combined length, not each individually-short piece - but a short fragment's own reading is excluded from that group's weighted average if it's >20% ("impossible" for a rideable road), since that's DEM noise, not real grade, unless the fragment is already long enough (>=500m) to be reliable on its own (a genuinely long, sustained impossible-grade reading is trusted). Real case this fixes: OSM way 258610048 in Arenzano, a parking-lot access loop where a few ~5-10m fragments read 22-27% purely from noise, dragging the whole loop's LTS up to 4 off a group average that included them. A group's real physical length is deduplicated first (a two-way street's graph representation carries both directions of every fragment, which double-counted length before this and could cross the 500m reliability threshold on half the real distance - real case: OSM way 92293835, "Via Giulio Zunino," 250m of real fragments read as ~500m). Where available, BikePathAnalysis.slope_penalty prefers a group's NET endpoint-to-endpoint elevation change (services/slope_strategies.py::sample_node_elevations DEM-samples every node's own elevation once) over the length-weighted mean of noisy per-fragment readings - errors from real micro-undulation/DEM artifacts between a way's own closely-spaced vertices cancel across just the 2 real endpoints instead of accumulating once per fragment, reliable at a much shorter 150m (vs. 500m) since the residual error no longer scales with fragment count. A loop (same start/end node) nets to ~0 automatically, without a separate exclusion rule. An explicit incline tag (mapper-verified, not DEM-derived) overrides both methods entirely regardless of length wherever present - real case: OSM ways 43085064/367406947/43085065 in Arenzano, incline=30-35% streets so short in total that neither DEM method ever judged them reliable enough to penalize at all.
  • Core map generation (slope_map, lts_map, choropleth_lts_map) plus a CRS-aware GeoParquet/GeoJSON export ready for vector tiling.
  • Extended analysis modules for ESDA, clusters, network, gap, destination-access, accidents, and sum-up.
  • Report generation (report.md + report.html) including only available artifacts.
  • Manual-input diagnostics via ltsbikeplan doctor.
  • A static MapLibre GL JS + PMTiles viewer (web/) with a 3D terrain toggle, a gap-analysis panel (low-stress network "islands" + candidate segments to close the gaps between them), client-side bike routing, and a URL that mirrors the full view state for sharing - see WEB.md (and ROUTING.md for the routing engine specifically).

Tech Stack

Area Tech
Language Python 3.9+
Packaging pyproject.toml + setuptools
Core libs numpy, pandas, requests
Geo/network geopandas, osmnx, shapely, networkx, folium, rasterio
ML/analysis scikit-learn, matplotlib
Optional rpy2 (legacy "v1" slope strategy only)
Testing unittest
CI GitHub Actions

Dependency definitions:

  • pyproject.toml
  • requirements.lock.txt
  • requirements-geo.lock.txt

Getting Started

Prerequisites

  • Python 3.9+
  • pip
  • (Optional, for HTML report) pandoc (system package, not a pip dependency)

Installation

git clone <your-fork-or-repo-url>
cd LTSBikePlan

python -m venv .venv
source .venv/bin/activate

pip install --upgrade pip
pip install -r requirements.lock.txt
pip install -e .

Install directly from a GitHub release tag:

pip install "git+https://github.com/dclfbk/LTSBikePlan.git@v3.0.0"

After installing, use the CLI from any shell:

ltsbikeplan doctor --city "Trento, Italy"
ltsbikeplan run-full --city "Bolzano, Italy" --with-report

If you want to reuse the code from another Python project, import the package modules directly:

from ltsbikeplan.cli import main
from ltsbikeplan.services.slope_service import SlopeService

For geospatial/full pipeline modules:

pip install -r requirements-geo.lock.txt
pip install -e .

pip install -e . here (not .[geo]) just to install this package itself without pip re-resolving the geo extra's version ranges against the exact pins already installed from the lock file above; richdem isn't part of that extra any more (see requirements-geo.lock.txt's own comment: services/slope_strategies.py's slope computation no longer uses it, or GDAL, at all - point-sampling the DEM directly needs nothing beyond rasterio).

Environment Variables

  • LTSBP_DEM_PATH - Path to a local DEM .tif file, used by fetch instead of the automatic Mapterhorn download (see below).
    • Default: unset (DEM is fetched automatically)
  • LTSBP_SLOPE_STRATEGY - Slope strategy selector (v1, v2, v3).
    • Default: v3
  • LTSBP_DATA_DIR - Runtime data directory.
    • Default: data/
  • LTSBP_IMAGES_DIR - Runtime output images directory.
    • Default: images/

DEM (Automatic)

By default fetch downloads elevation data automatically for whatever area you pass via --area/--city, from Mapterhorn (open Terrarium-encoded terrain tiles, 10m resolution over Italy - no account, no manual download). It's fetched once per area and cached under LTSBP_DATA_DIR/_cache/.

If you need a specific higher-resolution source instead (e.g. the 2-2.5m TINITALY tiles available in Alto Adige/Aosta), download it manually from tinitaly.pi.ingv.it, merge multi-tile areas into a single .tif, and set LTSBP_DEM_PATH=/absolute/path/to/your_dem.tif - this skips the automatic Mapterhorn fetch entirely.

Usage & Commands

Every command takes an area selector: --area NAME or --city NAME (equivalent - --city is kept as an alias for readability when naming a single place). Both resolve via osmnx/Nominatim/Overpass by default - works for any place worldwide, no extra dependency, matching how the original paper's tool worked. Add --osmit-estratti to resolve --area against osmit-estratti's pre-built Italian region/province/comune index instead - faster for large regioni/province, and the only way to disambiguate a name that matches at more than one admin level (--area-level {comune,provincia,regione}) or select precisely by ISTAT code (--istat CODE); both flags are ignored without --osmit-estratti. This osmnx/Overpass path now requests every extra tag BikePathAnalysis reads (services/graph_services.py's _EXTRA_USEFUL_TAGS_WAY) - it's easy for a new one to go quietly missing here, since osmnx's own default tag set is short and a missing tag doesn't error, it just leaves the whole column absent (found live: bicycle itself was missing until 2026-09-08, silently voiding every bicycle=yes/no/dismount/designated rule in biking_permitted on this path - a user's own bicycle=no edit for Venice's citywide cycling ban had no effect at all through a LTSBP_NO_OSMIT_ESTRATTI=1 re-fetch until this was fixed).

Check setup and manual inputs:

ltsbikeplan doctor --city "Trento, Italy"

Run modular pipeline (default: osmnx/Overpass):

ltsbikeplan fetch --area "Trento, Italy"
ltsbikeplan compute-lts --area "Trento, Italy"
ltsbikeplan maps --area "Trento, Italy"
ltsbikeplan report --area "Trento, Italy"

Run core end-to-end:

ltsbikeplan run --area "Trento, Italy" --with-report

Run full pipeline (includes extended analysis modules):

ltsbikeplan run-full --area "Provincia di Trento, Italy" --with-report

Faster Italy-only workflow via osmit-estratti's pre-built extracts:

ltsbikeplan run --area Trento --area-level comune --osmit-estratti --with-report
ltsbikeplan run-full --area "Provincia di Trento" --osmit-estratti --with-report

Run tests:

python -m unittest discover -s tests -p "test_*.py"

Web Viewer & Deployment

This README covers the data pipeline only. The public site built on top of it (web/) - the MapLibre viewer, the stats drill-down pages, tileset building, and the production nginx/systemd deployment - has its own doc: WEB.md. The client-side bike-routing engine specifically is documented separately in ROUTING.md.

Manual Inputs

Optional (for extended sections):

  • Accidents file: data/accidents_<area_slug>.geojson.
  • Population/destination datasets (used by destination-access/sum-up modules).

Project Structure

LTSBikePlan/
├── code/
│   ├── cli.py                        # thin CLI entry wrapper
│   ├── ltsbikeplan/
│   │   ├── assets/                   # static assets (rule dict, report css)
│   │   ├── domain/                   # core LTS domain logic + AreaSpec/CRS constants
│   │   ├── services/                 # reusable services (graph, slope, DEM, OSM ingestion, export, report...)
│   │   ├── pipeline/                 # runtime pipelines and section modules
│   │   ├── cli.py                    # official CLI implementation
│   │   └── runtime_requirements.py   # manual input registry
│   └── old_code/                     # archived notebooks/legacy scripts
├── scripts/build_tiles.sh            # GeoJSON -> PMTiles build for one area
├── scripts/build_national_tiles.sh   # merges every processed area into one PMTiles tileset (capped z4-11)
├── scripts/build_comuni_index.py     # web/data/comuni_index.json - istat/slug/bbox for the z12+ per-comune swap
├── scripts/build_italy_map_cron.sh   # unattended full-Italy rebuild (all province)
├── scripts/build_italy_map_comuni_cron.sh  # same, at comune granularity, incremental/resumable
├── scripts/setup_server.sh           # one-time Ubuntu provisioning for production deploy
├── deploy/                           # nginx site config + systemd timer for production
├── web/                               # static MapLibre GL JS + PMTiles viewer (see WEB.md)
├── tests/                            # unit and smoke tests
├── pyproject.toml                    # package metadata + entrypoints
├── requirements.lock.txt             # pinned core dependencies
├── requirements-geo.lock.txt         # pinned geospatial dependencies
├── README.md                         # this file - data pipeline
├── WEB.md                            # web viewer, stats site, tileset builds, deployment
└── ROUTING.md                        # client-side bike-routing engine

Note: there is currently no .github/workflows/ CI configuration in this repository despite earlier docs referencing one - tests are run manually (python -m unittest discover -s tests -p "test_*.py").

Future Work / Desiderata

Ideas not yet implemented, kept here so they aren't re-discovered from scratch:

  • Street-level imagery to flag under-tagged footway/path segments. A real audit (Sept 2026) found 82-98% of highway=footway/path ways across several Italian comuni (Arenzano, Venezia, Palermo, Milano, Roma, Bologna) carry no bicycle tag at all - not necessarily meaning cycling is prohibited, just that OSM has nothing recorded either way (see BikePathAnalysis.biking_permitted's footway default-deny). A vision model over street-level photos (Mapillary, KartaView, or Panoramax - an open, IGN-backed, decentralized alternative worth evaluating alongside/instead of Mapillary for licensing and coverage) could surface a "worth checking" signal for these segments to a human mapper. Two caveats that keep this a desiderata rather than a real feature: (1) bicycle=yes is a legal/local-knowledge fact more often than a visible physical sign, so a model would mostly be estimating physical attributes (width, surface, presence of an explicit shared-path pictogram) rather than legal permission itself; (2) street-level imagery coverage in Italy is very uneven (dense in actively-mapped cities, often empty in small comuni), so this would never be a general-purpose replacement for the tag - at most a review queue, and never an automatic tag-writer or a replacement for the deterministic, citable decision rules the LTS engine is built on.

Contributing

  1. Create a feature branch.
  2. Keep changes modular under code/ltsbikeplan/.
  3. Run tests locally before opening PR:
    • python -m unittest discover -s tests -p "test_*.py"

License

This project is licensed under the WTFPL v2. See LICENSE.

About

Level of Traffic Stress Bike Planning and Infrastructure Network Design for Safe and Accessible Cycling

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages