openwalkscore 0.1.0__py3-none-any.whl
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.
- openwalkscore/__init__.py +82 -0
- openwalkscore/_version.py +1 -0
- openwalkscore/cli.py +258 -0
- openwalkscore/dem.py +120 -0
- openwalkscore/geo.py +106 -0
- openwalkscore/htmlmap.py +196 -0
- openwalkscore/network.py +405 -0
- openwalkscore/osm.py +381 -0
- openwalkscore/overpass.py +278 -0
- openwalkscore/profiles.py +568 -0
- openwalkscore/py.typed +0 -0
- openwalkscore/scoring.py +708 -0
- openwalkscore-0.1.0.dist-info/METADATA +401 -0
- openwalkscore-0.1.0.dist-info/RECORD +18 -0
- openwalkscore-0.1.0.dist-info/WHEEL +5 -0
- openwalkscore-0.1.0.dist-info/entry_points.txt +2 -0
- openwalkscore-0.1.0.dist-info/licenses/LICENSE +21 -0
- openwalkscore-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""openwalkscore: open, transparent, API-key-free walkability scores from OpenStreetMap.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from openwalkscore import score_point
|
|
6
|
+
|
|
7
|
+
result = score_point(48.8530, 2.3499) # downloads OSM data (cached)
|
|
8
|
+
print(result.score, result.rating)
|
|
9
|
+
print(result.explain())
|
|
10
|
+
|
|
11
|
+
# offline, from a local extract
|
|
12
|
+
result = score_point(48.8530, 2.3499, data="paris_extract.osm")
|
|
13
|
+
|
|
14
|
+
The method is documented in :mod:`openwalkscore.scoring` and the README. This
|
|
15
|
+
project is not affiliated with Walk Score(R) or Redfin, and its scores are not
|
|
16
|
+
comparable to theirs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .dem import DEM, load_dem
|
|
21
|
+
from .htmlmap import render_html, write_html
|
|
22
|
+
from .network import NetworkMetrics, WalkNetwork, dijkstra
|
|
23
|
+
from .osm import POI, OSMData, Way, load_osm, parse_geojson, parse_osm_xml, parse_overpass_json
|
|
24
|
+
from .overpass import DEFAULT_ENDPOINTS, OverpassClient, OverpassError, build_query
|
|
25
|
+
from .profiles import (
|
|
26
|
+
CategoryConfig,
|
|
27
|
+
DecayConfig,
|
|
28
|
+
PenaltyConfig,
|
|
29
|
+
Profile,
|
|
30
|
+
get_profile,
|
|
31
|
+
list_profiles,
|
|
32
|
+
)
|
|
33
|
+
from .scoring import (
|
|
34
|
+
AmenityHit,
|
|
35
|
+
CategoryResult,
|
|
36
|
+
PenaltyResult,
|
|
37
|
+
WalkScorer,
|
|
38
|
+
WalkScoreResult,
|
|
39
|
+
compare,
|
|
40
|
+
decay,
|
|
41
|
+
rating_for,
|
|
42
|
+
score_grid,
|
|
43
|
+
score_point,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"__version__",
|
|
48
|
+
"AmenityHit",
|
|
49
|
+
"CategoryConfig",
|
|
50
|
+
"CategoryResult",
|
|
51
|
+
"DEFAULT_ENDPOINTS",
|
|
52
|
+
"DEM",
|
|
53
|
+
"DecayConfig",
|
|
54
|
+
"NetworkMetrics",
|
|
55
|
+
"OSMData",
|
|
56
|
+
"OverpassClient",
|
|
57
|
+
"OverpassError",
|
|
58
|
+
"POI",
|
|
59
|
+
"PenaltyConfig",
|
|
60
|
+
"PenaltyResult",
|
|
61
|
+
"Profile",
|
|
62
|
+
"WalkNetwork",
|
|
63
|
+
"WalkScoreResult",
|
|
64
|
+
"WalkScorer",
|
|
65
|
+
"Way",
|
|
66
|
+
"build_query",
|
|
67
|
+
"compare",
|
|
68
|
+
"decay",
|
|
69
|
+
"dijkstra",
|
|
70
|
+
"get_profile",
|
|
71
|
+
"list_profiles",
|
|
72
|
+
"load_dem",
|
|
73
|
+
"load_osm",
|
|
74
|
+
"parse_geojson",
|
|
75
|
+
"parse_osm_xml",
|
|
76
|
+
"parse_overpass_json",
|
|
77
|
+
"rating_for",
|
|
78
|
+
"render_html",
|
|
79
|
+
"score_grid",
|
|
80
|
+
"score_point",
|
|
81
|
+
"write_html",
|
|
82
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
openwalkscore/cli.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Command-line interface: ``openwalkscore score | grid | explain | compare | profiles``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Sequence
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .overpass import DEFAULT_ENDPOINTS, OverpassClient, OverpassError
|
|
13
|
+
from .profiles import list_profiles, resolve_profile
|
|
14
|
+
from .scoring import RATINGS, WalkScoreResult, compare, score_grid, score_point
|
|
15
|
+
|
|
16
|
+
METHOD_SUMMARY = """\
|
|
17
|
+
openwalkscore method (all numbers come from the profile shown below):
|
|
18
|
+
1. Distances to amenities are measured along the OpenStreetMap walking network
|
|
19
|
+
(pure-Python Dijkstra, points snapped to the nearest street edge). Without a
|
|
20
|
+
network, straight-line distance x detour_factor is used.
|
|
21
|
+
2. Each amenity earns credit 1 up to decay.full_m, falling smoothly to 0 at
|
|
22
|
+
decay.zero_m.
|
|
23
|
+
3. Per category the best max_count amenities are kept; the k-th is weighted by
|
|
24
|
+
ratio^(k-1) (normalised), so extra amenities add less and less.
|
|
25
|
+
4. Access score = 100 x sum(weight x category subscore) / sum(weight).
|
|
26
|
+
5. Penalties for low intersection density, long blocks and (with a DEM) steep
|
|
27
|
+
grades scale the access score down; combined penalty <= max_total_penalty.
|
|
28
|
+
Not affiliated with Walk Score(R) or Redfin; scores are not comparable to theirs.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
33
|
+
p.add_argument(
|
|
34
|
+
"--profile",
|
|
35
|
+
default="default",
|
|
36
|
+
help="built-in profile name (%(default)s), or a .toml profile file",
|
|
37
|
+
)
|
|
38
|
+
p.add_argument(
|
|
39
|
+
"--input",
|
|
40
|
+
"-i",
|
|
41
|
+
dest="input",
|
|
42
|
+
help="offline OSM input (.osm, .geojson or Overpass .json) instead of downloading",
|
|
43
|
+
)
|
|
44
|
+
p.add_argument("--dem", help="ESRI ASCII grid (.asc, WGS 84) for the slope penalty")
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--engine", choices=("python", "networkx"), default="python", help="shortest-path engine"
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("--timeout", type=float, default=60.0, help="Overpass timeout in seconds")
|
|
49
|
+
p.add_argument(
|
|
50
|
+
"--endpoint",
|
|
51
|
+
action="append",
|
|
52
|
+
help="Overpass interpreter URL (repeat for fail-over; default: public mirrors)",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--no-cache", action="store_true", help="bypass the on-disk Overpass cache")
|
|
55
|
+
p.add_argument("--cache-dir", help="Overpass cache folder")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add_point(p: argparse.ArgumentParser, required: bool = True) -> None:
|
|
59
|
+
p.add_argument("--lat", type=float, required=required, help="latitude (WGS 84)")
|
|
60
|
+
p.add_argument("--lon", type=float, required=required, help="longitude (WGS 84)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="openwalkscore",
|
|
66
|
+
description="Open, transparent walkability scores (0-100) from OpenStreetMap.",
|
|
67
|
+
epilog="Not affiliated with Walk Score(R)/Redfin. Map data (c) OpenStreetMap contributors.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument("--version", action="version", version=f"openwalkscore {__version__}")
|
|
70
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
71
|
+
|
|
72
|
+
s = sub.add_parser("score", help="score one point")
|
|
73
|
+
_add_point(s)
|
|
74
|
+
_add_common(s)
|
|
75
|
+
s.add_argument("--name", default="", help="label for the point")
|
|
76
|
+
s.add_argument("--json", action="store_true", help="print the full result as JSON")
|
|
77
|
+
s.add_argument("--html", help="also write an HTML map to this file")
|
|
78
|
+
|
|
79
|
+
g = sub.add_parser("grid", help="score a grid of cells, write GeoJSON")
|
|
80
|
+
g.add_argument(
|
|
81
|
+
"--bbox",
|
|
82
|
+
required=True,
|
|
83
|
+
help="west,south,east,north in degrees (e.g. 2.33,48.85,2.36,48.87)",
|
|
84
|
+
)
|
|
85
|
+
g.add_argument("--cell", type=float, default=250.0, help="cell size in metres (%(default)s)")
|
|
86
|
+
g.add_argument("--max-cells", type=int, default=10_000, help="safety limit on cell count")
|
|
87
|
+
g.add_argument("-o", "--output", required=True, help="output GeoJSON path")
|
|
88
|
+
g.add_argument("--html", help="also write an HTML map to this file")
|
|
89
|
+
_add_common(g)
|
|
90
|
+
|
|
91
|
+
e = sub.add_parser("explain", help="explain a point's score, or the method and profile")
|
|
92
|
+
_add_point(e, required=False)
|
|
93
|
+
_add_common(e)
|
|
94
|
+
|
|
95
|
+
c = sub.add_parser("compare", help="score and rank several points")
|
|
96
|
+
c.add_argument(
|
|
97
|
+
"--point",
|
|
98
|
+
action="append",
|
|
99
|
+
required=True,
|
|
100
|
+
help="'lat,lon' or 'name,lat,lon' (repeat)",
|
|
101
|
+
)
|
|
102
|
+
_add_common(c)
|
|
103
|
+
c.add_argument("--json", action="store_true", help="print results as JSON")
|
|
104
|
+
c.add_argument("--html", help="also write an HTML map to this file")
|
|
105
|
+
|
|
106
|
+
sub.add_parser("profiles", help="list built-in profiles")
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _client(args: argparse.Namespace) -> OverpassClient:
|
|
111
|
+
cache: Any = False if args.no_cache else (args.cache_dir or None)
|
|
112
|
+
return OverpassClient(
|
|
113
|
+
endpoints=args.endpoint or DEFAULT_ENDPOINTS, timeout_s=args.timeout, cache_dir=cache
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _kwargs(args: argparse.Namespace) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"data": args.input,
|
|
120
|
+
"dem": args.dem,
|
|
121
|
+
"engine": args.engine,
|
|
122
|
+
"client": _client(args),
|
|
123
|
+
"use_cache": not args.no_cache,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _summary(r: WalkScoreResult) -> str:
|
|
128
|
+
head = f"{r.name + ': ' if r.name else ''}{r.score}/100 ({r.rating})"
|
|
129
|
+
lines = [
|
|
130
|
+
head,
|
|
131
|
+
f" location {r.lat:.6f}, {r.lon:.6f}",
|
|
132
|
+
f" profile {r.profile} distances: {r.distance_mode}",
|
|
133
|
+
f" access score {r.access_score:.1f} penalties: -{r.total_penalty * 100:.1f} %",
|
|
134
|
+
" subscores:",
|
|
135
|
+
]
|
|
136
|
+
for c in r.categories:
|
|
137
|
+
near = c.nearest
|
|
138
|
+
where = f"{near.name} @ {near.distance_m:.0f} m" if near else "-"
|
|
139
|
+
lines.append(f" {c.label:<22} {c.subscore * 100:5.1f} % {where}")
|
|
140
|
+
return "\n".join(lines)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_point(text: str) -> tuple[str, float, float]:
|
|
144
|
+
parts = [p.strip() for p in text.split(",")]
|
|
145
|
+
if len(parts) == 2:
|
|
146
|
+
return ("", float(parts[0]), float(parts[1]))
|
|
147
|
+
if len(parts) == 3:
|
|
148
|
+
return (parts[0], float(parts[1]), float(parts[2]))
|
|
149
|
+
raise ValueError(f"--point {text!r}: use 'lat,lon' or 'name,lat,lon'")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _cmd_score(args: argparse.Namespace) -> int:
|
|
153
|
+
r = score_point(args.lat, args.lon, args.profile, name=args.name, **_kwargs(args))
|
|
154
|
+
print(json.dumps(r.to_dict(), indent=2, ensure_ascii=False) if args.json else _summary(r))
|
|
155
|
+
if args.html:
|
|
156
|
+
from .htmlmap import write_html
|
|
157
|
+
|
|
158
|
+
write_html(r, args.html, title=f"openwalkscore {r.score}/100")
|
|
159
|
+
print(f"map written to {args.html}", file=sys.stderr)
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _cmd_grid(args: argparse.Namespace) -> int:
|
|
164
|
+
parts = [p.strip() for p in args.bbox.split(",")]
|
|
165
|
+
if len(parts) != 4:
|
|
166
|
+
raise ValueError("--bbox needs four comma-separated numbers: west,south,east,north")
|
|
167
|
+
bbox = (float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]))
|
|
168
|
+
fc = score_grid(bbox, args.cell, args.profile, max_cells=args.max_cells, **_kwargs(args))
|
|
169
|
+
Path(args.output).write_text(json.dumps(fc, ensure_ascii=False), encoding="utf-8")
|
|
170
|
+
props = fc["properties"]
|
|
171
|
+
print(f"{props['cells']} cells, mean score {props['mean_score']} -> {args.output}")
|
|
172
|
+
if args.html:
|
|
173
|
+
from .htmlmap import write_html
|
|
174
|
+
|
|
175
|
+
write_html(fc, args.html, title="openwalkscore grid")
|
|
176
|
+
print(f"map written to {args.html}", file=sys.stderr)
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _cmd_explain(args: argparse.Namespace) -> int:
|
|
181
|
+
if (args.lat is None) != (args.lon is None):
|
|
182
|
+
raise ValueError("give both --lat and --lon, or neither")
|
|
183
|
+
if args.lat is None:
|
|
184
|
+
prof = resolve_profile(args.profile)
|
|
185
|
+
print(METHOD_SUMMARY)
|
|
186
|
+
print("Rating bands: " + ", ".join(f"{name} >= {lo}" for lo, name in RATINGS))
|
|
187
|
+
print(f"\nProfile '{prof.name}':")
|
|
188
|
+
print(json.dumps(prof.to_dict(), indent=2))
|
|
189
|
+
return 0
|
|
190
|
+
r = score_point(args.lat, args.lon, args.profile, **_kwargs(args))
|
|
191
|
+
print(r.explain())
|
|
192
|
+
near = r.nearest_amenities(limit=15)
|
|
193
|
+
if near:
|
|
194
|
+
print("\nNearest counted amenities:")
|
|
195
|
+
for h in near:
|
|
196
|
+
print(
|
|
197
|
+
f" {h.distance_m:7.0f} m {h.category:<18} {h.name} "
|
|
198
|
+
f"(credit {h.credit:.2f}, {h.distance_mode}, {h.osm_ref})"
|
|
199
|
+
)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _cmd_compare(args: argparse.Namespace) -> int:
|
|
204
|
+
pts = [_parse_point(p) for p in args.point]
|
|
205
|
+
named = [(n or f"#{i + 1}", la, lo) for i, (n, la, lo) in enumerate(pts)]
|
|
206
|
+
results = compare(named, args.profile, **_kwargs(args))
|
|
207
|
+
if args.json:
|
|
208
|
+
print(json.dumps([r.to_dict() for r in results], indent=2, ensure_ascii=False))
|
|
209
|
+
else:
|
|
210
|
+
ranked = sorted(results, key=lambda r: -r.score)
|
|
211
|
+
print(f"{'rank':<5}{'name':<24}{'score':>6} rating")
|
|
212
|
+
for i, r in enumerate(ranked, 1):
|
|
213
|
+
print(f"{i:<5}{r.name[:23]:<24}{r.score:>6} {r.rating}")
|
|
214
|
+
if args.html:
|
|
215
|
+
from .htmlmap import write_html
|
|
216
|
+
|
|
217
|
+
write_html(results, args.html, title="openwalkscore comparison")
|
|
218
|
+
print(f"map written to {args.html}", file=sys.stderr)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _cmd_profiles(args: argparse.Namespace) -> int:
|
|
223
|
+
for name in list_profiles():
|
|
224
|
+
print(f"{name:<10} {resolve_profile(name).description}")
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
229
|
+
"""Entry point; returns a process exit code."""
|
|
230
|
+
# OSM names are in every script; never crash on a narrow console encoding.
|
|
231
|
+
for stream in (sys.stdout, sys.stderr):
|
|
232
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
233
|
+
if reconfigure is not None:
|
|
234
|
+
try:
|
|
235
|
+
reconfigure(errors="backslashreplace")
|
|
236
|
+
except (ValueError, OSError): # pragma: no cover - exotic streams
|
|
237
|
+
pass
|
|
238
|
+
parser = build_parser()
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
handlers = {
|
|
241
|
+
"score": _cmd_score,
|
|
242
|
+
"grid": _cmd_grid,
|
|
243
|
+
"explain": _cmd_explain,
|
|
244
|
+
"compare": _cmd_compare,
|
|
245
|
+
"profiles": _cmd_profiles,
|
|
246
|
+
}
|
|
247
|
+
try:
|
|
248
|
+
return handlers[args.command](args)
|
|
249
|
+
except OverpassError as exc:
|
|
250
|
+
print(f"openwalkscore: download failed: {exc}", file=sys.stderr)
|
|
251
|
+
return 3
|
|
252
|
+
except (ValueError, FileNotFoundError, OSError) as exc:
|
|
253
|
+
print(f"openwalkscore: error: {exc}", file=sys.stderr)
|
|
254
|
+
return 2
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__": # pragma: no cover
|
|
258
|
+
sys.exit(main())
|
openwalkscore/dem.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Optional elevation input for the slope penalty.
|
|
2
|
+
|
|
3
|
+
The scorer only needs a callable ``elevation(lat, lon) -> metres | None``. This
|
|
4
|
+
module offers two ready-made sources without heavy dependencies:
|
|
5
|
+
|
|
6
|
+
* :meth:`DEM.from_ascii_grid` reads an ESRI ASCII grid (``.asc``) in geographic
|
|
7
|
+
coordinates (WGS 84 degrees), the format GDAL writes with
|
|
8
|
+
``gdal_translate -of AAIGrid``.
|
|
9
|
+
* :meth:`DEM.from_function` wraps any callable (for example a rasterio sampler).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, Optional
|
|
17
|
+
|
|
18
|
+
__all__ = ["DEM", "load_dem"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DEM:
|
|
22
|
+
"""Elevation source. Call it as ``dem(lat, lon)``; returns metres or ``None``."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, fn: Callable[[float, float], Optional[float]], name: str = "dem") -> None:
|
|
25
|
+
self._fn = fn
|
|
26
|
+
self.name = name
|
|
27
|
+
|
|
28
|
+
def __call__(self, lat: float, lon: float) -> float | None:
|
|
29
|
+
z = self._fn(lat, lon)
|
|
30
|
+
if z is None:
|
|
31
|
+
return None
|
|
32
|
+
z = float(z)
|
|
33
|
+
return z if math.isfinite(z) else None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_function(cls, fn: Callable[[float, float], Optional[float]]) -> DEM:
|
|
37
|
+
return cls(fn, name=getattr(fn, "__name__", "function"))
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_ascii_grid(cls, path: str | Path) -> DEM:
|
|
41
|
+
"""Load an ESRI ASCII grid whose cell coordinates are WGS 84 degrees.
|
|
42
|
+
|
|
43
|
+
Values are bilinearly interpolated between cell centres; points outside
|
|
44
|
+
the grid or touching ``NODATA`` cells return ``None``.
|
|
45
|
+
"""
|
|
46
|
+
header: dict[str, float] = {}
|
|
47
|
+
rows: list[list[float]] = []
|
|
48
|
+
with open(path, encoding="utf-8") as fh:
|
|
49
|
+
for line in fh:
|
|
50
|
+
parts = line.split()
|
|
51
|
+
if not parts:
|
|
52
|
+
continue
|
|
53
|
+
key = parts[0].lower()
|
|
54
|
+
if not rows and key in (
|
|
55
|
+
"ncols",
|
|
56
|
+
"nrows",
|
|
57
|
+
"xllcorner",
|
|
58
|
+
"yllcorner",
|
|
59
|
+
"xllcenter",
|
|
60
|
+
"yllcenter",
|
|
61
|
+
"cellsize",
|
|
62
|
+
"nodata_value",
|
|
63
|
+
):
|
|
64
|
+
header[key] = float(parts[1])
|
|
65
|
+
continue
|
|
66
|
+
rows.append([float(p) for p in parts])
|
|
67
|
+
for req in ("ncols", "nrows", "cellsize"):
|
|
68
|
+
if req not in header:
|
|
69
|
+
raise ValueError(f"{path}: ASCII grid header lacks {req!r}")
|
|
70
|
+
ncols, nrows, cs = int(header["ncols"]), int(header["nrows"]), header["cellsize"]
|
|
71
|
+
values = [v for row in rows for v in row]
|
|
72
|
+
if len(values) != ncols * nrows:
|
|
73
|
+
raise ValueError(f"{path}: expected {ncols * nrows} values, found {len(values)}")
|
|
74
|
+
grid = [values[r * ncols : (r + 1) * ncols] for r in range(nrows)]
|
|
75
|
+
if "xllcenter" in header:
|
|
76
|
+
x0 = header["xllcenter"]
|
|
77
|
+
y0 = header["yllcenter"]
|
|
78
|
+
else:
|
|
79
|
+
x0 = header.get("xllcorner", 0.0) + cs / 2
|
|
80
|
+
y0 = header.get("yllcorner", 0.0) + cs / 2
|
|
81
|
+
nodata = header.get("nodata_value")
|
|
82
|
+
# Row 0 is the northern edge; cell-centre latitude of row r:
|
|
83
|
+
y_top = y0 + (nrows - 1) * cs
|
|
84
|
+
|
|
85
|
+
def sample(lat: float, lon: float) -> float | None:
|
|
86
|
+
fx = (lon - x0) / cs
|
|
87
|
+
fy = (y_top - lat) / cs
|
|
88
|
+
eps = 1e-6 # forgive floating-point round-off at the grid edge
|
|
89
|
+
if fx < -eps or fy < -eps or fx > ncols - 1 + eps or fy > nrows - 1 + eps:
|
|
90
|
+
return None
|
|
91
|
+
fx = min(max(fx, 0.0), ncols - 1.0)
|
|
92
|
+
fy = min(max(fy, 0.0), nrows - 1.0)
|
|
93
|
+
c0 = min(int(fx), max(ncols - 2, 0))
|
|
94
|
+
r0 = min(int(fy), max(nrows - 2, 0))
|
|
95
|
+
c1, r1 = min(c0 + 1, ncols - 1), min(r0 + 1, nrows - 1)
|
|
96
|
+
tx, ty = fx - c0, fy - r0
|
|
97
|
+
corners = (grid[r0][c0], grid[r0][c1], grid[r1][c0], grid[r1][c1])
|
|
98
|
+
if nodata is not None and any(v == nodata for v in corners):
|
|
99
|
+
return None
|
|
100
|
+
top = corners[0] * (1 - tx) + corners[1] * tx
|
|
101
|
+
bottom = corners[2] * (1 - tx) + corners[3] * tx
|
|
102
|
+
return top * (1 - ty) + bottom * ty
|
|
103
|
+
|
|
104
|
+
return cls(sample, name=str(path))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_dem(
|
|
108
|
+
source: str | Path | DEM | Callable[[float, float], Optional[float]] | None,
|
|
109
|
+
) -> DEM | None:
|
|
110
|
+
"""Turn a path, callable or :class:`DEM` into a :class:`DEM` (``None`` passes through)."""
|
|
111
|
+
if source is None or isinstance(source, DEM):
|
|
112
|
+
return source
|
|
113
|
+
if callable(source):
|
|
114
|
+
return DEM.from_function(source)
|
|
115
|
+
path = Path(source)
|
|
116
|
+
if path.suffix.lower() != ".asc":
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"only ESRI ASCII grids (.asc) are read directly; pass a callable otherwise"
|
|
119
|
+
)
|
|
120
|
+
return DEM.from_ascii_grid(path)
|
openwalkscore/geo.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Small geodesy helpers: great-circle distance, local projection and bounding boxes.
|
|
2
|
+
|
|
3
|
+
Everything here is pure Python. Distances are in metres, coordinates in WGS 84
|
|
4
|
+
degrees. The local projection is an equirectangular approximation that is accurate
|
|
5
|
+
to well under 1 % over the few kilometres a walkability analysis covers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Tuple
|
|
12
|
+
|
|
13
|
+
EARTH_RADIUS_M = 6_371_008.8
|
|
14
|
+
"""Mean Earth radius (IUGG) in metres."""
|
|
15
|
+
|
|
16
|
+
BBox = Tuple[float, float, float, float]
|
|
17
|
+
"""Bounding box as ``(west, south, east, north)`` in degrees (GeoJSON order)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
21
|
+
"""Great-circle distance between two WGS 84 points, in metres."""
|
|
22
|
+
p1 = math.radians(lat1)
|
|
23
|
+
p2 = math.radians(lat2)
|
|
24
|
+
dp = p2 - p1
|
|
25
|
+
dl = math.radians(lon2 - lon1)
|
|
26
|
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
27
|
+
return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LocalProjection:
|
|
31
|
+
"""Equirectangular projection centred on a reference point.
|
|
32
|
+
|
|
33
|
+
``forward`` maps ``(lat, lon)`` to ``(x, y)`` metres east/north of the origin and
|
|
34
|
+
``inverse`` maps back. Good enough for city-scale geometry (snapping, buffers).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, lat0: float, lon0: float) -> None:
|
|
38
|
+
self.lat0 = lat0
|
|
39
|
+
self.lon0 = lon0
|
|
40
|
+
self._kx = math.radians(1.0) * EARTH_RADIUS_M * math.cos(math.radians(lat0))
|
|
41
|
+
self._ky = math.radians(1.0) * EARTH_RADIUS_M
|
|
42
|
+
|
|
43
|
+
def forward(self, lat: float, lon: float) -> tuple[float, float]:
|
|
44
|
+
return ((lon - self.lon0) * self._kx, (lat - self.lat0) * self._ky)
|
|
45
|
+
|
|
46
|
+
def inverse(self, x: float, y: float) -> tuple[float, float]:
|
|
47
|
+
return (self.lat0 + y / self._ky, self.lon0 + x / self._kx)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def bbox_around(lat: float, lon: float, radius_m: float) -> BBox:
|
|
51
|
+
"""Bounding box ``(west, south, east, north)`` that contains a circle of ``radius_m``."""
|
|
52
|
+
if radius_m < 0:
|
|
53
|
+
raise ValueError("radius_m must be non-negative")
|
|
54
|
+
dlat = math.degrees(radius_m / EARTH_RADIUS_M)
|
|
55
|
+
coslat = max(math.cos(math.radians(lat)), 1e-6)
|
|
56
|
+
dlon = math.degrees(radius_m / (EARTH_RADIUS_M * coslat))
|
|
57
|
+
return (lon - dlon, max(lat - dlat, -90.0), lon + dlon, min(lat + dlat, 90.0))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def expand_bbox(bbox: BBox, margin_m: float) -> BBox:
|
|
61
|
+
"""Grow a bounding box by ``margin_m`` metres on every side."""
|
|
62
|
+
west, south, east, north = validate_bbox(bbox)
|
|
63
|
+
dlat = math.degrees(margin_m / EARTH_RADIUS_M)
|
|
64
|
+
# The widest latitude of the box needs the largest longitude margin.
|
|
65
|
+
worst_lat = max(abs(south), abs(north))
|
|
66
|
+
coslat = max(math.cos(math.radians(worst_lat)), 1e-6)
|
|
67
|
+
dlon = math.degrees(margin_m / (EARTH_RADIUS_M * coslat))
|
|
68
|
+
return (
|
|
69
|
+
max(west - dlon, -180.0),
|
|
70
|
+
max(south - dlat, -90.0),
|
|
71
|
+
min(east + dlon, 180.0),
|
|
72
|
+
min(north + dlat, 90.0),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_bbox(bbox: BBox) -> BBox:
|
|
77
|
+
"""Check that ``bbox`` is a sane ``(west, south, east, north)`` tuple and return it."""
|
|
78
|
+
if len(bbox) != 4:
|
|
79
|
+
raise ValueError("bbox must have four values: west, south, east, north")
|
|
80
|
+
west, south, east, north = (float(v) for v in bbox)
|
|
81
|
+
if not (-180.0 <= west <= 180.0 and -180.0 <= east <= 180.0):
|
|
82
|
+
raise ValueError("bbox longitudes must be within [-180, 180]")
|
|
83
|
+
if not (-90.0 <= south <= 90.0 and -90.0 <= north <= 90.0):
|
|
84
|
+
raise ValueError("bbox latitudes must be within [-90, 90]")
|
|
85
|
+
if west >= east or south >= north:
|
|
86
|
+
raise ValueError("bbox must satisfy west < east and south < north (west,south,east,north)")
|
|
87
|
+
return (west, south, east, north)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def bbox_diagonal_m(bbox: BBox) -> float:
|
|
91
|
+
"""Length of the bounding-box diagonal in metres."""
|
|
92
|
+
west, south, east, north = bbox
|
|
93
|
+
return haversine_m(south, west, north, east)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_latlon(lat: float, lon: float) -> tuple[float, float]:
|
|
97
|
+
"""Return ``(lat, lon)`` as floats or raise ``ValueError`` when out of range."""
|
|
98
|
+
lat_f = float(lat)
|
|
99
|
+
lon_f = float(lon)
|
|
100
|
+
if not (math.isfinite(lat_f) and math.isfinite(lon_f)):
|
|
101
|
+
raise ValueError("latitude and longitude must be finite numbers")
|
|
102
|
+
if not -90.0 <= lat_f <= 90.0:
|
|
103
|
+
raise ValueError(f"latitude {lat_f} is outside [-90, 90]")
|
|
104
|
+
if not -180.0 <= lon_f <= 180.0:
|
|
105
|
+
raise ValueError(f"longitude {lon_f} is outside [-180, 180]")
|
|
106
|
+
return lat_f, lon_f
|