Metadata-Version: 2.4
Name: bikelts
Version: 0.1.1
Summary: Bicycle Level of Traffic Stress (LTS 1-4) from OpenStreetMap tags or tables: Mekuria 2012, Furth 2017 and Bike Ottawa rulesets, assumption logging and low-stress connectivity analysis.
Author-email: Yusuf Eminoğlu <yusufeminoglu@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/YusufEminoglu/bikelts_sdk
Project-URL: Repository, https://github.com/YusufEminoglu/bikelts_sdk
Project-URL: Issues, https://github.com/YusufEminoglu/bikelts_sdk/issues
Project-URL: Changelog, https://github.com/YusufEminoglu/bikelts_sdk/blob/main/CHANGELOG.md
Keywords: cycling,bicycle,level-of-traffic-stress,lts,active-transportation,bike-network,low-stress-connectivity,openstreetmap,osm,transportation-planning,gis,accessibility
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: geo
Requires-Dist: geopandas>=0.12.0; extra == "geo"
Requires-Dist: shapely>=2.0.0; extra == "geo"
Provides-Extra: graph
Requires-Dist: networkx>=2.8.0; extra == "graph"
Provides-Extra: all
Requires-Dist: geopandas>=0.12.0; extra == "all"
Requires-Dist: shapely>=2.0.0; extra == "all"
Requires-Dist: networkx>=2.8.0; extra == "all"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: license-file

# bikelts

**Bicycle Level of Traffic Stress (LTS 1-4) for Python.** `bikelts` classifies street
segments and intersections for cycling from OpenStreetMap tags or from your own
tabular attributes, records *why* each segment got its score and *which inputs were
assumed*, and analyses the connectivity of the resulting low-stress network.

It is written for active-transportation planners and researchers who need a transparent,
reproducible LTS map rather than a black box.

- Three selectable, documented rulesets: **Mekuria, Furth & Nixon (2012)**,
  **Furth (2017) v2.0**, and the **Bike Ottawa** OSM tag rules.
- Directional results (`lts_forward`, `lts_backward`) with contraflow handling.
- Per-segment reasons and an assumption log, e.g.
  `maxspeed missing -> assumed 50 km/h from highway=residential urban default for country DE`.
- Intersection criteria: right-turn-lane approaches and unsignalized crossings.
- Network analysis in pure Python: LTS shares, low-stress islands, connectivity metrics,
  Dijkstra with an LTS cap, origin-destination reachability and detour ratios.
- Offline readers for OSM XML, Overpass JSON and GeoJSON; optional Overpass download.
- No required dependencies; optional `geopandas` and `networkx` converters.

## Installation

```bash
pip install bikelts              # core, no dependencies
pip install "bikelts[geo]"       # + geopandas/shapely (to_geodataframe)
pip install "bikelts[graph]"     # + networkx (to_networkx)
pip install "bikelts[all]"
```

Python 3.9 or newer.

## Quick start

### Command line

```bash
# Optional: fetch highways for a bounding box (south west north east) from Overpass
bikelts download --bbox 41.000 29.000 41.010 29.015 -o area.json --timeout 60

# Classify (OSM XML .osm, Overpass .json or GeoJSON) and write GeoJSON with LTS + reasons
bikelts classify area.osm -o lts.geojson --method furth2017 --country DE --intersections

# Low-stress islands, LTS shares and OD reachability
bikelts islands lts.geojson --max-lts 2 -o islands.geojson
bikelts stats lts.geojson --max-lts 2
bikelts reach lts.geojson --max-lts 2 --pairs 500 --max-detour 1.25

# Tabular input: CSV in, CSV out (columns listed below)
bikelts classify segments.csv -o segments_lts.csv --method mekuria2012

bikelts methods      # list rulesets with their sources
```

Every analysis command accepts `--json` for machine-readable output.

### Python

