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/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ """Command-line interface: ``bboxkit convert|info|area|tiles|split|buffer|reproject|geocode``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ import warnings
10
+ from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO
11
+
12
+ from ._version import __version__
13
+ from .bbox import BBox
14
+ from .formats import FORMAT_ALIASES, FORMATS, format_bbox
15
+ from .parsing import PARSERS, parse
16
+
17
+ _UNITS = {"m": 1.0, "km": 1000.0, "mi": 1609.344, "ft": 0.3048, "nmi": 1852.0}
18
+
19
+
20
+ def parse_distance(text: str) -> float:
21
+ """``"1km"``, ``"500 m"``, ``"2mi"``, ``"250"`` (metres) to metres."""
22
+ m = re.fullmatch(r"\s*([-+]?\d+(?:\.\d*)?|[-+]?\.\d+)\s*([a-zA-Z]*)\s*", text)
23
+ if not m:
24
+ raise ValueError(f"cannot read distance {text!r}; try 500m or 1.5km")
25
+ unit = m.group(2).lower() or "m"
26
+ if unit not in _UNITS:
27
+ raise ValueError(f"unknown distance unit {unit!r}; use one of {', '.join(_UNITS)}")
28
+ return float(m.group(1)) * _UNITS[unit]
29
+
30
+
31
+ def _grid_spec(text: str) -> List[int]:
32
+ m = re.fullmatch(r"\s*(\d+)\s*[xX*,]\s*(\d+)\s*", text)
33
+ if not m:
34
+ raise ValueError(f"cannot read grid {text!r}; use e.g. 3x2 (columns x rows)")
35
+ return [int(m.group(1)), int(m.group(2))]
36
+
37
+
38
+ def _load(args: argparse.Namespace) -> BBox:
39
+ text = " ".join(args.bbox).strip()
40
+ if text == "-":
41
+ text = sys.stdin.read().strip()
42
+ return parse(text, fmt=args.source_format, order=args.order, crs=args.crs)
43
+
44
+
45
+ def _out(box: BBox, fmt: str, precision: Optional[int]) -> str:
46
+ kwargs: Dict[str, Any] = {}
47
+ if precision is not None and fmt in (
48
+ "wsen",
49
+ "swne",
50
+ "bboxfinder",
51
+ "overpass",
52
+ "wkt",
53
+ "envelope",
54
+ "postgis",
55
+ "gdal-te",
56
+ "gdal-projwin",
57
+ "bbox",
58
+ "csv",
59
+ "latlon",
60
+ "gdal",
61
+ "te",
62
+ "projwin",
63
+ ):
64
+ kwargs["precision"] = precision
65
+ return format_bbox(box, fmt, **kwargs)
66
+
67
+
68
+ def _cmd_convert(args: argparse.Namespace, out: TextIO) -> None:
69
+ box = _load(args)
70
+ if args.to == "all":
71
+ for name in FORMATS:
72
+ try:
73
+ value = _out(box, name, args.precision)
74
+ except (ValueError, ImportError) as exc:
75
+ value = f"(n/a: {exc})"
76
+ out.write(f"{name:<13} {value}\n")
77
+ return
78
+ out.write(_out(box, args.to, args.precision) + "\n")
79
+
80
+
81
+ def _cmd_info(args: argparse.Namespace, out: TextIO) -> None:
82
+ box = _load(args)
83
+ lon, lat = box.center
84
+ info: Dict[str, Any] = {
85
+ "bbox": list(box.bounds),
86
+ "crs": box.crs,
87
+ "crosses_antimeridian": box.crosses_antimeridian,
88
+ "width": box.width,
89
+ "height": box.height,
90
+ "center": [lon, lat],
91
+ "area_km2": box.area_km2(),
92
+ }
93
+ if box.is_geographic:
94
+ info["width_m"] = box.width_m
95
+ info["height_m"] = box.height_m
96
+ out.write(json.dumps(info, indent=2) + "\n")
97
+
98
+
99
+ def _cmd_area(args: argparse.Namespace, out: TextIO) -> None:
100
+ area = _load(args).area_km2()
101
+ out.write(f"{area:.{args.digits}f}\n")
102
+
103
+
104
+ def _cmd_tiles(args: argparse.Namespace, out: TextIO) -> None:
105
+ box = _load(args)
106
+ if args.count:
107
+ out.write(f"{box.tile_count(args.zoom)}\n")
108
+ return
109
+ for t in box.tiles(args.zoom, max_tiles=args.max):
110
+ if args.format == "quadkey":
111
+ out.write(t.quadkey + "\n")
112
+ elif args.format == "wsen":
113
+ out.write(BBox(*t.bounds).to_wsen() + "\n")
114
+ else:
115
+ out.write(f"{t}\n")
116
+
117
+
118
+ def _write_boxes(boxes: Sequence[BBox], fmt: str, precision: Optional[int], out: TextIO) -> None:
119
+ if fmt == "geojson":
120
+ fc = {
121
+ "type": "FeatureCollection",
122
+ "features": [
123
+ b.to_geojson(feature=True, properties={"id": i}) for i, b in enumerate(boxes)
124
+ ],
125
+ }
126
+ out.write(json.dumps(fc) + "\n")
127
+ return
128
+ for b in boxes:
129
+ out.write(_out(b, fmt, precision) + "\n")
130
+
131
+
132
+ def _cmd_split(args: argparse.Namespace, out: TextIO) -> None:
133
+ box = _load(args)
134
+ if args.grid:
135
+ nx, ny = _grid_spec(args.grid)
136
+ cells = box.grid(nx, ny)
137
+ else:
138
+ cy = parse_distance(args.cell_y) if args.cell_y else None
139
+ cells = box.split(parse_distance(args.cell), cy, max_cells=args.max)
140
+ _write_boxes(cells, args.to, args.precision, out)
141
+
142
+
143
+ def _cmd_buffer(args: argparse.Namespace, out: TextIO) -> None:
144
+ box = _load(args).buffer(parse_distance(args.distance))
145
+ out.write(_out(box, args.to, args.precision) + "\n")
146
+
147
+
148
+ def _cmd_reproject(args: argparse.Namespace, out: TextIO) -> None:
149
+ box = _load(args)
150
+ if args.target.lower() == "utm":
151
+ res = box.to_utm()
152
+ else:
153
+ res = box.to_crs(args.target, densify=args.densify)
154
+ text = _out(res, args.to, args.precision)
155
+ out.write(f"{text}\n" if args.quiet else f"{text} ({res.crs})\n")
156
+
157
+
158
+ def _cmd_geocode(args: argparse.Namespace, out: TextIO) -> None:
159
+ from .nominatim import geocode_bbox
160
+
161
+ box = geocode_bbox(
162
+ " ".join(args.query),
163
+ user_agent=args.user_agent,
164
+ email=args.email,
165
+ timeout=args.timeout,
166
+ base_url=args.server,
167
+ )
168
+ out.write(_out(box, args.to, args.precision) + "\n")
169
+
170
+
171
+ def _cmd_formats(args: argparse.Namespace, out: TextIO) -> None:
172
+ out.write("Output formats (--to):\n")
173
+ for name, desc in FORMATS.items():
174
+ out.write(f" {name:<13} {desc}\n")
175
+ out.write("Aliases: " + ", ".join(f"{a}={b}" for a, b in FORMAT_ALIASES.items()) + "\n")
176
+ out.write("Input formats (--from; detected automatically when omitted):\n")
177
+ out.write(" " + ", ".join(sorted(PARSERS)) + ", wsen, swne\n")
178
+
179
+
180
+ def _format_choice(text: str) -> str:
181
+ key = text.lower().replace("_", "-")
182
+ if key != "all" and key not in FORMATS and key not in FORMAT_ALIASES:
183
+ raise argparse.ArgumentTypeError(f"unknown format {text!r} (see 'bboxkit formats')")
184
+ return key
185
+
186
+
187
+ def build_parser() -> argparse.ArgumentParser:
188
+ parser = argparse.ArgumentParser(
189
+ prog="bboxkit",
190
+ description="Parse, convert and operate on bounding boxes.",
191
+ epilog="Boxes starting with '-' (e.g. -122.5,37.7,...) work as they are.",
192
+ )
193
+ parser.add_argument("--version", action="version", version=f"bboxkit {__version__}")
194
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
195
+ sub.required = True
196
+
197
+ common = argparse.ArgumentParser(add_help=False)
198
+ common.add_argument("bbox", nargs="+", help="bounding box in any format ('-' reads stdin)")
199
+ common.add_argument(
200
+ "--from",
201
+ dest="source_format",
202
+ metavar="FMT",
203
+ help="input format, e.g. wsen, swne, wkt, tile (default: detect)",
204
+ )
205
+ common.add_argument(
206
+ "--order",
207
+ choices=["wsen", "swne"],
208
+ help="order of four plain numbers (default: guess, with a warning)",
209
+ )
210
+ common.add_argument("--crs", help="CRS of the input coordinates (default EPSG:4326)")
211
+ common.add_argument("--precision", type=int, metavar="N", help="decimal places in the output")
212
+
213
+ def add(
214
+ name: str,
215
+ func: Callable[[argparse.Namespace, TextIO], None],
216
+ help_: str,
217
+ with_bbox: bool = True,
218
+ ) -> argparse.ArgumentParser:
219
+ p = sub.add_parser(
220
+ name, parents=[common] if with_bbox else [], help=help_, description=help_
221
+ )
222
+ p.set_defaults(func=func)
223
+ return p
224
+
225
+ p = add("convert", _cmd_convert, "convert a box to another format")
226
+ p.add_argument(
227
+ "--to", type=_format_choice, default="wsen", help="output format, or 'all' (default wsen)"
228
+ )
229
+
230
+ add("info", _cmd_info, "print size, centre and area as JSON")
231
+
232
+ p = add("area", _cmd_area, "area in square kilometres (ellipsoidal)")
233
+ p.add_argument("--digits", type=int, default=3, help="decimals to print (default 3)")
234
+
235
+ p = add("tiles", _cmd_tiles, "list XYZ tiles covering the box")
236
+ p.add_argument("--zoom", "-z", type=int, required=True)
237
+ p.add_argument("--format", choices=["zxy", "quadkey", "wsen"], default="zxy")
238
+ p.add_argument("--count", action="store_true", help="only print the number of tiles")
239
+ p.add_argument("--max", type=int, default=100_000, help="refuse more tiles (default 100000)")
240
+
241
+ p = add("split", _cmd_split, "split into cells of a given size or an n x m grid")
242
+ g = p.add_mutually_exclusive_group(required=True)
243
+ g.add_argument("--cell", help="cell size, e.g. 1km or 500m")
244
+ g.add_argument("--grid", help="columns x rows, e.g. 4x3")
245
+ p.add_argument("--cell-y", help="cell height if different from --cell")
246
+ p.add_argument("--to", type=_format_choice, default="wsen")
247
+ p.add_argument("--max", type=int, default=100_000, help="refuse more cells (default 100000)")
248
+
249
+ p = add("buffer", _cmd_buffer, "grow (or shrink) the box by a distance")
250
+ p.add_argument("--distance", "-d", required=True, help="e.g. 500m, 2km, -100m")
251
+ p.add_argument("--to", type=_format_choice, default="wsen")
252
+
253
+ p = add("reproject", _cmd_reproject, "reproject to EPSG:3857, UTM or (with pyproj) any CRS")
254
+ p.add_argument(
255
+ "--to-crs",
256
+ dest="target",
257
+ required=True,
258
+ help="target CRS, e.g. EPSG:3857, EPSG:32635 or 'utm' for the local zone",
259
+ )
260
+ p.add_argument("--densify", type=int, default=21)
261
+ p.add_argument("--to", type=_format_choice, default="wsen")
262
+ p.add_argument("--quiet", "-q", action="store_true", help="do not print the CRS")
263
+
264
+ p = add(
265
+ "geocode",
266
+ _cmd_geocode,
267
+ "look up a place name on Nominatim (network; usage policy applies)",
268
+ with_bbox=False,
269
+ )
270
+ p.add_argument("query", nargs="+", help="place name")
271
+ p.add_argument("--to", type=_format_choice, default="wsen")
272
+ p.add_argument("--precision", type=int, metavar="N")
273
+ p.add_argument("--user-agent", help="identify your application (recommended)")
274
+ p.add_argument("--email", help="contact address sent to Nominatim")
275
+ p.add_argument("--timeout", type=float, default=10.0)
276
+ p.add_argument("--server", default="https://nominatim.openstreetmap.org/search")
277
+
278
+ add("formats", _cmd_formats, "list supported formats", with_bbox=False)
279
+ return parser
280
+
281
+
282
+ _DASH_VALUE = re.compile(r"^-(?:\d|\.\d|te\s|projwin\s|spat\s)")
283
+
284
+
285
+ def _protect(argv: Sequence[str]) -> List[str]:
286
+ """Keep argparse from mistaking '-122.5,37.7,...' or '-te 1 2 3 4' for options."""
287
+ return [
288
+ f" {a}" if _DASH_VALUE.match(a) and (" " in a or "," in a or ";" in a) else a for a in argv
289
+ ]
290
+
291
+
292
+ def main(argv: Optional[Sequence[str]] = None, out: Optional[TextIO] = None) -> int:
293
+ """Entry point; returns the process exit code."""
294
+ stream = out if out is not None else sys.stdout
295
+ args = build_parser().parse_args(_protect(sys.argv[1:] if argv is None else argv))
296
+ with warnings.catch_warnings(record=True) as caught:
297
+ warnings.simplefilter("always")
298
+ try:
299
+ args.func(args, stream)
300
+ except (ValueError, TypeError, LookupError, ImportError, OSError) as exc:
301
+ for w in caught:
302
+ sys.stderr.write(f"bboxkit: warning: {w.message}\n")
303
+ sys.stderr.write(f"bboxkit: error: {exc}\n")
304
+ return 2
305
+ for w in caught:
306
+ sys.stderr.write(f"bboxkit: warning: {w.message}\n")
307
+ return 0
308
+
309
+
310
+ if __name__ == "__main__": # pragma: no cover
311
+ raise SystemExit(main())
bboxkit/crs.py ADDED
@@ -0,0 +1,380 @@
1
+ """Coordinate reference systems: names, Web Mercator and UTM, optional pyproj.
2
+
3
+ Built in (no dependencies):
4
+
5
+ * geographic WGS 84 (``EPSG:4326``, also written ``OGC:CRS84``, ``WGS84``),
6
+ * Web Mercator (``EPSG:3857`` and its historical aliases),
7
+ * UTM on WGS 84 (``EPSG:32601``-``32660`` north, ``EPSG:32701``-``32760`` south)
8
+ using the 6th-order Krueger series (nanometre-level accuracy within a zone).
9
+
10
+ Any other CRS is handed to :mod:`pyproj` when it is installed
11
+ (``pip install bboxkit[pyproj]``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import math
18
+ import re
19
+ from typing import Any, Callable, List, Optional, Tuple, Union
20
+
21
+ from ._geo import MERCATOR_MAX_LAT, WGS84_A, WGS84_E, WGS84_N
22
+
23
+ __all__ = [
24
+ "WGS84",
25
+ "WEB_MERCATOR",
26
+ "normalize_crs",
27
+ "is_geographic",
28
+ "is_builtin",
29
+ "utm_zone",
30
+ "utm_crs",
31
+ "utm_zone_of_crs",
32
+ "lonlat_to_mercator",
33
+ "mercator_to_lonlat",
34
+ "lonlat_to_utm",
35
+ "utm_to_lonlat",
36
+ "transform_point",
37
+ ]
38
+
39
+ WGS84 = "EPSG:4326"
40
+ WEB_MERCATOR = "EPSG:3857"
41
+
42
+ CRSLike = Union[str, int]
43
+
44
+ _ALIASES = {
45
+ "WGS84": WGS84,
46
+ "WGS 84": WGS84,
47
+ "CRS84": WGS84,
48
+ "OGC:CRS84": WGS84,
49
+ "EPSG:4326": WGS84,
50
+ "EPSG:3857": WEB_MERCATOR,
51
+ "EPSG:900913": WEB_MERCATOR,
52
+ "EPSG:3785": WEB_MERCATOR,
53
+ "EPSG:102100": WEB_MERCATOR,
54
+ "EPSG:102113": WEB_MERCATOR,
55
+ "ESRI:102100": WEB_MERCATOR,
56
+ "ESRI:102113": WEB_MERCATOR,
57
+ }
58
+
59
+ # Geographic (lon/lat degree) CRSs recognised without pyproj. Only EPSG:4326
60
+ # can be transformed without pyproj; the others are known to use degrees, so
61
+ # bounding boxes in them get latitude checks and antimeridian semantics.
62
+ _GEOGRAPHIC = {
63
+ WGS84,
64
+ "EPSG:4258", # ETRS89
65
+ "EPSG:4269", # NAD83
66
+ "EPSG:4283", # GDA94
67
+ "EPSG:7844", # GDA2020
68
+ "EPSG:4167", # NZGD2000
69
+ "EPSG:4490", # CGCS2000
70
+ "EPSG:4612", # JGD2000
71
+ "EPSG:6668", # JGD2011
72
+ "EPSG:4674", # SIRGAS 2000
73
+ "EPSG:4019", # GRS 1980 unknown datum
74
+ }
75
+
76
+ _URN = re.compile(r"^urn:ogc:def:crs:(EPSG|OGC):[^:]*:(\w+)$", re.IGNORECASE)
77
+ _HTTP = re.compile(r"^https?://www\.opengis\.net/def/crs/(EPSG|OGC)/[^/]+/(\w+)$", re.IGNORECASE)
78
+ _AUTH = re.compile(r"^(EPSG|ESRI|OGC)\s*:{1,2}\s*(\w+)$", re.IGNORECASE)
79
+
80
+
81
+ def normalize_crs(crs: CRSLike) -> str:
82
+ """Return a canonical ``AUTHORITY:CODE`` string for common CRS spellings.
83
+
84
+ ``4326``, ``"epsg:4326"``, ``"urn:ogc:def:crs:EPSG::4326"``,
85
+ ``"http://www.opengis.net/def/crs/OGC/1.3/CRS84"`` and ``"WGS84"`` all map to
86
+ ``"EPSG:4326"`` (bounding boxes in this package are always x/lon first, so
87
+ CRS84 and EPSG:4326 are the same thing here). Unknown strings such as
88
+ PROJ or WKT definitions are returned stripped but otherwise unchanged.
89
+ """
90
+ if isinstance(crs, bool):
91
+ raise TypeError("crs must be a string or an integer EPSG code")
92
+ if isinstance(crs, int):
93
+ text = f"EPSG:{crs}"
94
+ elif isinstance(crs, str):
95
+ text = crs.strip()
96
+ if not text:
97
+ raise ValueError("crs must not be empty")
98
+ else:
99
+ raise TypeError("crs must be a string or an integer EPSG code")
100
+ for pattern in (_URN, _HTTP, _AUTH):
101
+ m = pattern.match(text)
102
+ if m:
103
+ text = f"{m.group(1).upper()}:{m.group(2).upper()}"
104
+ break
105
+ else:
106
+ if text.isdigit():
107
+ text = f"EPSG:{text}"
108
+ return _ALIASES.get(text.upper(), _ALIASES.get(text, text))
109
+
110
+
111
+ @functools.lru_cache(maxsize=256)
112
+ def _pyproj_is_geographic(crs: str) -> bool:
113
+ try:
114
+ from pyproj import CRS
115
+ except ImportError:
116
+ return False
117
+ try:
118
+ return bool(CRS.from_user_input(crs).is_geographic)
119
+ except Exception: # noqa: BLE001 - any parse failure means "not geographic"
120
+ return False
121
+
122
+
123
+ def is_geographic(crs: CRSLike) -> bool:
124
+ """True if coordinates in ``crs`` are longitude/latitude degrees."""
125
+ c = normalize_crs(crs)
126
+ if c in _GEOGRAPHIC:
127
+ return True
128
+ if c == WEB_MERCATOR or utm_zone_of_crs(c) is not None:
129
+ return False
130
+ return _pyproj_is_geographic(c)
131
+
132
+
133
+ def is_builtin(crs: CRSLike) -> bool:
134
+ """True if ``crs`` can be transformed without pyproj."""
135
+ c = normalize_crs(crs)
136
+ return c in (WGS84, WEB_MERCATOR) or utm_zone_of_crs(c) is not None
137
+
138
+
139
+ # --------------------------------------------------------------------------
140
+ # UTM zones
141
+ # --------------------------------------------------------------------------
142
+
143
+
144
+ def utm_zone(lon: float, lat: float) -> int:
145
+ """UTM zone number for a point, with the Norway and Svalbard exceptions."""
146
+ if not -180.0 <= lon <= 180.0:
147
+ lon = (lon + 180.0) % 360.0 - 180.0
148
+ zone = int((lon + 180.0) // 6.0) % 60 + 1
149
+ if 56.0 <= lat < 64.0 and 3.0 <= lon < 12.0:
150
+ return 32
151
+ if 72.0 <= lat <= 84.0 and 0.0 <= lon < 42.0:
152
+ if lon < 9.0:
153
+ return 31
154
+ if lon < 21.0:
155
+ return 33
156
+ if lon < 33.0:
157
+ return 35
158
+ return 37
159
+ return zone
160
+
161
+
162
+ def utm_crs(zone: int, south: bool = False) -> str:
163
+ """EPSG code string of a WGS 84 / UTM zone."""
164
+ if not 1 <= zone <= 60:
165
+ raise ValueError(f"UTM zone must be 1..60, got {zone}")
166
+ return f"EPSG:{32700 + zone if south else 32600 + zone}"
167
+
168
+
169
+ def utm_zone_of_crs(crs: str) -> Optional[Tuple[int, bool]]:
170
+ """``(zone, south)`` if ``crs`` is a WGS 84 or ETRS89 UTM CRS, else None."""
171
+ m = re.match(r"^EPSG:(\d+)$", crs)
172
+ if not m:
173
+ return None
174
+ code = int(m.group(1))
175
+ if 32601 <= code <= 32660:
176
+ return (code - 32600, False)
177
+ if 32701 <= code <= 32760:
178
+ return (code - 32700, True)
179
+ if 25828 <= code <= 25838: # ETRS89 / UTM 28N-38N, identical to WGS 84 at this level
180
+ return (code - 25800, False)
181
+ return None
182
+
183
+
184
+ # --------------------------------------------------------------------------
185
+ # Web Mercator
186
+ # --------------------------------------------------------------------------
187
+
188
+
189
+ def lonlat_to_mercator(lon: float, lat: float) -> Tuple[float, float]:
190
+ """Project to EPSG:3857 metres. Latitude is clamped to +-85.0511 degrees.
191
+
192
+ Longitudes are not wrapped, so values beyond +-180 give x beyond the
193
+ usual +-20037508.34 m, which keeps antimeridian-crossing boxes contiguous.
194
+ """
195
+ lat = max(-MERCATOR_MAX_LAT, min(MERCATOR_MAX_LAT, lat))
196
+ x = WGS84_A * math.radians(lon)
197
+ y = WGS84_A * math.log(math.tan(math.pi / 4.0 + math.radians(lat) / 2.0))
198
+ return (x, y)
199
+
200
+
201
+ def mercator_to_lonlat(x: float, y: float) -> Tuple[float, float]:
202
+ """Inverse of :func:`lonlat_to_mercator` (longitude is not wrapped)."""
203
+ lon = math.degrees(x / WGS84_A)
204
+ lat = math.degrees(2.0 * math.atan(math.exp(y / WGS84_A)) - math.pi / 2.0)
205
+ return (lon, lat)
206
+
207
+
208
+ # --------------------------------------------------------------------------
209
+ # Transverse Mercator (Krueger series, Karney 2011) for UTM
210
+ # --------------------------------------------------------------------------
211
+
212
+ _K0 = 0.9996
213
+ _n = WGS84_N
214
+ _A = WGS84_A / (1.0 + _n) * (1.0 + _n**2 / 4.0 + _n**4 / 64.0 + _n**6 / 256.0)
215
+ _ALPHA = (
216
+ _n / 2 - 2 * _n**2 / 3 + 5 * _n**3 / 16 + 41 * _n**4 / 180 - 127 * _n**5 / 288
217
+ + 7891 * _n**6 / 37800,
218
+ 13 * _n**2 / 48 - 3 * _n**3 / 5 + 557 * _n**4 / 1440 + 281 * _n**5 / 630
219
+ - 1983433 * _n**6 / 1935360,
220
+ 61 * _n**3 / 240 - 103 * _n**4 / 140 + 15061 * _n**5 / 26880 + 167603 * _n**6 / 181440,
221
+ 49561 * _n**4 / 161280 - 179 * _n**5 / 168 + 6601661 * _n**6 / 7257600,
222
+ 34729 * _n**5 / 80640 - 3418889 * _n**6 / 1995840,
223
+ 212378941 * _n**6 / 319334400,
224
+ ) # fmt: skip
225
+ _BETA = (
226
+ _n / 2 - 2 * _n**2 / 3 + 37 * _n**3 / 96 - _n**4 / 360 - 81 * _n**5 / 512
227
+ + 96199 * _n**6 / 604800,
228
+ _n**2 / 48 + _n**3 / 15 - 437 * _n**4 / 1440 + 46 * _n**5 / 105 - 1118711 * _n**6 / 3870720,
229
+ 17 * _n**3 / 480 - 37 * _n**4 / 840 - 209 * _n**5 / 4480 + 5569 * _n**6 / 90720,
230
+ 4397 * _n**4 / 161280 - 11 * _n**5 / 504 - 830251 * _n**6 / 7257600,
231
+ 4583 * _n**5 / 161280 - 108847 * _n**6 / 3991680,
232
+ 20648693 * _n**6 / 638668800,
233
+ ) # fmt: skip
234
+
235
+
236
+ def _tm_forward(dlon: float, lat: float) -> Tuple[float, float]:
237
+ """Transverse Mercator x, y (unscaled by k0) for a longitude offset."""
238
+ phi = math.radians(lat)
239
+ lam = math.radians(dlon)
240
+ if abs(lat) >= 90.0:
241
+ tau_p = math.copysign(math.inf, lat)
242
+ else:
243
+ tau = math.tan(phi)
244
+ sigma = math.sinh(WGS84_E * math.atanh(WGS84_E * tau / math.sqrt(1.0 + tau * tau)))
245
+ tau_p = tau * math.sqrt(1.0 + sigma * sigma) - sigma * math.sqrt(1.0 + tau * tau)
246
+ cos_lam = math.cos(lam)
247
+ if math.isinf(tau_p):
248
+ xi_p = math.copysign(math.pi / 2.0, tau_p)
249
+ eta_p = 0.0
250
+ else:
251
+ xi_p = math.atan2(tau_p, cos_lam)
252
+ eta_p = math.asinh(math.sin(lam) / math.sqrt(tau_p * tau_p + cos_lam * cos_lam))
253
+ xi = xi_p
254
+ eta = eta_p
255
+ for j, a in enumerate(_ALPHA, start=1):
256
+ xi += a * math.sin(2 * j * xi_p) * math.cosh(2 * j * eta_p)
257
+ eta += a * math.cos(2 * j * xi_p) * math.sinh(2 * j * eta_p)
258
+ return (_A * eta, _A * xi)
259
+
260
+
261
+ def _tm_inverse(x: float, y: float) -> Tuple[float, float]:
262
+ """Inverse of :func:`_tm_forward`: returns (longitude offset, latitude)."""
263
+ eta = x / _A
264
+ xi = y / _A
265
+ xi_p = xi
266
+ eta_p = eta
267
+ for j, b in enumerate(_BETA, start=1):
268
+ xi_p -= b * math.sin(2 * j * xi) * math.cosh(2 * j * eta)
269
+ eta_p -= b * math.cos(2 * j * xi) * math.sinh(2 * j * eta)
270
+ sinh_eta = math.sinh(eta_p)
271
+ sin_xi = math.sin(xi_p)
272
+ cos_xi = math.cos(xi_p)
273
+ denom = math.sqrt(sinh_eta * sinh_eta + cos_xi * cos_xi)
274
+ if denom == 0.0:
275
+ return (0.0, math.copysign(90.0, sin_xi))
276
+ tau_p = sin_xi / denom
277
+ tau = tau_p
278
+ e2 = WGS84_E * WGS84_E
279
+ for _ in range(20):
280
+ sigma = math.sinh(WGS84_E * math.atanh(WGS84_E * tau / math.sqrt(1.0 + tau * tau)))
281
+ tau_i = tau * math.sqrt(1.0 + sigma * sigma) - sigma * math.sqrt(1.0 + tau * tau)
282
+ d_tau = (
283
+ (tau_p - tau_i)
284
+ / math.sqrt(1.0 + tau_i * tau_i)
285
+ * (1.0 + (1.0 - e2) * tau * tau)
286
+ / ((1.0 - e2) * math.sqrt(1.0 + tau * tau))
287
+ )
288
+ tau += d_tau
289
+ if abs(d_tau) < 1e-14:
290
+ break
291
+ lat = math.degrees(math.atan(tau))
292
+ dlon = math.degrees(math.atan2(sinh_eta, cos_xi))
293
+ return (dlon, lat)
294
+
295
+
296
+ def _central_meridian(zone: int) -> float:
297
+ return zone * 6.0 - 183.0
298
+
299
+
300
+ def lonlat_to_utm(lon: float, lat: float, zone: int, south: bool) -> Tuple[float, float]:
301
+ """WGS 84 lon/lat to UTM easting/northing in the given zone."""
302
+ dlon = (lon - _central_meridian(zone) + 180.0) % 360.0 - 180.0
303
+ x, y = _tm_forward(dlon, lat)
304
+ return (500000.0 + _K0 * x, _K0 * y + (10000000.0 if south else 0.0))
305
+
306
+
307
+ def utm_to_lonlat(easting: float, northing: float, zone: int, south: bool) -> Tuple[float, float]:
308
+ """UTM easting/northing to lon/lat. Longitude is CM + offset (not wrapped)."""
309
+ x = (easting - 500000.0) / _K0
310
+ y = (northing - (10000000.0 if south else 0.0)) / _K0
311
+ dlon, lat = _tm_inverse(x, y)
312
+ return (_central_meridian(zone) + dlon, lat)
313
+
314
+
315
+ # --------------------------------------------------------------------------
316
+ # Point transforms between built-in CRSs
317
+ # --------------------------------------------------------------------------
318
+
319
+ Point = Tuple[float, float]
320
+
321
+
322
+ def _to_lonlat_fn(crs: str) -> Callable[[float, float], Point]:
323
+ if crs == WGS84:
324
+ return lambda x, y: (x, y)
325
+ if crs == WEB_MERCATOR:
326
+ return mercator_to_lonlat
327
+ utm = utm_zone_of_crs(crs)
328
+ if utm is not None:
329
+ zone, south = utm
330
+ return lambda x, y: utm_to_lonlat(x, y, zone, south)
331
+ raise ValueError(f"{crs} is not a built-in CRS")
332
+
333
+
334
+ def _from_lonlat_fn(crs: str) -> Callable[[float, float], Point]:
335
+ if crs == WGS84:
336
+ return lambda x, y: (x, y)
337
+ if crs == WEB_MERCATOR:
338
+ return lonlat_to_mercator
339
+ utm = utm_zone_of_crs(crs)
340
+ if utm is not None:
341
+ zone, south = utm
342
+ return lambda x, y: lonlat_to_utm(x, y, zone, south)
343
+ raise ValueError(f"{crs} is not a built-in CRS")
344
+
345
+
346
+ def transform_point(x: float, y: float, src: CRSLike, dst: CRSLike) -> Point:
347
+ """Transform one point between CRSs (built-in, else pyproj)."""
348
+ s, d = normalize_crs(src), normalize_crs(dst)
349
+ if s == d:
350
+ return (x, y)
351
+ if is_builtin(s) and is_builtin(d):
352
+ lon, lat = _to_lonlat_fn(s)(x, y)
353
+ return _from_lonlat_fn(d)(lon, lat)
354
+ t = _pyproj_transformer(s, d)
355
+ tx, ty = t.transform(x, y)
356
+ return (float(tx), float(ty))
357
+
358
+
359
+ def transform_points(points: List[Point], src: str, dst: str) -> List[Point]:
360
+ """Transform many points between two built-in CRSs via lon/lat."""
361
+ to_ll = _to_lonlat_fn(src)
362
+ from_ll = _from_lonlat_fn(dst)
363
+ return [from_ll(*to_ll(x, y)) for x, y in points]
364
+
365
+
366
+ def require_pyproj() -> Any:
367
+ """Import pyproj or raise an ImportError with installation advice."""
368
+ try:
369
+ import pyproj
370
+ except ImportError as exc: # pragma: no cover - exercised only without pyproj
371
+ raise ImportError(
372
+ "this CRS needs pyproj; install it with: pip install bboxkit[pyproj]"
373
+ ) from exc
374
+ return pyproj
375
+
376
+
377
+ @functools.lru_cache(maxsize=64)
378
+ def _pyproj_transformer(src: str, dst: str) -> Any:
379
+ pyproj = require_pyproj()
380
+ return pyproj.Transformer.from_crs(src, dst, always_xy=True)