bboxkit 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.
- bboxkit/__init__.py +31 -0
- bboxkit/__main__.py +6 -0
- bboxkit/_geo.py +222 -0
- bboxkit/_version.py +1 -0
- bboxkit/bbox.py +1029 -0
- bboxkit/cli.py +311 -0
- bboxkit/crs.py +380 -0
- bboxkit/formats.py +86 -0
- bboxkit/geohash.py +83 -0
- bboxkit/mgrs.py +136 -0
- bboxkit/nominatim.py +113 -0
- bboxkit/parsing.py +722 -0
- bboxkit/pluscode.py +125 -0
- bboxkit/py.typed +0 -0
- bboxkit/tiles.py +148 -0
- bboxkit-0.1.0.dist-info/METADATA +270 -0
- bboxkit-0.1.0.dist-info/RECORD +21 -0
- bboxkit-0.1.0.dist-info/WHEEL +5 -0
- bboxkit-0.1.0.dist-info/entry_points.txt +2 -0
- bboxkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- bboxkit-0.1.0.dist-info/top_level.txt +1 -0
bboxkit/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""bboxkit: parse, convert and operate on bounding boxes, with zero dependencies.
|
|
2
|
+
|
|
3
|
+
>>> from bboxkit import BBox, parse
|
|
4
|
+
>>> box = parse("29.0,40.9,29.2,41.1", order="wsen")
|
|
5
|
+
>>> box.to_overpass()
|
|
6
|
+
'(40.9,29,41.1,29.2)'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .bbox import BBox, union_all
|
|
11
|
+
from .crs import WEB_MERCATOR, WGS84, normalize_crs, utm_crs, utm_zone
|
|
12
|
+
from .formats import FORMATS, format_bbox
|
|
13
|
+
from .parsing import PARSERS, AmbiguousOrderWarning, parse
|
|
14
|
+
from .tiles import Tile
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"BBox",
|
|
19
|
+
"Tile",
|
|
20
|
+
"parse",
|
|
21
|
+
"union_all",
|
|
22
|
+
"format_bbox",
|
|
23
|
+
"FORMATS",
|
|
24
|
+
"PARSERS",
|
|
25
|
+
"AmbiguousOrderWarning",
|
|
26
|
+
"WGS84",
|
|
27
|
+
"WEB_MERCATOR",
|
|
28
|
+
"normalize_crs",
|
|
29
|
+
"utm_zone",
|
|
30
|
+
"utm_crs",
|
|
31
|
+
]
|
bboxkit/__main__.py
ADDED
bboxkit/_geo.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Ellipsoid constants, longitude arcs and closed-form geodesy on WGS 84.
|
|
2
|
+
|
|
3
|
+
Everything here works on plain floats so the rest of the package can stay
|
|
4
|
+
free of third-party dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import Iterable, List, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
# WGS 84 ellipsoid.
|
|
13
|
+
WGS84_A = 6378137.0
|
|
14
|
+
WGS84_F = 1.0 / 298.257223563
|
|
15
|
+
WGS84_B = WGS84_A * (1.0 - WGS84_F)
|
|
16
|
+
WGS84_E2 = WGS84_F * (2.0 - WGS84_F)
|
|
17
|
+
WGS84_E = math.sqrt(WGS84_E2)
|
|
18
|
+
#: Third flattening n = f / (2 - f), used by the Krueger series.
|
|
19
|
+
WGS84_N = WGS84_F / (2.0 - WGS84_F)
|
|
20
|
+
|
|
21
|
+
#: Latitude limit of the square Web Mercator world (EPSG:3857 / XYZ tiles).
|
|
22
|
+
MERCATOR_MAX_LAT = math.degrees(math.atan(math.sinh(math.pi)))
|
|
23
|
+
|
|
24
|
+
Arc = Tuple[float, float]
|
|
25
|
+
"""A longitude arc as ``(start, width)``: start in [-180, 180), width in [0, 360]."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def wrap_lon(lon: float) -> float:
|
|
29
|
+
"""Wrap a longitude into [-180, 180)."""
|
|
30
|
+
wrapped = (lon + 180.0) % 360.0 - 180.0
|
|
31
|
+
# ``%`` can return 360.0 - tiny for tiny negative inputs.
|
|
32
|
+
return -180.0 if wrapped >= 180.0 else wrapped
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def wrap_delta(delta: float) -> float:
|
|
36
|
+
"""Wrap a longitude difference into (-180, 180]."""
|
|
37
|
+
d = (delta + 180.0) % 360.0 - 180.0
|
|
38
|
+
return 180.0 if d == -180.0 and delta > 0 else d
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lon_arc(west: float, east: float) -> Arc:
|
|
42
|
+
"""Return the eastward arc from ``west`` to ``east`` (crossing allowed)."""
|
|
43
|
+
if west == -180.0 and east == 180.0:
|
|
44
|
+
return (-180.0, 360.0)
|
|
45
|
+
width = east - west if west <= east else east - west + 360.0
|
|
46
|
+
return (wrap_lon(west), width)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def arc_to_lons(arc: Arc) -> Tuple[float, float]:
|
|
50
|
+
"""Convert an arc back to a ``(west, east)`` pair (``west > east`` if crossing)."""
|
|
51
|
+
start, width = arc
|
|
52
|
+
if width >= 360.0:
|
|
53
|
+
return (-180.0, 180.0)
|
|
54
|
+
east = start + width
|
|
55
|
+
if east > 180.0:
|
|
56
|
+
east -= 360.0
|
|
57
|
+
return (start, east)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def unwrapped_to_lons(lo: float, hi: float) -> Tuple[float, float]:
|
|
61
|
+
"""Turn an unwrapped longitude range (values may exceed +-180) into west/east."""
|
|
62
|
+
if hi - lo >= 360.0:
|
|
63
|
+
return (-180.0, 180.0)
|
|
64
|
+
return arc_to_lons((wrap_lon(lo), hi - lo))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def covering_arc(arcs: Iterable[Arc]) -> Arc:
|
|
68
|
+
"""Smallest longitude arc that covers every arc in ``arcs``.
|
|
69
|
+
|
|
70
|
+
This is the complement of the largest longitude gap, the rule RFC 7946
|
|
71
|
+
section 5.2 uses for features straddling the antimeridian. On a tie the
|
|
72
|
+
arc that does not cross the antimeridian wins.
|
|
73
|
+
"""
|
|
74
|
+
segs = sorted(((s + 180.0) % 360.0, w) for s, w in arcs)
|
|
75
|
+
if not segs:
|
|
76
|
+
raise ValueError("no longitudes to cover")
|
|
77
|
+
if any(w >= 360.0 for _, w in segs):
|
|
78
|
+
return (-180.0, 360.0)
|
|
79
|
+
merged: List[List[float]] = []
|
|
80
|
+
for s, w in segs:
|
|
81
|
+
e = s + w
|
|
82
|
+
if merged and s <= merged[-1][1]:
|
|
83
|
+
merged[-1][1] = max(merged[-1][1], e)
|
|
84
|
+
else:
|
|
85
|
+
merged.append([s, e])
|
|
86
|
+
# Segments running past 360 may swallow segments at the start.
|
|
87
|
+
while len(merged) > 1 and merged[-1][1] >= merged[0][0] + 360.0:
|
|
88
|
+
merged[-1][1] = max(merged[-1][1], merged[0][1] + 360.0)
|
|
89
|
+
merged.pop(0)
|
|
90
|
+
if len(merged) == 1:
|
|
91
|
+
s, e = merged[0]
|
|
92
|
+
if e - s >= 360.0:
|
|
93
|
+
return (-180.0, 360.0)
|
|
94
|
+
return (wrap_lon(s - 180.0), e - s)
|
|
95
|
+
best: Tuple[float, int, float, float] = (-1.0, 0, 0.0, 0.0)
|
|
96
|
+
count = len(merged)
|
|
97
|
+
for i in range(count):
|
|
98
|
+
gap_start = merged[i][1]
|
|
99
|
+
nxt = merged[(i + 1) % count][0] + (360.0 if i == count - 1 else 0.0)
|
|
100
|
+
gap = nxt - gap_start
|
|
101
|
+
# The covering arc runs from the end of the gap round to its start.
|
|
102
|
+
start = nxt
|
|
103
|
+
width = 360.0 - gap
|
|
104
|
+
lon_start = wrap_lon(start - 180.0)
|
|
105
|
+
non_crossing = 1 if lon_start + width <= 180.0 else 0
|
|
106
|
+
cand = (gap, non_crossing, lon_start, width)
|
|
107
|
+
if cand[:2] > best[:2]:
|
|
108
|
+
best = cand
|
|
109
|
+
return (best[2], best[3])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def snap_arc_edges(
|
|
113
|
+
arc: Arc, wests: Iterable[float], easts: Iterable[float], tol: float = 1e-9
|
|
114
|
+
) -> Tuple[float, float]:
|
|
115
|
+
"""``(west, east)`` of ``arc``, snapped to the exact input longitudes it came from.
|
|
116
|
+
|
|
117
|
+
``start + width`` can differ from the original east edge by a rounding
|
|
118
|
+
error; picking the matching input value keeps results exact.
|
|
119
|
+
"""
|
|
120
|
+
west, east = arc_to_lons(arc)
|
|
121
|
+
if arc[1] >= 360.0:
|
|
122
|
+
return (west, east)
|
|
123
|
+
for x in wests:
|
|
124
|
+
if abs(wrap_delta(x - west)) <= tol:
|
|
125
|
+
west = -180.0 if x == 180.0 else x
|
|
126
|
+
break
|
|
127
|
+
for x in easts:
|
|
128
|
+
if abs(wrap_delta(x - east)) <= tol:
|
|
129
|
+
east = 180.0 if x == -180.0 else wrap_lon(x) if x > 180.0 else x
|
|
130
|
+
break
|
|
131
|
+
if west == 180.0:
|
|
132
|
+
west = -180.0
|
|
133
|
+
return (max(-180.0, min(180.0, west)), max(-180.0, min(180.0, east)))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def component_arc(lons: Sequence[float]) -> Arc:
|
|
137
|
+
"""Longitude arc of a connected line or ring.
|
|
138
|
+
|
|
139
|
+
Consecutive vertices more than 180 degrees apart are joined across the
|
|
140
|
+
antimeridian (the shorter way round), as RFC 7946 prescribes.
|
|
141
|
+
"""
|
|
142
|
+
if not lons:
|
|
143
|
+
raise ValueError("empty coordinate sequence")
|
|
144
|
+
if any(abs(b - a) > 180.0 for a, b in zip(lons, lons[1:])):
|
|
145
|
+
unwrapped = [lons[0]]
|
|
146
|
+
for a, b in zip(lons, lons[1:]):
|
|
147
|
+
unwrapped.append(unwrapped[-1] + wrap_delta(b - a))
|
|
148
|
+
lo, hi = min(unwrapped), max(unwrapped)
|
|
149
|
+
if hi - lo >= 360.0:
|
|
150
|
+
return (-180.0, 360.0)
|
|
151
|
+
return (wrap_lon(lo), hi - lo)
|
|
152
|
+
lo, hi = min(lons), max(lons)
|
|
153
|
+
return lon_arc(lo, hi)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
# Radii, meridian arc and area
|
|
158
|
+
# --------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def meridional_radius(lat: float) -> float:
|
|
162
|
+
"""Radius of curvature in the meridian, M(phi), in metres."""
|
|
163
|
+
s = math.sin(math.radians(lat))
|
|
164
|
+
return WGS84_A * (1.0 - WGS84_E2) / math.pow(1.0 - WGS84_E2 * s * s, 1.5)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def prime_vertical_radius(lat: float) -> float:
|
|
168
|
+
"""Radius of curvature in the prime vertical, N(phi), in metres."""
|
|
169
|
+
s = math.sin(math.radians(lat))
|
|
170
|
+
return WGS84_A / math.sqrt(1.0 - WGS84_E2 * s * s)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parallel_radius(lat: float) -> float:
|
|
174
|
+
"""Radius of the parallel circle at ``lat`` (N cos phi), in metres."""
|
|
175
|
+
return prime_vertical_radius(lat) * math.cos(math.radians(lat))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def meridian_distance(lat: float) -> float:
|
|
179
|
+
"""Distance along the meridian from the equator to ``lat`` in metres.
|
|
180
|
+
|
|
181
|
+
Helmert's series in the third flattening; accurate to well below a
|
|
182
|
+
millimetre on WGS 84.
|
|
183
|
+
"""
|
|
184
|
+
n = WGS84_N
|
|
185
|
+
phi = math.radians(lat)
|
|
186
|
+
a0 = WGS84_A / (1.0 + n) * (1.0 + n**2 / 4.0 + n**4 / 64.0)
|
|
187
|
+
return a0 * (
|
|
188
|
+
phi
|
|
189
|
+
- (1.5 * n - 9.0 / 16.0 * n**3) * math.sin(2 * phi)
|
|
190
|
+
+ (15.0 / 16.0 * n**2 - 15.0 / 32.0 * n**4) * math.sin(4 * phi)
|
|
191
|
+
- 35.0 / 48.0 * n**3 * math.sin(6 * phi)
|
|
192
|
+
+ 315.0 / 512.0 * n**4 * math.sin(8 * phi)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _authalic_q(lat: float) -> float:
|
|
197
|
+
s = math.sin(math.radians(lat))
|
|
198
|
+
es = WGS84_E * s
|
|
199
|
+
return s / (1.0 - es * es) + math.log((1.0 + es) / (1.0 - es)) / (2.0 * WGS84_E)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def rectangle_area_m2(width_deg: float, south: float, north: float) -> float:
|
|
203
|
+
"""Exact ellipsoidal area of a lat/lon rectangle bounded by parallels.
|
|
204
|
+
|
|
205
|
+
``A = (b^2 / 2) * dlambda * (q(north) - q(south))`` with the authalic
|
|
206
|
+
function q. The east/west edges are meridians and the north/south edges
|
|
207
|
+
are parallels, exactly like the region a bounding box describes.
|
|
208
|
+
"""
|
|
209
|
+
return 0.5 * WGS84_B**2 * math.radians(width_deg) * (_authalic_q(north) - _authalic_q(south))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def metres_to_dlat(metres: float, lat: float) -> float:
|
|
213
|
+
"""Latitude span (degrees) of ``metres`` along the meridian near ``lat``."""
|
|
214
|
+
return math.degrees(metres / meridional_radius(lat))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def metres_to_dlon(metres: float, lat: float) -> float:
|
|
218
|
+
"""Longitude span (degrees) of ``metres`` along the parallel at ``lat``."""
|
|
219
|
+
r = parallel_radius(lat)
|
|
220
|
+
if r <= 1e-6:
|
|
221
|
+
return 360.0
|
|
222
|
+
return math.degrees(metres / r)
|
bboxkit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|