```python
from bikelts import LTSParameters, classify_tags

params = LTSParameters(country="DE")
r = classify_tags(
    {"highway": "secondary", "maxspeed": "50", "cycleway:right": "lane", "parking:right": "lane"},
    method="furth2017",
    params=params,
)
r.lts_forward, r.lts_backward       # (2, 3)
r.reasons
# ['forward: bike lane alongside parking: 1 lane per direction, reach 12-14 ft (reach 13 ft), 30 mph -> LTS 2',
#  'backward: mixed traffic, 1 thru lane per direction, ADT 3000+, 30 mph -> LTS 3']
r.assumptions
# ['ADT missing -> assumed 9000 veh/day from highway=secondary default',
#  'lanes missing -> assumed 2 from highway=secondary two-way default', ...]
```

A whole extract and its network:

```python
from bikelts import (LTSNetwork, LTSParameters, classify_osm, connectivity_metrics,
                     detect_crossings, low_stress_islands, od_reachability,
                     random_od_pairs, read_osm, to_geojson, write_geojson)

params = LTSParameters(country="DE")
data = read_osm("area.osm")                       # .osm, Overpass .json or .geojson
ways = classify_osm(data, "furth2017", params)
crossings = detect_crossings(data, ways, params)
write_geojson("lts.geojson", to_geojson(ways, crossings, data))

net = LTSNetwork.from_classified(ways, crossings)
islands = low_stress_islands(net, max_lts=2)
metrics = connectivity_metrics(net, max_lts=2)
results, summary = od_reachability(net, random_od_pairs(net, 500, seed=1), max_lts=2)
summary["low_stress_connected_share"]           # share of OD pairs connected at LTS <= 2
```

Tabular records (dicts or CSV rows) use `classify_record` / `classify_records`. See
[`examples/`](examples) for a runnable script, a synthetic OSM extract and a sample CSV.

## Rulesets

Select with `method=` (Python) or `--method` (CLI).

| | `mekuria2012` | `furth2017` (default) | `bikeottawa` |
|---|---|---|---|
| Source | Mekuria, Furth & Nixon (2012), MTI Report 11-19 | Furth (2017), *LTS Criteria for Road Segments* v2.0 | Bike Ottawa `stressmodel.js` v1.0.1 |
| Mixed traffic | total lanes (2-3 / 4-5 / 6+) x speed (<=25 / 30 / 35+ mph); lower value when no centreline and ADT <= 3000 | thru lanes per direction (unlaned / 1 / 2 / 3+) x **ADT** x speed (<=20 ... 50+ mph); effective ADT = 1.5 x ADT on one-way roads | `maxspeed` (km/h), total `lanes`, `highway=residential`, service types |
| Bike lane, no parking | weakest link over lanes/median, width (6 ft), speed, blockage | lanes x width (6+ / 4-5 ft) x speed; < 4 ft does not qualify | lanes, explicit width, speed, residential |
| Bike lane with parking | weakest link over lanes, reach (15 / 14 / 13.5 ft), speed, blockage | lanes x reach (15+ / 12-14 ft) x speed (<= 35 mph); reach < 12 ft does not qualify | lanes, explicit width, speed, residential |
| Separated paths, cycle tracks | LTS 1 | LTS 1 | LTS 1 (also `highway=footway`) |
| Blocked lanes | blockage row (LTS 3) | rated as mixed traffic | not used |
| ADT, centreline | ADT <= 3000 + no centreline footnote | central | not used |
| Direction | per direction | per direction | one score per way |
| Intersections | Table 5 and 6 (below) | Table 5 and 6 (below) | Table 6 if requested |

Table numbers for the 2012 criteria follow Furth's republished *LTS Criteria Tables*:
Table 1 segments by bikeway type, Table 2 bike lanes alongside a parking lane, Table 3 bike
lanes not alongside a parking lane, Table 4 mixed traffic, Table 5 intersection approaches
with a right-turn lane, Table 6a/6b unsignalized crossings without/with a crossing island.
The 2017 v2.0 document has three unnumbered tables (mixed traffic; bike lanes and shoulders
not adjacent to a parking lane; bike lanes alongside a parking lane). The test suite checks
every cell of all of these tables.

