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.
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
- 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-estrattito resolve--areaagainst 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=yesorramp: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=yesare excluded as not legally bikeable; ways taggedsac_scale/mtb:scaleabove a walking/easy threshold are excluded as mountain trails, not bikeable infrastructure;accessvalues that mean "not open to the general public" -private,permit,customers,delivery,agricultural,forestry,destination,military- are excluded too, and so ishighway=service(driveways, parking aisles, alleys), unless a more specificbicycle=yes/designated/permissive/official/dismounttag explicitly overrides it;highway=footwayis excluded outright unless one of those same tags is present - OSM convention treats a footway as foot traffic only by default, unlikehighway=pathwhich stays open by default - exceptfootway=crossing, always kept as a mixed-traffic street crossing;bicycle=dismounton a footway/path is kept in the network but downgraded to LTS 2, not the LTS 1 a plainbicycle=yesgets, since it means walking the bike rather than riding it;highway=living_streetis 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 nomaxspeedtag 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; acycleway=lane/cycleway:*=lanepainted bike lane with no adjacent parking is scored against its own, more lenient width/speed thresholds (<1.7mwidth,<=65km/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; atertiary/unclassified/serviceway is treated as residential-equivalent (quiet local connector) unless itsref/old_refstarts 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). - 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'sMIN_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). - 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).
| 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.tomlrequirements.lock.txtrequirements-geo.lock.txt
- Python 3.9+
pip- (Optional, for HTML report)
pandoc(system package, not a pip dependency)
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-reportIf 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 SlopeServiceFor 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).
LTSBP_DEM_PATH- Path to a local DEM.tiffile, used byfetchinstead of the automatic Mapterhorn download (see below).- Default: unset (DEM is fetched automatically)
LTSBP_SLOPE_STRATEGY- Slope strategy selector (v1,v2,v3).- Default:
v3
- Default:
LTSBP_DATA_DIR- Runtime data directory.- Default:
data/
- Default:
LTSBP_IMAGES_DIR- Runtime output images directory.- Default:
images/
- Default:
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.
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.
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-reportRun full pipeline (includes extended analysis modules):
ltsbikeplan run-full --area "Provincia di Trento, Italy" --with-reportFaster 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-reportRun tests:
python -m unittest discover -s tests -p "test_*.py"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.
Optional (for extended sections):
- Accidents file:
data/accidents_<area_slug>.geojson. - Population/destination datasets (used by destination-access/sum-up modules).
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").
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/pathways across several Italian comuni (Arenzano, Venezia, Palermo, Milano, Roma, Bologna) carry nobicycletag at all - not necessarily meaning cycling is prohibited, just that OSM has nothing recorded either way (seeBikePathAnalysis.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=yesis 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.
- Create a feature branch.
- Keep changes modular under
code/ltsbikeplan/. - Run tests locally before opening PR:
python -m unittest discover -s tests -p "test_*.py"
This project is licensed under the WTFPL v2. See LICENSE.