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.
@@ -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
@@ -0,0 +1,22 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ *.egg
9
+
10
+ .venv/
11
+ venv/
12
+ env/
13
+
14
+ .coverage
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+
19
+ .DS_Store
20
+ .vscode/
21
+ .idea/
22
+ uv.lock
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/osm-lts.svg)](https://pypi.org/project/osm-lts/)
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/osm-lts.svg)](https://pypi.org/project/osm-lts/)
33
+ [![CI](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml/badge.svg)](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml)
34
+ [![License: MIT](https://img.shields.io/pypi/l/osm-lts.svg)](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).
@@ -0,0 +1,129 @@
1
+ # osm-lts
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/osm-lts.svg)](https://pypi.org/project/osm-lts/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/osm-lts.svg)](https://pypi.org/project/osm-lts/)
5
+ [![CI](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml/badge.svg)](https://github.com/bikestreets/osm-lts/actions/workflows/test.yml)
6
+ [![License: MIT](https://img.shields.io/pypi/l/osm-lts.svg)](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]