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 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
@@ -0,0 +1,6 @@
1
+ """``python -m bboxkit``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__": # pragma: no cover
6
+ raise SystemExit(main())
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"