### Intersections

- **Right-turn lanes (Table 5)**: `right_turn_config(length_ft, bike_lane_position,
  turning_speed_mph, rt_lanes, option_lane)` returns one of `short_straight` (LTS >= 2),
  `long_straight` (>= 3), `shift_left` (>= 3) or `other` (4). A table row can carry
  `rt_lane_config`; the floor is applied to bike lanes and mixed traffic, not to separated
  facilities. OSM does not describe these geometries, so this is table input only.
- **Unsignalized crossings (Table 6)**: `unsignalized_crossing_lts(lanes, speed_mph, island)`.
  In OSM data, `detect_crossings` finds nodes where a *dominant* street (the hardest to cross,
  plus ways sharing its `name`/`ref`) passes through and a less major way meets it. Signals
  (`highway=traffic_signals`, `crossing=traffic_signals`, `crossing:signals=yes`) give LTS 1;
  islands come from `crossing:island=yes`, `crossing=island` or `traffic_calming=island`.
  Furth's 2017 update revised segments only, so the 2012 intersection tables are used with
  every ruleset.

In network analysis, a crossing with LTS above the cap blocks movements between two
non-dominant approaches (going straight across), while turning onto the dominant street is
governed by that street's own segment LTS.

## Inputs, defaults and assumptions

### OSM tags understood

| Topic | Tags |
|---|---|
| Access | `highway`, `bicycle` (`no`, `use_sidepath`, `dismount` close the way), `access`, `vehicle`, `motor_vehicle` |
| Direction | `oneway` (`yes`/`-1`/`reversible`), `oneway:bicycle`, `junction=roundabout`, `cycleway=opposite*`, `cycleway:<side>:oneway` |
| Bikeways | `cycleway`, `cycleway:left/right/both` = `lane`, `track`, `shared_lane`, `share_busway`, `opposite_lane`, `opposite_track`, `opposite`, `separate`, `no`; subkeys `:lane` (`advisory`, `pictogram`), `:width`, `:buffer`, `:separation` (kerb, bollard, flex_post... make a lane a protected lane), `sidewalk[:side]:bicycle=designated`, `shoulder[:side]` + `shoulder:access:bicycle` |
| Speed | `maxspeed` (`50`, `30 mph`, `DE:urban`, `FR:zone30`, `GB:nsl_single`, `none`, `walk`), `maxspeed:forward/backward`, `zone:maxspeed`, `source:maxspeed`, `bicycle_road`/`cyclestreet` |
| Lanes | `lanes`, `lanes:forward/backward`, `lanes:bus/psv` (excluded), `lane_markings`, `dual_carriageway` |
| Parking | new scheme `parking:<side>=lane/street_side/on_kerb/half_on_kerb/no/separate`, old scheme `parking:lane:<side>=parallel/diagonal/perpendicular/no_parking/no_stopping`, `parking:<side>:width` |
| Other | `surface` (paved / cobbled / unpaved classes), `adt`/`aadt` if you add counts |

Sides are mapped to travel directions using the driving side of the `country` (left-hand
traffic for GB, IE, JP, AU, NZ, IN, ZA and others).

### Defaults (all logged when used)

| Missing input | Default |
|---|---|
| `maxspeed` | national urban/rural/motorway/living-street limit for 35 countries (`LTSParameters(country=..., context=...)`), generic 50/90/120 km/h otherwise; `service`/`track` capped at 30 km/h |
| `lanes` | by class: trunk 4, primary...residential 2 (one-way: half) |
| centreline | none on residential, living_street, service, track; marked elsewhere |
| ADT | planning-level guess by class: residential 600, unclassified 1500, tertiary 4000, secondary 9000, primary 15000, trunk 25000 veh/day (`adt_overrides` to change) |
| bike lane width | 1.5 m (about 5 ft); buffer `yes` = 0.5 m; shoulder 1.2 m |
| parking next to a bike lane | absent (`assume_parking=True` to flip); parking lane 2.4 m |
| blockage | rare (`default_blockage="frequent"`) |
| speed columns | metric speeds are rounded to the nearest 5 mph before the mph tables (50 km/h -> 30 mph); `speed_binning="exact"` disables this |
| widths | rounded to the nearest 0.5 ft before the ft tables |

