osm-lts 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osm_lts-0.2.0/.github/workflows/release.yml +31 -0
- osm_lts-0.2.0/.github/workflows/test.yml +23 -0
- osm_lts-0.2.0/.gitignore +22 -0
- osm_lts-0.2.0/CHANGELOG.md +29 -0
- osm_lts-0.2.0/LICENSE +21 -0
- osm_lts-0.2.0/PKG-INFO +157 -0
- osm_lts-0.2.0/README.md +129 -0
- osm_lts-0.2.0/pyproject.toml +56 -0
- osm_lts-0.2.0/src/osm_lts/__init__.py +32 -0
- osm_lts-0.2.0/src/osm_lts/_classify.py +255 -0
- osm_lts-0.2.0/src/osm_lts/_constants.py +66 -0
- osm_lts-0.2.0/src/osm_lts/cli.py +100 -0
- osm_lts-0.2.0/src/osm_lts/py.typed +0 -0
- osm_lts-0.2.0/tests/test_classifier.py +145 -0
- osm_lts-0.2.0/tests/test_classify.py +140 -0
- osm_lts-0.2.0/tests/test_cli.py +42 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: release
|
|
10
|
+
cancel-in-progress: false
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
id-token: write # Trusted Publishing OIDC exchange
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
|
+
- uses: actions/setup-python@v6
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.13"
|
|
22
|
+
cache: pip
|
|
23
|
+
- run: pip install -e '.[test]' build
|
|
24
|
+
- run: pytest
|
|
25
|
+
- run: python -m build
|
|
26
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
27
|
+
with:
|
|
28
|
+
# No-op when pyproject's version is already on PyPI, so a
|
|
29
|
+
# routine commit that doesn't bump the version is silently
|
|
30
|
+
# skipped instead of failing the workflow.
|
|
31
|
+
skip-existing: true
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
- uses: actions/setup-python@v6
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
cache: pip
|
|
22
|
+
- run: pip install -e '.[test]'
|
|
23
|
+
- run: pytest
|
osm_lts-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-05-15
|
|
4
|
+
|
|
5
|
+
- New `Classifier` class — frozen dataclass exposing the previously-
|
|
6
|
+
hardcoded defaults (excluded highways, per-highway speed/lane
|
|
7
|
+
defaults, fallback speed/lane counts, cycleway sub-tag priority)
|
|
8
|
+
as overridable fields. `classify(tags)` is a convenience wrapper
|
|
9
|
+
around the default-configured singleton; `Classifier(...)` is the
|
|
10
|
+
way to model regions whose conventions differ from the US-centric
|
|
11
|
+
defaults.
|
|
12
|
+
- Instances are callable: `Classifier()(tags)` works alongside
|
|
13
|
+
`.classify(tags)`.
|
|
14
|
+
- Tests pin every override surface (excluded highways add/drop,
|
|
15
|
+
speed fallback flipping the LTS 4 threshold, lane-count fallback
|
|
16
|
+
promoting to LTS 4, cycleway sub-tag priority reordering) plus
|
|
17
|
+
frozen-instance enforcement and per-instance dict isolation.
|
|
18
|
+
|
|
19
|
+
## 0.1.0 — 2026-05-15
|
|
20
|
+
|
|
21
|
+
Initial release. Pure-Python Furth Level of Traffic Stress classifier
|
|
22
|
+
extracted from the Bike Streets routing platform.
|
|
23
|
+
|
|
24
|
+
- `osm_lts.classify(tags)` — returns `LTS` 1–4 (or `None` for excluded
|
|
25
|
+
highways) given an OSM tag mapping. Tolerates units in `maxspeed`
|
|
26
|
+
and `lanes`, walks `cycleway` and per-side variants.
|
|
27
|
+
- `osm-lts classify` CLI for batch JSON / JSONL / array input.
|
|
28
|
+
- Tests pin every Furth rule branch plus edge cases (unit strings,
|
|
29
|
+
cycleway sub-tags, separation overriding speed, multi-lane bumps).
|
osm_lts-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bike Streets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
osm_lts-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: osm-lts
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Classify OpenStreetMap ways by Level of Traffic Stress (LTS) using the Furth methodology.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bikestreets/osm-lts
|
|
6
|
+
Project-URL: Source, https://github.com/bikestreets/osm-lts
|
|
7
|
+
Project-URL: Issues, https://github.com/bikestreets/osm-lts/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/bikestreets/osm-lts/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Bike Streets
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: advocacy,bicycle,cycling,level-of-traffic-stress,lts,openstreetmap,osm,transportation
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
23
|
+
Classifier: Topic :: Sociology
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# osm-lts
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/osm-lts/)
|
|
32
|
+
[](https://pypi.org/project/osm-lts/)
|
|
33
|
+
[](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml)
|
|
34
|
+
[](https://github.com/bikestreets/osm-lts/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
Classify OpenStreetMap ways by **Level of Traffic Stress (LTS)** using the
|
|
37
|
+
[Furth methodology](https://peterfurth.sites.northeastern.edu/level-of-traffic-stress/).
|
|
38
|
+
|
|
39
|
+
LTS is a 1–4 scale from "kid-comfortable" (1) to "strong-and-fearless only"
|
|
40
|
+
(4). It's the standard advocacy and planning input for "where is the bike
|
|
41
|
+
network actually rideable for a typical adult" — far more honest than miles
|
|
42
|
+
of "bike infrastructure" because it captures whether that infrastructure is
|
|
43
|
+
on a calm street or a six-lane arterial.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install osm-lts
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Pure Python, no dependencies, Python 3.9+.
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from osm_lts import classify
|
|
57
|
+
|
|
58
|
+
classify({"highway": "residential", "maxspeed": "25 mph"})
|
|
59
|
+
# <LTS.MOST_ADULTS: 2>
|
|
60
|
+
|
|
61
|
+
classify({"highway": "primary"})
|
|
62
|
+
# <LTS.STRONG_AND_FEARLESS: 4>
|
|
63
|
+
|
|
64
|
+
classify({"highway": "cycleway"})
|
|
65
|
+
# <LTS.KID_COMFORTABLE: 1>
|
|
66
|
+
|
|
67
|
+
classify({"highway": "footway"})
|
|
68
|
+
# None — outside scope (not relevant to cyclist stress)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The function takes any `Mapping[str, str]` of OSM tags. Numeric tags
|
|
72
|
+
(`maxspeed`, `lanes`) tolerate units (`"25 mph"`, `"50 km/h"`, `"4;3"`) —
|
|
73
|
+
only the leading digits are read. The result is an `IntEnum`, so
|
|
74
|
+
`int(classify(tags))` gives you the bare LTS value for serialization.
|
|
75
|
+
|
|
76
|
+
### CLI
|
|
77
|
+
|
|
78
|
+
The package ships with an `osm-lts` command for batch jobs:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
echo '{"highway": "residential", "maxspeed": "25 mph"}' | osm-lts classify
|
|
82
|
+
# {"tags": {"highway": "residential", "maxspeed": "25 mph"}, "lts": 2}
|
|
83
|
+
|
|
84
|
+
osm-lts classify --in ways.jsonl --out lts.jsonl
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Input is JSON, JSONL, or a single JSON array — auto-detected.
|
|
88
|
+
|
|
89
|
+
## How it works
|
|
90
|
+
|
|
91
|
+
The classifier mirrors Furth's published rules:
|
|
92
|
+
|
|
93
|
+
| Tier | Description | Example triggers |
|
|
94
|
+
| :---: | ---------------------------- | --------------------------------------------------------------- |
|
|
95
|
+
| LTS 1 | Suitable for children | `highway=cycleway`, `living_street`, `cycleway=track` |
|
|
96
|
+
| LTS 2 | Most adults will tolerate | `residential` ≤25 mph, bike lane on a slow street |
|
|
97
|
+
| LTS 3 | Experienced cyclists only | `tertiary`, fast residential, bike lane on a faster street |
|
|
98
|
+
| LTS 4 | Strong-and-fearless only | `primary` / `trunk`, `>35 mph`, `≥3 lanes` and `>30 mph` |
|
|
99
|
+
|
|
100
|
+
The branches evaluate top-to-bottom and short-circuit on the first match.
|
|
101
|
+
Order matters — a `cycleway=track` on a 40 mph arterial returns LTS 1
|
|
102
|
+
because separation wins over speed. Highways outside scope (`motorway`,
|
|
103
|
+
`footway`, `sidewalk`, `steps`, `pedestrian`) return `None`.
|
|
104
|
+
|
|
105
|
+
When `maxspeed` or `lanes` are missing, highway-typical defaults fill in:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from osm_lts import (
|
|
109
|
+
DEFAULT_SPEED_MPH_BY_HIGHWAY,
|
|
110
|
+
DEFAULT_LANE_COUNT_BY_HIGHWAY,
|
|
111
|
+
EXCLUDED_HIGHWAYS,
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
These are public so callers can read them in their own UIs (e.g. "we
|
|
116
|
+
assumed 25 mph because the way was untagged").
|
|
117
|
+
|
|
118
|
+
### Customizing the rules
|
|
119
|
+
|
|
120
|
+
Wrap a `Classifier` instance to override any of the defaults. Useful for
|
|
121
|
+
modeling a city or country whose posted-speed conventions or in-scope
|
|
122
|
+
highway set differ from the US-centric defaults the package ships with.
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from osm_lts import Classifier, EXCLUDED_HIGHWAYS
|
|
126
|
+
|
|
127
|
+
# Stricter unknown-speed default:
|
|
128
|
+
strict = Classifier(speed_mph_fallback=20)
|
|
129
|
+
strict({"highway": "residential"}) # <LTS.MOST_ADULTS: 2>
|
|
130
|
+
|
|
131
|
+
# Drop pedestrian-priority paths out of scope entirely:
|
|
132
|
+
narrower = Classifier(excluded_highways=EXCLUDED_HIGHWAYS | {"path"})
|
|
133
|
+
narrower({"highway": "path", "bicycle": "designated"}) # None
|
|
134
|
+
|
|
135
|
+
# Per-highway speed overrides:
|
|
136
|
+
slower_residential = Classifier(speed_mph_by_highway={"residential": 20})
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`Classifier` is a frozen dataclass — instances are hashable and
|
|
140
|
+
thread-safe to share. Use `dataclasses.replace(clf, ...)` for tweaked
|
|
141
|
+
copies.
|
|
142
|
+
|
|
143
|
+
## Origin
|
|
144
|
+
|
|
145
|
+
Extracted from the [Bike Streets](https://bikestreets.com/) city-mapping
|
|
146
|
+
platform.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
pip install -e '.[test]'
|
|
152
|
+
pytest
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT. See [LICENSE](LICENSE).
|
osm_lts-0.2.0/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# osm-lts
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/osm-lts/)
|
|
4
|
+
[](https://pypi.org/project/osm-lts/)
|
|
5
|
+
[](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml)
|
|
6
|
+
[](https://github.com/bikestreets/osm-lts/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
Classify OpenStreetMap ways by **Level of Traffic Stress (LTS)** using the
|
|
9
|
+
[Furth methodology](https://peterfurth.sites.northeastern.edu/level-of-traffic-stress/).
|
|
10
|
+
|
|
11
|
+
LTS is a 1–4 scale from "kid-comfortable" (1) to "strong-and-fearless only"
|
|
12
|
+
(4). It's the standard advocacy and planning input for "where is the bike
|
|
13
|
+
network actually rideable for a typical adult" — far more honest than miles
|
|
14
|
+
of "bike infrastructure" because it captures whether that infrastructure is
|
|
15
|
+
on a calm street or a six-lane arterial.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install osm-lts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Pure Python, no dependencies, Python 3.9+.
|
|
24
|
+
|
|
25
|
+
## Use
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from osm_lts import classify
|
|
29
|
+
|
|
30
|
+
classify({"highway": "residential", "maxspeed": "25 mph"})
|
|
31
|
+
# <LTS.MOST_ADULTS: 2>
|
|
32
|
+
|
|
33
|
+
classify({"highway": "primary"})
|
|
34
|
+
# <LTS.STRONG_AND_FEARLESS: 4>
|
|
35
|
+
|
|
36
|
+
classify({"highway": "cycleway"})
|
|
37
|
+
# <LTS.KID_COMFORTABLE: 1>
|
|
38
|
+
|
|
39
|
+
classify({"highway": "footway"})
|
|
40
|
+
# None — outside scope (not relevant to cyclist stress)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The function takes any `Mapping[str, str]` of OSM tags. Numeric tags
|
|
44
|
+
(`maxspeed`, `lanes`) tolerate units (`"25 mph"`, `"50 km/h"`, `"4;3"`) —
|
|
45
|
+
only the leading digits are read. The result is an `IntEnum`, so
|
|
46
|
+
`int(classify(tags))` gives you the bare LTS value for serialization.
|
|
47
|
+
|
|
48
|
+
### CLI
|
|
49
|
+
|
|
50
|
+
The package ships with an `osm-lts` command for batch jobs:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
echo '{"highway": "residential", "maxspeed": "25 mph"}' | osm-lts classify
|
|
54
|
+
# {"tags": {"highway": "residential", "maxspeed": "25 mph"}, "lts": 2}
|
|
55
|
+
|
|
56
|
+
osm-lts classify --in ways.jsonl --out lts.jsonl
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Input is JSON, JSONL, or a single JSON array — auto-detected.
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
The classifier mirrors Furth's published rules:
|
|
64
|
+
|
|
65
|
+
| Tier | Description | Example triggers |
|
|
66
|
+
| :---: | ---------------------------- | --------------------------------------------------------------- |
|
|
67
|
+
| LTS 1 | Suitable for children | `highway=cycleway`, `living_street`, `cycleway=track` |
|
|
68
|
+
| LTS 2 | Most adults will tolerate | `residential` ≤25 mph, bike lane on a slow street |
|
|
69
|
+
| LTS 3 | Experienced cyclists only | `tertiary`, fast residential, bike lane on a faster street |
|
|
70
|
+
| LTS 4 | Strong-and-fearless only | `primary` / `trunk`, `>35 mph`, `≥3 lanes` and `>30 mph` |
|
|
71
|
+
|
|
72
|
+
The branches evaluate top-to-bottom and short-circuit on the first match.
|
|
73
|
+
Order matters — a `cycleway=track` on a 40 mph arterial returns LTS 1
|
|
74
|
+
because separation wins over speed. Highways outside scope (`motorway`,
|
|
75
|
+
`footway`, `sidewalk`, `steps`, `pedestrian`) return `None`.
|
|
76
|
+
|
|
77
|
+
When `maxspeed` or `lanes` are missing, highway-typical defaults fill in:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from osm_lts import (
|
|
81
|
+
DEFAULT_SPEED_MPH_BY_HIGHWAY,
|
|
82
|
+
DEFAULT_LANE_COUNT_BY_HIGHWAY,
|
|
83
|
+
EXCLUDED_HIGHWAYS,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
These are public so callers can read them in their own UIs (e.g. "we
|
|
88
|
+
assumed 25 mph because the way was untagged").
|
|
89
|
+
|
|
90
|
+
### Customizing the rules
|
|
91
|
+
|
|
92
|
+
Wrap a `Classifier` instance to override any of the defaults. Useful for
|
|
93
|
+
modeling a city or country whose posted-speed conventions or in-scope
|
|
94
|
+
highway set differ from the US-centric defaults the package ships with.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from osm_lts import Classifier, EXCLUDED_HIGHWAYS
|
|
98
|
+
|
|
99
|
+
# Stricter unknown-speed default:
|
|
100
|
+
strict = Classifier(speed_mph_fallback=20)
|
|
101
|
+
strict({"highway": "residential"}) # <LTS.MOST_ADULTS: 2>
|
|
102
|
+
|
|
103
|
+
# Drop pedestrian-priority paths out of scope entirely:
|
|
104
|
+
narrower = Classifier(excluded_highways=EXCLUDED_HIGHWAYS | {"path"})
|
|
105
|
+
narrower({"highway": "path", "bicycle": "designated"}) # None
|
|
106
|
+
|
|
107
|
+
# Per-highway speed overrides:
|
|
108
|
+
slower_residential = Classifier(speed_mph_by_highway={"residential": 20})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`Classifier` is a frozen dataclass — instances are hashable and
|
|
112
|
+
thread-safe to share. Use `dataclasses.replace(clf, ...)` for tweaked
|
|
113
|
+
copies.
|
|
114
|
+
|
|
115
|
+
## Origin
|
|
116
|
+
|
|
117
|
+
Extracted from the [Bike Streets](https://bikestreets.com/) city-mapping
|
|
118
|
+
platform.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e '.[test]'
|
|
124
|
+
pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "osm-lts"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Classify OpenStreetMap ways by Level of Traffic Stress (LTS) using the Furth methodology."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Bike Streets" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"osm",
|
|
16
|
+
"openstreetmap",
|
|
17
|
+
"lts",
|
|
18
|
+
"level-of-traffic-stress",
|
|
19
|
+
"cycling",
|
|
20
|
+
"bicycle",
|
|
21
|
+
"advocacy",
|
|
22
|
+
"transportation",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
33
|
+
"Topic :: Sociology",
|
|
34
|
+
"Operating System :: OS Independent",
|
|
35
|
+
"Intended Audience :: Science/Research",
|
|
36
|
+
]
|
|
37
|
+
dependencies = []
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
test = ["pytest>=7"]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
osm-lts = "osm_lts.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://github.com/bikestreets/osm-lts"
|
|
47
|
+
Source = "https://github.com/bikestreets/osm-lts"
|
|
48
|
+
Issues = "https://github.com/bikestreets/osm-lts/issues"
|
|
49
|
+
Changelog = "https://github.com/bikestreets/osm-lts/blob/main/CHANGELOG.md"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/osm_lts"]
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests", "src"]
|
|
56
|
+
addopts = "-ra --doctest-modules"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""osm-lts — classify OpenStreetMap ways by Level of Traffic Stress.
|
|
2
|
+
|
|
3
|
+
Implements the Furth methodology
|
|
4
|
+
(peterfurth.sites.northeastern.edu/level-of-traffic-stress/) as a pure
|
|
5
|
+
Python function operating on OSM tag dicts. No PostGIS, no Django, no
|
|
6
|
+
network I/O — just OSM tags in, LTS 1-4 (or ``None``) out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._classify import LTS, Classifier, classify
|
|
10
|
+
from ._constants import (
|
|
11
|
+
CYCLEWAY_TAG_KEYS,
|
|
12
|
+
DEFAULT_LANE_COUNT_BY_HIGHWAY,
|
|
13
|
+
DEFAULT_LANE_COUNT_FALLBACK,
|
|
14
|
+
DEFAULT_SPEED_MPH_BY_HIGHWAY,
|
|
15
|
+
DEFAULT_SPEED_MPH_FALLBACK,
|
|
16
|
+
EXCLUDED_HIGHWAYS,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.2.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"LTS",
|
|
23
|
+
"Classifier",
|
|
24
|
+
"classify",
|
|
25
|
+
"EXCLUDED_HIGHWAYS",
|
|
26
|
+
"DEFAULT_SPEED_MPH_BY_HIGHWAY",
|
|
27
|
+
"DEFAULT_SPEED_MPH_FALLBACK",
|
|
28
|
+
"DEFAULT_LANE_COUNT_BY_HIGHWAY",
|
|
29
|
+
"DEFAULT_LANE_COUNT_FALLBACK",
|
|
30
|
+
"CYCLEWAY_TAG_KEYS",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Furth Level of Traffic Stress classifier.
|
|
2
|
+
|
|
3
|
+
Reference: peterfurth.sites.northeastern.edu/level-of-traffic-stress/
|
|
4
|
+
|
|
5
|
+
The classification is a four-tier scale from "kid-comfortable" (1) to
|
|
6
|
+
"strong-and-fearless only" (4). The branches below evaluate top-to-
|
|
7
|
+
bottom; the first match wins. Order matters — separation (a
|
|
8
|
+
``cycleway=track``) short-circuits before any speed/lane bump can
|
|
9
|
+
push the way up to LTS 4.
|
|
10
|
+
|
|
11
|
+
Two entry points:
|
|
12
|
+
|
|
13
|
+
* :func:`classify` — module-level function using the default rules.
|
|
14
|
+
Equivalent to ``Classifier().classify(tags)``.
|
|
15
|
+
* :class:`Classifier` — frozen dataclass with overridable defaults
|
|
16
|
+
(excluded highways, speed/lane fallbacks, cycleway sub-tag order).
|
|
17
|
+
Construct one when you need to model a city or country whose
|
|
18
|
+
posted-speed conventions or in-scope highway set differ from the
|
|
19
|
+
US-centric defaults.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import IntEnum
|
|
27
|
+
from typing import Mapping, Optional
|
|
28
|
+
|
|
29
|
+
from . import _constants as C
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LTS(IntEnum):
|
|
33
|
+
"""Furth's Level of Traffic Stress, 1 (calmest) to 4 (most hostile)."""
|
|
34
|
+
|
|
35
|
+
KID_COMFORTABLE = 1
|
|
36
|
+
MOST_ADULTS = 2
|
|
37
|
+
EXPERIENCED_ONLY = 3
|
|
38
|
+
STRONG_AND_FEARLESS = 4
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_NON_DIGIT_RE = re.compile(r"[^0-9]")
|
|
42
|
+
_BIKE_LANE_KINDS = frozenset({"lane", "opposite_lane", "shared_lane"})
|
|
43
|
+
_LTS4_HIGHWAYS = frozenset({"primary", "primary_link", "trunk", "trunk_link"})
|
|
44
|
+
_LTS3_HIGHWAYS = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"tertiary",
|
|
47
|
+
"tertiary_link",
|
|
48
|
+
"secondary",
|
|
49
|
+
"secondary_link",
|
|
50
|
+
"residential",
|
|
51
|
+
"unclassified",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_int(value: Optional[str]) -> Optional[int]:
|
|
57
|
+
"""Strip non-digits from an OSM tag value and return ``int``, else ``None``.
|
|
58
|
+
|
|
59
|
+
OSM ``maxspeed`` and ``lanes`` are free-form strings: ``"25 mph"``,
|
|
60
|
+
``"50 km/h"``, ``"4"``, ``"4;3"``. We only consume the leading
|
|
61
|
+
digits — anything that doesn't yield digits returns ``None`` and
|
|
62
|
+
the caller falls back to a highway-typical default.
|
|
63
|
+
"""
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
digits = _NON_DIGIT_RE.sub("", str(value))
|
|
67
|
+
return int(digits) if digits else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Classifier:
|
|
72
|
+
"""Configurable Furth LTS classifier.
|
|
73
|
+
|
|
74
|
+
All fields default to the public module-level constants, so a
|
|
75
|
+
bare ``Classifier()`` is equivalent to calling :func:`classify`.
|
|
76
|
+
Override fields to model a region whose conventions differ from
|
|
77
|
+
the US-centric defaults: a city with stricter posted-speed
|
|
78
|
+
fallbacks, a country whose ``footway``-tagged ways are commonly
|
|
79
|
+
rideable, etc.
|
|
80
|
+
|
|
81
|
+
The dataclass is frozen so an instance is hashable and safe to
|
|
82
|
+
share across threads. To "modify" a classifier, use
|
|
83
|
+
:meth:`dataclasses.replace`.
|
|
84
|
+
|
|
85
|
+
Examples:
|
|
86
|
+
Default behavior::
|
|
87
|
+
|
|
88
|
+
>>> Classifier()({"highway": "residential"})
|
|
89
|
+
<LTS.MOST_ADULTS: 2>
|
|
90
|
+
|
|
91
|
+
Stricter unknown-speed default (treat unknowns as 20 mph)::
|
|
92
|
+
|
|
93
|
+
>>> clf = Classifier(speed_mph_fallback=20)
|
|
94
|
+
>>> clf({"highway": "residential"})
|
|
95
|
+
<LTS.MOST_ADULTS: 2>
|
|
96
|
+
|
|
97
|
+
Also exclude pedestrian-priority paths from scope::
|
|
98
|
+
|
|
99
|
+
>>> from osm_lts import EXCLUDED_HIGHWAYS
|
|
100
|
+
>>> clf = Classifier(
|
|
101
|
+
... excluded_highways=EXCLUDED_HIGHWAYS | {"path"}
|
|
102
|
+
... )
|
|
103
|
+
>>> clf({"highway": "path", "bicycle": "designated"}) is None
|
|
104
|
+
True
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
# frozenset is itself immutable, so a shared module-level default
|
|
108
|
+
# is safe — the dataclass machinery doesn't raise on it the way
|
|
109
|
+
# it would on dict/list/set defaults.
|
|
110
|
+
excluded_highways: frozenset = C.EXCLUDED_HIGHWAYS
|
|
111
|
+
|
|
112
|
+
# ``Mapping`` defaults need a factory: dict is mutable so Python
|
|
113
|
+
# would otherwise share one dict across every Classifier instance,
|
|
114
|
+
# which would be both surprising and a source of mutation bugs.
|
|
115
|
+
speed_mph_by_highway: Mapping[str, int] = field(
|
|
116
|
+
default_factory=lambda: dict(C.DEFAULT_SPEED_MPH_BY_HIGHWAY)
|
|
117
|
+
)
|
|
118
|
+
speed_mph_fallback: int = C.DEFAULT_SPEED_MPH_FALLBACK
|
|
119
|
+
|
|
120
|
+
lane_count_by_highway: Mapping[str, int] = field(
|
|
121
|
+
default_factory=lambda: dict(C.DEFAULT_LANE_COUNT_BY_HIGHWAY)
|
|
122
|
+
)
|
|
123
|
+
lane_count_fallback: int = C.DEFAULT_LANE_COUNT_FALLBACK
|
|
124
|
+
|
|
125
|
+
# Tuples are immutable; safe as a direct default.
|
|
126
|
+
cycleway_tag_keys: tuple = C.CYCLEWAY_TAG_KEYS
|
|
127
|
+
|
|
128
|
+
def _resolve_speed_mph(self, highway: str, maxspeed: Optional[str]) -> int:
|
|
129
|
+
explicit = _coerce_int(maxspeed)
|
|
130
|
+
if explicit is not None:
|
|
131
|
+
return explicit
|
|
132
|
+
return self.speed_mph_by_highway.get(highway, self.speed_mph_fallback)
|
|
133
|
+
|
|
134
|
+
def _resolve_lane_count(self, highway: str, lanes: Optional[str]) -> int:
|
|
135
|
+
explicit = _coerce_int(lanes)
|
|
136
|
+
if explicit is not None:
|
|
137
|
+
return explicit
|
|
138
|
+
return self.lane_count_by_highway.get(highway, self.lane_count_fallback)
|
|
139
|
+
|
|
140
|
+
def _resolve_cycleway_kind(
|
|
141
|
+
self, tags: Mapping[str, str]
|
|
142
|
+
) -> Optional[str]:
|
|
143
|
+
"""First non-empty cycleway* value, in :attr:`cycleway_tag_keys` order."""
|
|
144
|
+
for key in self.cycleway_tag_keys:
|
|
145
|
+
value = tags.get(key)
|
|
146
|
+
if value:
|
|
147
|
+
return value
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def classify(self, tags: Mapping[str, str]) -> Optional[LTS]:
|
|
151
|
+
"""Return the Furth LTS classification for an OSM way's tags.
|
|
152
|
+
|
|
153
|
+
See :func:`classify` for the full docstring; this method is
|
|
154
|
+
the same logic with this instance's overridable defaults.
|
|
155
|
+
"""
|
|
156
|
+
highway = tags.get("highway")
|
|
157
|
+
if not highway or highway in self.excluded_highways:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
bicycle = tags.get("bicycle")
|
|
161
|
+
cycleway_kind = self._resolve_cycleway_kind(tags)
|
|
162
|
+
speed_mph = self._resolve_speed_mph(highway, tags.get("maxspeed"))
|
|
163
|
+
lane_count = self._resolve_lane_count(highway, tags.get("lanes"))
|
|
164
|
+
|
|
165
|
+
# LTS 1 — separated paths, designated bike infrastructure,
|
|
166
|
+
# slow-by-design streets. Short-circuits before any speed/
|
|
167
|
+
# lane bump: a `cycleway=track` on a 40 mph arterial still
|
|
168
|
+
# returns 1 because the rider is physically separated from
|
|
169
|
+
# traffic.
|
|
170
|
+
if highway == "cycleway":
|
|
171
|
+
return LTS.KID_COMFORTABLE
|
|
172
|
+
if highway == "path" and bicycle == "designated":
|
|
173
|
+
return LTS.KID_COMFORTABLE
|
|
174
|
+
if highway == "living_street":
|
|
175
|
+
return LTS.KID_COMFORTABLE
|
|
176
|
+
if cycleway_kind == "track":
|
|
177
|
+
return LTS.KID_COMFORTABLE
|
|
178
|
+
|
|
179
|
+
# LTS 4 — high-speed, multi-lane arterial, or trunk/primary
|
|
180
|
+
# by tag class. A painted bike lane on a >35 mph 6-lane road
|
|
181
|
+
# is still LTS 4; paint doesn't reduce stress on a hostile
|
|
182
|
+
# street.
|
|
183
|
+
if speed_mph > 35:
|
|
184
|
+
return LTS.STRONG_AND_FEARLESS
|
|
185
|
+
if highway in _LTS4_HIGHWAYS:
|
|
186
|
+
return LTS.STRONG_AND_FEARLESS
|
|
187
|
+
if lane_count >= 3 and speed_mph > 30:
|
|
188
|
+
return LTS.STRONG_AND_FEARLESS
|
|
189
|
+
|
|
190
|
+
# LTS 2 — calm residential or a bike lane on a slow street.
|
|
191
|
+
if cycleway_kind in _BIKE_LANE_KINDS and speed_mph <= 25:
|
|
192
|
+
return LTS.MOST_ADULTS
|
|
193
|
+
if highway in {"residential", "unclassified"} and speed_mph <= 25:
|
|
194
|
+
return LTS.MOST_ADULTS
|
|
195
|
+
|
|
196
|
+
# LTS 3 — moderate-speed mixed traffic, bike lane on a
|
|
197
|
+
# faster street, tertiary collectors, or any residential/
|
|
198
|
+
# unclassified not already absorbed by the LTS 2 branch.
|
|
199
|
+
if cycleway_kind in _BIKE_LANE_KINDS:
|
|
200
|
+
return LTS.EXPERIENCED_ONLY
|
|
201
|
+
if highway in _LTS3_HIGHWAYS:
|
|
202
|
+
return LTS.EXPERIENCED_ONLY
|
|
203
|
+
|
|
204
|
+
# Service ways (alleys, driveways, parking aisles) — assume
|
|
205
|
+
# low traffic. Real OSM data tags these heavily, so absent
|
|
206
|
+
# any explicit speed signal we treat them as comfortable.
|
|
207
|
+
if highway == "service":
|
|
208
|
+
return LTS.KID_COMFORTABLE
|
|
209
|
+
|
|
210
|
+
return LTS.EXPERIENCED_ONLY
|
|
211
|
+
|
|
212
|
+
# Calling a classifier directly is the ergonomic API:
|
|
213
|
+
# clf = Classifier(...)
|
|
214
|
+
# clf(tags)
|
|
215
|
+
def __call__(self, tags: Mapping[str, str]) -> Optional[LTS]:
|
|
216
|
+
return self.classify(tags)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Singleton used by the module-level :func:`classify` shortcut. Frozen
|
|
220
|
+
# dataclass + immutable defaults make this safe to share across calls
|
|
221
|
+
# and threads.
|
|
222
|
+
_DEFAULT_CLASSIFIER = Classifier()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def classify(tags: Mapping[str, str]) -> Optional[LTS]:
|
|
226
|
+
"""Return the Furth LTS classification for an OSM way's tags.
|
|
227
|
+
|
|
228
|
+
Convenience wrapper around a default-configured
|
|
229
|
+
:class:`Classifier`. For custom defaults or a different in-scope
|
|
230
|
+
highway set, instantiate a :class:`Classifier` directly.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
tags: Mapping of OSM key to value, e.g. ``{"highway":
|
|
234
|
+
"residential", "maxspeed": "25 mph"}``. Numeric tag values
|
|
235
|
+
(``maxspeed``, ``lanes``) tolerate units — only the
|
|
236
|
+
leading digits are read.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
:class:`LTS` 1-4, or ``None`` for ways outside scope:
|
|
240
|
+
|
|
241
|
+
* No ``highway`` tag.
|
|
242
|
+
* ``highway`` in :data:`EXCLUDED_HIGHWAYS` (motorways,
|
|
243
|
+
footways, sidewalks, steps, pedestrian).
|
|
244
|
+
|
|
245
|
+
Examples:
|
|
246
|
+
>>> classify({"highway": "cycleway"})
|
|
247
|
+
<LTS.KID_COMFORTABLE: 1>
|
|
248
|
+
>>> classify({"highway": "residential", "maxspeed": "25 mph"})
|
|
249
|
+
<LTS.MOST_ADULTS: 2>
|
|
250
|
+
>>> classify({"highway": "primary"})
|
|
251
|
+
<LTS.STRONG_AND_FEARLESS: 4>
|
|
252
|
+
>>> classify({"highway": "footway"}) is None
|
|
253
|
+
True
|
|
254
|
+
"""
|
|
255
|
+
return _DEFAULT_CLASSIFIER.classify(tags)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tunable defaults for the LTS classifier.
|
|
2
|
+
|
|
3
|
+
These mirror the SQL CASE branches in the original Bike Streets
|
|
4
|
+
implementation. Surfaced as module-level constants so callers can
|
|
5
|
+
inspect them and (in a future minor release) override them via a
|
|
6
|
+
``Classifier`` instance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Highways the classifier returns ``None`` for. Bikes are prohibited
|
|
12
|
+
# (``motorway``), the way isn't relevant to cyclist stress
|
|
13
|
+
# (``footway``, ``sidewalk``, ``steps``), or the way is pedestrian-
|
|
14
|
+
# only by convention.
|
|
15
|
+
EXCLUDED_HIGHWAYS: frozenset[str] = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"motorway",
|
|
18
|
+
"motorway_link",
|
|
19
|
+
"footway",
|
|
20
|
+
"sidewalk",
|
|
21
|
+
"steps",
|
|
22
|
+
"pedestrian",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Speed defaults in mph when the way has no explicit ``maxspeed`` tag.
|
|
27
|
+
# Most US residential streets don't carry a maxspeed; the planning
|
|
28
|
+
# default is the posted limit for the highway class.
|
|
29
|
+
DEFAULT_SPEED_MPH_BY_HIGHWAY: dict[str, int] = {
|
|
30
|
+
"living_street": 15,
|
|
31
|
+
"residential": 25,
|
|
32
|
+
"unclassified": 25,
|
|
33
|
+
"service": 25,
|
|
34
|
+
"tertiary": 30,
|
|
35
|
+
"tertiary_link": 30,
|
|
36
|
+
"secondary": 35,
|
|
37
|
+
"secondary_link": 35,
|
|
38
|
+
"primary": 40,
|
|
39
|
+
"primary_link": 40,
|
|
40
|
+
"trunk": 40,
|
|
41
|
+
"trunk_link": 40,
|
|
42
|
+
}
|
|
43
|
+
DEFAULT_SPEED_MPH_FALLBACK: int = 25
|
|
44
|
+
|
|
45
|
+
# Lane-count defaults when ``lanes`` is missing.
|
|
46
|
+
DEFAULT_LANE_COUNT_BY_HIGHWAY: dict[str, int] = {
|
|
47
|
+
"residential": 2,
|
|
48
|
+
"service": 2,
|
|
49
|
+
"primary": 4,
|
|
50
|
+
"primary_link": 4,
|
|
51
|
+
"secondary": 4,
|
|
52
|
+
"secondary_link": 4,
|
|
53
|
+
}
|
|
54
|
+
DEFAULT_LANE_COUNT_FALLBACK: int = 2
|
|
55
|
+
|
|
56
|
+
# OSM cycleway sub-tags consulted in priority order. ``cycleway`` (the
|
|
57
|
+
# unprefixed key) wins when present, then per-side variants. A way
|
|
58
|
+
# tagged with conflicting sides (e.g. lane right, track left) returns
|
|
59
|
+
# the first one set — callers wanting per-direction precision should
|
|
60
|
+
# preprocess the tags themselves.
|
|
61
|
+
CYCLEWAY_TAG_KEYS: tuple[str, ...] = (
|
|
62
|
+
"cycleway",
|
|
63
|
+
"cycleway:right",
|
|
64
|
+
"cycleway:left",
|
|
65
|
+
"cycleway:both",
|
|
66
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Command-line interface for ``osm-lts``.
|
|
2
|
+
|
|
3
|
+
Reads a JSON object, JSON array, or one JSON object per line from
|
|
4
|
+
stdin (or ``--in <file>``) and writes the LTS classification to
|
|
5
|
+
stdout (or ``--out <file>``) as JSONL.
|
|
6
|
+
|
|
7
|
+
Examples::
|
|
8
|
+
|
|
9
|
+
echo '{"highway": "residential", "maxspeed": "25 mph"}' \\
|
|
10
|
+
| osm-lts classify
|
|
11
|
+
osm-lts classify --in ways.jsonl --out lts.jsonl
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from typing import IO, Iterable, Iterator
|
|
20
|
+
|
|
21
|
+
from . import __version__
|
|
22
|
+
from ._classify import classify
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _iter_json_objects(stream: IO[str]) -> Iterator[dict]:
|
|
26
|
+
"""Yield one tag dict per JSON object found in ``stream``.
|
|
27
|
+
|
|
28
|
+
Accepts either a single JSON value (object or array) or one
|
|
29
|
+
JSON object per line — whichever the file looks like at first
|
|
30
|
+
non-whitespace character.
|
|
31
|
+
"""
|
|
32
|
+
text = stream.read().strip()
|
|
33
|
+
if not text:
|
|
34
|
+
return
|
|
35
|
+
if text.startswith("["):
|
|
36
|
+
for obj in json.loads(text):
|
|
37
|
+
yield obj
|
|
38
|
+
elif text.startswith("{") and "\n{" not in text and "}\n{" not in text:
|
|
39
|
+
# Single object, no embedded newlines between objects.
|
|
40
|
+
yield json.loads(text)
|
|
41
|
+
else:
|
|
42
|
+
for line in text.splitlines():
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if line:
|
|
45
|
+
yield json.loads(line)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _classify_command(args: argparse.Namespace) -> int:
|
|
49
|
+
for tags in _iter_json_objects(args.infile):
|
|
50
|
+
result = classify(tags)
|
|
51
|
+
out_obj = {"tags": tags, "lts": int(result) if result is not None else None}
|
|
52
|
+
args.outfile.write(json.dumps(out_obj))
|
|
53
|
+
args.outfile.write("\n")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
58
|
+
parser = argparse.ArgumentParser(
|
|
59
|
+
prog="osm-lts",
|
|
60
|
+
description="Classify OSM ways by Furth Level of Traffic Stress.",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--version", action="version", version=f"osm-lts {__version__}"
|
|
64
|
+
)
|
|
65
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
66
|
+
|
|
67
|
+
classify_parser = sub.add_parser(
|
|
68
|
+
"classify",
|
|
69
|
+
help="Classify one or more OSM tag dicts.",
|
|
70
|
+
description=(
|
|
71
|
+
"Read JSON tag dicts from stdin (or --in) and write a "
|
|
72
|
+
"JSONL stream of {tags, lts} objects to stdout (or --out)."
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
classify_parser.add_argument(
|
|
76
|
+
"--in",
|
|
77
|
+
dest="infile",
|
|
78
|
+
type=argparse.FileType("r"),
|
|
79
|
+
default=sys.stdin,
|
|
80
|
+
help="Input file (default: stdin).",
|
|
81
|
+
)
|
|
82
|
+
classify_parser.add_argument(
|
|
83
|
+
"--out",
|
|
84
|
+
dest="outfile",
|
|
85
|
+
type=argparse.FileType("w"),
|
|
86
|
+
default=sys.stdout,
|
|
87
|
+
help="Output file (default: stdout).",
|
|
88
|
+
)
|
|
89
|
+
classify_parser.set_defaults(func=_classify_command)
|
|
90
|
+
return parser
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: list[str] | None = None) -> int:
|
|
94
|
+
parser = _build_parser()
|
|
95
|
+
args = parser.parse_args(argv)
|
|
96
|
+
return args.func(args)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Behavior tests for :class:`osm_lts.Classifier` overrides.
|
|
2
|
+
|
|
3
|
+
The function-level rules are covered in ``test_classify.py``. These
|
|
4
|
+
tests exercise the override surface — confirming each constructor
|
|
5
|
+
field actually changes behavior in the expected direction, and that
|
|
6
|
+
defaulted instances stay in lockstep with the module-level
|
|
7
|
+
:func:`osm_lts.classify` shortcut.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import dataclasses
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from osm_lts import (
|
|
17
|
+
EXCLUDED_HIGHWAYS,
|
|
18
|
+
LTS,
|
|
19
|
+
Classifier,
|
|
20
|
+
classify,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_default_classifier_matches_module_function() -> None:
|
|
25
|
+
"""``Classifier()`` and the module-level ``classify`` agree."""
|
|
26
|
+
clf = Classifier()
|
|
27
|
+
for tags in [
|
|
28
|
+
{"highway": "residential"},
|
|
29
|
+
{"highway": "primary"},
|
|
30
|
+
{"highway": "cycleway"},
|
|
31
|
+
{"highway": "footway"},
|
|
32
|
+
{"highway": "tertiary", "cycleway:right": "lane"},
|
|
33
|
+
{},
|
|
34
|
+
]:
|
|
35
|
+
assert clf.classify(tags) == classify(tags)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_callable_alias_matches_classify() -> None:
|
|
39
|
+
clf = Classifier()
|
|
40
|
+
tags = {"highway": "residential", "maxspeed": "25 mph"}
|
|
41
|
+
assert clf(tags) == clf.classify(tags) == LTS.MOST_ADULTS
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_override_excluded_highways_excludes_more() -> None:
|
|
45
|
+
"""Adding ``path`` to the exclusion set drops it out of scope."""
|
|
46
|
+
clf = Classifier(excluded_highways=EXCLUDED_HIGHWAYS | {"path"})
|
|
47
|
+
assert clf({"highway": "path", "bicycle": "designated"}) is None
|
|
48
|
+
# Other excluded highways still excluded.
|
|
49
|
+
assert clf({"highway": "footway"}) is None
|
|
50
|
+
# Non-excluded paths unchanged.
|
|
51
|
+
assert clf({"highway": "residential"}) == LTS.MOST_ADULTS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_override_excluded_highways_excludes_fewer() -> None:
|
|
55
|
+
"""Removing ``footway`` from the exclusion set lets it classify."""
|
|
56
|
+
looser = EXCLUDED_HIGHWAYS - {"footway"}
|
|
57
|
+
clf = Classifier(excluded_highways=looser)
|
|
58
|
+
# footway has no LTS-specific rule → falls through to the LTS 3
|
|
59
|
+
# default. Just confirm it's no longer excluded (returns a value
|
|
60
|
+
# rather than None).
|
|
61
|
+
assert clf({"highway": "footway"}) is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_override_speed_mph_fallback() -> None:
|
|
65
|
+
"""A 20 mph fallback pushes unknown highways into the LTS 2 branch."""
|
|
66
|
+
clf = Classifier(speed_mph_fallback=20)
|
|
67
|
+
# `highway=byway` isn't in the per-highway speed map → falls back
|
|
68
|
+
# to ``speed_mph_fallback``. With 20 mph it would fall through to
|
|
69
|
+
# the catch-all LTS 3 (only LTS 4 cares about 35+, only LTS 2's
|
|
70
|
+
# residential/lane branches care about ≤25), which is the same as
|
|
71
|
+
# the default — but a different fallback that crosses the 35 mph
|
|
72
|
+
# threshold flips the result.
|
|
73
|
+
high = Classifier(speed_mph_fallback=40)
|
|
74
|
+
assert high({"highway": "byway"}) == LTS.STRONG_AND_FEARLESS
|
|
75
|
+
assert clf({"highway": "byway"}) == LTS.EXPERIENCED_ONLY
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_override_speed_mph_by_highway() -> None:
|
|
79
|
+
"""City posts residential at 20 mph by default → still LTS 2 but cleaner."""
|
|
80
|
+
clf = Classifier(speed_mph_by_highway={"residential": 20})
|
|
81
|
+
assert clf({"highway": "residential"}) == LTS.MOST_ADULTS
|
|
82
|
+
# Residential without an override would also be LTS 2 at 25 mph,
|
|
83
|
+
# so test the cross-threshold case: bump tertiary's default to 25
|
|
84
|
+
# and a tertiary with no maxspeed turns into LTS 2.
|
|
85
|
+
clf = Classifier(
|
|
86
|
+
speed_mph_by_highway={"tertiary": 25},
|
|
87
|
+
# tertiary needs a bike lane to qualify for LTS 2. (Without
|
|
88
|
+
# one the LTS 2 branch is residential/unclassified-only.)
|
|
89
|
+
)
|
|
90
|
+
assert clf({"highway": "tertiary", "cycleway": "lane"}) == LTS.MOST_ADULTS
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_override_lane_count_fallback_can_promote_to_lts4() -> None:
|
|
94
|
+
"""Bumping the lane-count fallback flips a moderate-speed road to LTS 4."""
|
|
95
|
+
# `highway=byway` has no per-highway lane default and no per-
|
|
96
|
+
# highway speed default. With speed override 32 (>30) and lanes
|
|
97
|
+
# override 4 (≥3), the LTS 4 multi-lane branch should fire.
|
|
98
|
+
clf = Classifier(speed_mph_fallback=32, lane_count_fallback=4)
|
|
99
|
+
assert clf({"highway": "byway"}) == LTS.STRONG_AND_FEARLESS
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_override_cycleway_tag_keys_changes_priority() -> None:
|
|
103
|
+
"""Re-ordering the cycleway sub-tag list changes which value wins."""
|
|
104
|
+
tags = {
|
|
105
|
+
"highway": "tertiary",
|
|
106
|
+
"maxspeed": "25 mph",
|
|
107
|
+
"cycleway:left": "track",
|
|
108
|
+
"cycleway:right": "lane",
|
|
109
|
+
}
|
|
110
|
+
# Default order picks ``cycleway:right`` (priority 2) before
|
|
111
|
+
# ``cycleway:left`` (priority 3): ``lane`` on a 25 mph street →
|
|
112
|
+
# LTS 2.
|
|
113
|
+
assert classify(tags) == LTS.MOST_ADULTS
|
|
114
|
+
|
|
115
|
+
# Override prioritizes ``cycleway:left`` first: ``track`` →
|
|
116
|
+
# short-circuits to LTS 1.
|
|
117
|
+
clf = Classifier(
|
|
118
|
+
cycleway_tag_keys=("cycleway", "cycleway:left", "cycleway:right")
|
|
119
|
+
)
|
|
120
|
+
assert clf(tags) == LTS.KID_COMFORTABLE
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_classifier_is_frozen() -> None:
|
|
124
|
+
"""Field assignment raises — use ``dataclasses.replace`` instead."""
|
|
125
|
+
clf = Classifier()
|
|
126
|
+
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
127
|
+
clf.speed_mph_fallback = 30 # type: ignore[misc]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_dataclasses_replace_returns_modified_copy() -> None:
|
|
131
|
+
"""The standard ``replace`` helper produces a tweaked copy."""
|
|
132
|
+
base = Classifier()
|
|
133
|
+
stricter = dataclasses.replace(base, speed_mph_fallback=20)
|
|
134
|
+
assert stricter.speed_mph_fallback == 20
|
|
135
|
+
assert base.speed_mph_fallback != 20 # original untouched
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_default_classifier_independent_dicts() -> None:
|
|
139
|
+
"""Each ``Classifier()`` gets its own dict — no shared mutation risk."""
|
|
140
|
+
a = Classifier()
|
|
141
|
+
b = Classifier()
|
|
142
|
+
# The dataclass uses default_factory, so ``speed_mph_by_highway``
|
|
143
|
+
# is a fresh dict per instance — mutating one wouldn't bleed into
|
|
144
|
+
# the other.
|
|
145
|
+
assert a.speed_mph_by_highway is not b.speed_mph_by_highway
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Behavior tests for :func:`osm_lts.classify`.
|
|
2
|
+
|
|
3
|
+
Each parametrize case asserts a specific Furth-rule outcome with the
|
|
4
|
+
smallest tag bag that exercises it. Edge cases (missing tags, mph
|
|
5
|
+
strings with units, multi-arterial bumps, separation overriding
|
|
6
|
+
speed) are kept as named tests so a regression points right at the
|
|
7
|
+
rule that broke.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from osm_lts import LTS, classify
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize(
|
|
18
|
+
"tags, expected",
|
|
19
|
+
[
|
|
20
|
+
# LTS 1 — separated, designated, slow-by-design
|
|
21
|
+
({"highway": "cycleway"}, LTS.KID_COMFORTABLE),
|
|
22
|
+
({"highway": "path", "bicycle": "designated"}, LTS.KID_COMFORTABLE),
|
|
23
|
+
({"highway": "living_street"}, LTS.KID_COMFORTABLE),
|
|
24
|
+
(
|
|
25
|
+
{"highway": "residential", "cycleway": "track"},
|
|
26
|
+
LTS.KID_COMFORTABLE,
|
|
27
|
+
),
|
|
28
|
+
({"highway": "service"}, LTS.KID_COMFORTABLE),
|
|
29
|
+
# LTS 2 — calm residential or bike lane on slow street
|
|
30
|
+
({"highway": "residential"}, LTS.MOST_ADULTS),
|
|
31
|
+
({"highway": "residential", "maxspeed": "25 mph"}, LTS.MOST_ADULTS),
|
|
32
|
+
({"highway": "unclassified", "maxspeed": "20 mph"}, LTS.MOST_ADULTS),
|
|
33
|
+
(
|
|
34
|
+
{"highway": "tertiary", "maxspeed": "25 mph", "cycleway": "lane"},
|
|
35
|
+
LTS.MOST_ADULTS,
|
|
36
|
+
),
|
|
37
|
+
# LTS 3 — collectors / fast residential / bike lane on faster street
|
|
38
|
+
(
|
|
39
|
+
{"highway": "residential", "maxspeed": "30 mph"},
|
|
40
|
+
LTS.EXPERIENCED_ONLY,
|
|
41
|
+
),
|
|
42
|
+
({"highway": "tertiary"}, LTS.EXPERIENCED_ONLY),
|
|
43
|
+
(
|
|
44
|
+
{"highway": "secondary", "maxspeed": "30 mph"},
|
|
45
|
+
LTS.EXPERIENCED_ONLY,
|
|
46
|
+
),
|
|
47
|
+
(
|
|
48
|
+
{"highway": "tertiary", "cycleway": "lane"},
|
|
49
|
+
LTS.EXPERIENCED_ONLY,
|
|
50
|
+
),
|
|
51
|
+
# LTS 4 — high-speed / arterial / multi-lane
|
|
52
|
+
({"highway": "primary"}, LTS.STRONG_AND_FEARLESS),
|
|
53
|
+
({"highway": "trunk"}, LTS.STRONG_AND_FEARLESS),
|
|
54
|
+
(
|
|
55
|
+
{"highway": "secondary", "maxspeed": "40 mph"},
|
|
56
|
+
LTS.STRONG_AND_FEARLESS,
|
|
57
|
+
),
|
|
58
|
+
(
|
|
59
|
+
{
|
|
60
|
+
"highway": "secondary",
|
|
61
|
+
"lanes": "4",
|
|
62
|
+
"maxspeed": "35 mph",
|
|
63
|
+
},
|
|
64
|
+
LTS.STRONG_AND_FEARLESS,
|
|
65
|
+
),
|
|
66
|
+
],
|
|
67
|
+
)
|
|
68
|
+
def test_classify(tags: dict, expected: LTS) -> None:
|
|
69
|
+
assert classify(tags) == expected
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"highway",
|
|
74
|
+
[
|
|
75
|
+
"motorway",
|
|
76
|
+
"motorway_link",
|
|
77
|
+
"footway",
|
|
78
|
+
"sidewalk",
|
|
79
|
+
"steps",
|
|
80
|
+
"pedestrian",
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
def test_excluded_highways_return_none(highway: str) -> None:
|
|
84
|
+
assert classify({"highway": highway}) is None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_no_highway_tag_returns_none() -> None:
|
|
88
|
+
assert classify({}) is None
|
|
89
|
+
assert classify({"name": "Anywhere St"}) is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_maxspeed_strips_unit_string() -> None:
|
|
93
|
+
"""``maxspeed: '25 mph'`` should be parsed as ``25``, not raise."""
|
|
94
|
+
assert (
|
|
95
|
+
classify({"highway": "residential", "maxspeed": "25 mph"})
|
|
96
|
+
== LTS.MOST_ADULTS
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_lanes_strips_units_too() -> None:
|
|
101
|
+
"""``lanes`` is rare-but-real with weird values like ``'4;3'``."""
|
|
102
|
+
assert (
|
|
103
|
+
classify(
|
|
104
|
+
{
|
|
105
|
+
"highway": "secondary",
|
|
106
|
+
"maxspeed": "35 mph",
|
|
107
|
+
"lanes": "4;3",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
== LTS.STRONG_AND_FEARLESS
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_cycleway_subkey_priority() -> None:
|
|
115
|
+
"""``cycleway:right`` is read when the unprefixed ``cycleway`` is absent."""
|
|
116
|
+
tags = {
|
|
117
|
+
"highway": "tertiary",
|
|
118
|
+
"maxspeed": "25 mph",
|
|
119
|
+
"cycleway:right": "lane",
|
|
120
|
+
}
|
|
121
|
+
assert classify(tags) == LTS.MOST_ADULTS
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_protected_track_beats_arterial_speed() -> None:
|
|
125
|
+
"""A ``cycleway=track`` overrides a 40 mph arterial — separation wins."""
|
|
126
|
+
assert (
|
|
127
|
+
classify({"highway": "primary", "cycleway": "track"})
|
|
128
|
+
== LTS.KID_COMFORTABLE
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_returned_int_is_useable_as_lts_value() -> None:
|
|
133
|
+
"""``LTS`` is an ``IntEnum`` so JSON / CSV serialization is trivial."""
|
|
134
|
+
result = classify({"highway": "residential"})
|
|
135
|
+
assert int(result) == 2
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_returns_lts_enum_not_bare_int() -> None:
|
|
139
|
+
"""Caller can pattern-match on the named values."""
|
|
140
|
+
assert isinstance(classify({"highway": "primary"}), LTS)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Smoke tests for the ``osm-lts`` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from osm_lts.cli import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(stdin_text: str, capsys) -> list[dict]:
|
|
12
|
+
# argparse.FileType('r') with default=sys.stdin would normally
|
|
13
|
+
# bind to the real stdin; pytest's capsys swap covers it.
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.stdin = io.StringIO(stdin_text)
|
|
17
|
+
rc = main(["classify"])
|
|
18
|
+
assert rc == 0
|
|
19
|
+
out = capsys.readouterr().out
|
|
20
|
+
return [json.loads(line) for line in out.splitlines() if line.strip()]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cli_single_object(capsys) -> None:
|
|
24
|
+
rows = _run('{"highway": "residential", "maxspeed": "25 mph"}', capsys)
|
|
25
|
+
assert rows == [
|
|
26
|
+
{"tags": {"highway": "residential", "maxspeed": "25 mph"}, "lts": 2}
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_cli_jsonl_input(capsys) -> None:
|
|
31
|
+
payload = '{"highway": "primary"}\n{"highway": "footway"}\n'
|
|
32
|
+
rows = _run(payload, capsys)
|
|
33
|
+
assert rows == [
|
|
34
|
+
{"tags": {"highway": "primary"}, "lts": 4},
|
|
35
|
+
{"tags": {"highway": "footway"}, "lts": None},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_cli_array_input(capsys) -> None:
|
|
40
|
+
payload = '[{"highway": "cycleway"}, {"highway": "residential"}]'
|
|
41
|
+
rows = _run(payload, capsys)
|
|
42
|
+
assert [r["lts"] for r in rows] == [1, 2]
|