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.
@@ -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