### Tabular columns

`id, highway, facility (mixed | bike_lane | shoulder | separated | no_motor_traffic),
speed_mph or speed_kmh, lanes or lanes_per_direction, oneway, contraflow, centerline, adt,
median, residential, blockage (rare | frequent), bike_lane_width_ft|_m, buffer_ft|_m, parking,
parking_width_ft|_m, reach_ft|_m, rt_lane_config, permitted, surface`. All optional; missing
values are assumed and logged.

## Network analysis

- `lts_share(net)` - length and share by LTS (worst permitted direction).
- `low_stress_islands(net, max_lts=2)` - components of the network of edges with LTS <= k in
  at least one direction, broken at crossings whose LTS exceeds k.
- `connectivity_metrics(net, max_lts)` - low-stress length and share, number of islands,
  largest-island shares, concentration index sum(L_i^2)/(sum L_i)^2, alpha/beta/gamma indices.
- `shortest_path`, `distances_from` - Dijkstra with direction, an LTS cap and crossing
  restrictions (states are node + incoming edge at crossing nodes).
- `od_reachability(net, pairs, max_lts=2, max_detour=1.25)` - for each OD pair the
  unrestricted and the low-stress shortest distance and their ratio; the summary gives the
  share reachable at low stress and the share *connected* within the detour allowance (25 %
  by default, following Mekuria et al. 2012).
- `to_networkx(net, max_lts)` and `to_geodataframe(ways)` with the optional extras.

## Limitations

- LTS is only as good as the tags. OSM rarely records ADT, bike lane width, parking, medians
  or right-turn lane geometry; check the `assumptions` field and supply better data (tags,
  `adt_overrides`, table input) where it matters.
- The speed used is the posted limit, not the prevailing (85th percentile) speed the criteria
  prefer; pass measured speeds through table input when you have them.
- Divided roads mapped as two one-way ways are counted per carriageway; use
  `dual_carriageway=yes` or `LTSParameters(infer_dual_carriageway=True)` for the 2012 street
  width.
- Crossing detection is a heuristic (dominant street by Table 6 LTS, lanes and speed); left
  turns, roundabouts and the draft roundabout criteria are not modelled.
- The `bikeottawa` port follows the reference rules but takes speeds and lanes from the bikelts
  parser (country defaults, units) and uses widths only when tagged; see the module docstring.
- Surfaces are reported but not scored unless you opt in with `unpaved_min_lts`, which is an
  extension and not part of any published criteria.
- Default speed tables reflect general national limits; regional and local rules differ.

## References

- Mekuria, M. C., Furth, P. G., & Nixon, H. (2012). *Low-Stress Bicycling and Network
  Connectivity* (MTI Report 11-19). Mineta Transportation Institute, San Jose State University.
- Furth, P. G. *LTS Criteria Tables* (republication of the 2012 criteria, Tables 1-6),
  Northeastern University.
- Furth, P. G. (2017). *Level of Traffic Stress Criteria for Road Segments*, version 2.0, June
  2017. Northeastern University.
- Bike Ottawa. *stressmodel* (version 1.0.1), https://github.com/BikeOttawa/stressmodel
- OpenStreetMap Wiki. *Key:cycleway*, *Key:parking*, *Default speed limits*.
- Kansky, K. J. (1963). *Structure of Transportation Networks*. University of Chicago.

If you use bikelts in research, please cite the method papers above and this software (see
[`CITATION.cff`](CITATION.cff)).

## Development

```bash
pip install -e ".[dev]"
pytest --cov=bikelts
ruff check . && ruff format --check src tests && mypy src
```

## License

MIT, see [LICENSE](LICENSE). OSM data is (c) OpenStreetMap contributors under the ODbL; the
sample extract in `examples/` is synthetic.
