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/formats.py ADDED
@@ -0,0 +1,86 @@
1
+ """Format a :class:`~bboxkit.BBox` by name."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Callable, Dict
7
+
8
+ from .bbox import BBox
9
+
10
+ __all__ = ["FORMATS", "FORMAT_ALIASES", "format_bbox"]
11
+
12
+
13
+ def _json(value: Any) -> str:
14
+ return json.dumps(value, separators=(",", ":"))
15
+
16
+
17
+ _Formatter = Callable[..., str]
18
+
19
+ FORMATS: Dict[str, str] = {
20
+ "wsen": "west,south,east,north (GeoJSON, Shapely, OSM API, STAC)",
21
+ "swne": "south,west,north,east (lat-first)",
22
+ "bboxfinder": "bboxfinder.com link",
23
+ "overpass": "Overpass QL (s,w,n,e) filter",
24
+ "osm": "openstreetmap.org #map=zoom/lat/lon link that fits the box",
25
+ "wkt": "WKT POLYGON (MULTIPOLYGON when crossing the antimeridian)",
26
+ "envelope": "CQL/Solr ENVELOPE(minX, maxX, maxY, minY)",
27
+ "postgis": "PostGIS ST_MakeEnvelope(xmin, ymin, xmax, ymax, srid)",
28
+ "geojson": "GeoJSON Polygon/MultiPolygon with bbox member",
29
+ "shapely": "Shapely bounds tuple (minx, miny, maxx, maxy)",
30
+ "gdal-te": "gdalwarp -te xmin ymin xmax ymax",
31
+ "gdal-projwin": "gdal_translate -projwin ulx uly lrx lry",
32
+ "leaflet": "Leaflet [[south, west], [north, east]]",
33
+ "mapbox": "Mapbox/MapLibre [[west, south], [east, north]]",
34
+ "tile": "deepest XYZ tile z/x/y containing the box",
35
+ "quadkey": "Bing quadkey of that tile",
36
+ "geohash": "longest geohash containing the box",
37
+ "pluscode": "longest Plus Code containing the box",
38
+ "mgrs": "finest MGRS square containing the box",
39
+ "json": "JSON object {west, south, east, north, crs}",
40
+ }
41
+ """Format names accepted by :meth:`BBox.format` and ``bboxkit convert --to``."""
42
+
43
+ FORMAT_ALIASES: Dict[str, str] = {
44
+ "bbox": "wsen",
45
+ "csv": "wsen",
46
+ "latlon": "swne",
47
+ "gdal": "gdal-te",
48
+ "te": "gdal-te",
49
+ "projwin": "gdal-projwin",
50
+ "xyz": "tile",
51
+ "plus": "pluscode",
52
+ "olc": "pluscode",
53
+ "bounds": "shapely",
54
+ }
55
+
56
+ _FORMATTERS: Dict[str, _Formatter] = {
57
+ "wsen": lambda b, **kw: b.to_wsen(**kw),
58
+ "swne": lambda b, **kw: b.to_swne(**kw),
59
+ "bboxfinder": lambda b, **kw: b.to_bboxfinder(**kw),
60
+ "overpass": lambda b, **kw: b.to_overpass(**kw),
61
+ "osm": lambda b, **kw: b.to_osm_url(**kw),
62
+ "wkt": lambda b, **kw: b.to_wkt(**kw),
63
+ "envelope": lambda b, **kw: b.to_wkt_envelope(**kw),
64
+ "postgis": lambda b, **kw: b.to_postgis(**kw),
65
+ "geojson": lambda b, **kw: _json(b.to_geojson(**kw)),
66
+ "shapely": lambda b: _json(list(b.to_shapely_bounds())),
67
+ "gdal-te": lambda b, **kw: b.to_gdal_te(**kw),
68
+ "gdal-projwin": lambda b, **kw: b.to_gdal_projwin(**kw),
69
+ "leaflet": lambda b: _json(b.to_leaflet()),
70
+ "mapbox": lambda b: _json(b.to_mapbox()),
71
+ "tile": lambda b, **kw: str(b.to_tile(**kw)),
72
+ "quadkey": lambda b, **kw: b.to_quadkey(**kw),
73
+ "geohash": lambda b, **kw: b.to_geohash(**kw),
74
+ "pluscode": lambda b: b.to_pluscode(),
75
+ "mgrs": lambda b: b.to_mgrs(),
76
+ "json": lambda b: _json(b.to_dict()),
77
+ }
78
+
79
+
80
+ def format_bbox(bbox: BBox, fmt: str, **kwargs: Any) -> str:
81
+ """Render ``bbox`` in the format called ``fmt`` (see :data:`FORMATS`)."""
82
+ key = fmt.lower().replace("_", "-")
83
+ key = FORMAT_ALIASES.get(key, key)
84
+ if key not in _FORMATTERS:
85
+ raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
86
+ return _FORMATTERS[key](bbox, **kwargs)
bboxkit/geohash.py ADDED
@@ -0,0 +1,83 @@
1
+ """Geohash cells (Gustavo Niemeyer's base-32 interleaved lon/lat code)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Tuple
6
+
7
+ __all__ = ["BASE32", "decode_bounds", "encode", "MAX_PRECISION"]
8
+
9
+ BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
10
+ _INDEX = {c: i for i, c in enumerate(BASE32)}
11
+
12
+ #: 12 characters is about 3.7 cm x 1.9 cm, well past float-precision GPS.
13
+ MAX_PRECISION = 12
14
+
15
+
16
+ def decode_bounds(geohash: str) -> Tuple[float, float, float, float]:
17
+ """``(west, south, east, north)`` of a geohash cell."""
18
+ gh = geohash.strip().lower()
19
+ if not gh:
20
+ raise ValueError("empty geohash")
21
+ if len(gh) > 22:
22
+ raise ValueError("geohash is too long")
23
+ lon_lo, lon_hi = -180.0, 180.0
24
+ lat_lo, lat_hi = -90.0, 90.0
25
+ even = True
26
+ for ch in gh:
27
+ if ch not in _INDEX:
28
+ raise ValueError(f"invalid geohash character {ch!r} in {geohash!r}")
29
+ bits = _INDEX[ch]
30
+ for shift in range(4, -1, -1):
31
+ bit = (bits >> shift) & 1
32
+ if even:
33
+ mid = (lon_lo + lon_hi) / 2.0
34
+ if bit:
35
+ lon_lo = mid
36
+ else:
37
+ lon_hi = mid
38
+ else:
39
+ mid = (lat_lo + lat_hi) / 2.0
40
+ if bit:
41
+ lat_lo = mid
42
+ else:
43
+ lat_hi = mid
44
+ even = not even
45
+ return (lon_lo, lat_lo, lon_hi, lat_hi)
46
+
47
+
48
+ def encode(lon: float, lat: float, precision: int = 9) -> str:
49
+ """Geohash of a point with ``precision`` characters."""
50
+ if not 1 <= precision <= 22:
51
+ raise ValueError("precision must be 1..22")
52
+ if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
53
+ raise ValueError(f"point ({lon}, {lat}) is outside lon/lat range")
54
+ lon_lo, lon_hi = -180.0, 180.0
55
+ lat_lo, lat_hi = -90.0, 90.0
56
+ out: List[str] = []
57
+ even = True
58
+ bits = 0
59
+ nbits = 0
60
+ while len(out) < precision:
61
+ if even:
62
+ mid = (lon_lo + lon_hi) / 2.0
63
+ if lon >= mid:
64
+ bits = (bits << 1) | 1
65
+ lon_lo = mid
66
+ else:
67
+ bits <<= 1
68
+ lon_hi = mid
69
+ else:
70
+ mid = (lat_lo + lat_hi) / 2.0
71
+ if lat >= mid:
72
+ bits = (bits << 1) | 1
73
+ lat_lo = mid
74
+ else:
75
+ bits <<= 1
76
+ lat_hi = mid
77
+ even = not even
78
+ nbits += 1
79
+ if nbits == 5:
80
+ out.append(BASE32[bits])
81
+ bits = 0
82
+ nbits = 0
83
+ return "".join(out)
bboxkit/mgrs.py ADDED
@@ -0,0 +1,136 @@
1
+ """MGRS grid squares (UTM part, latitudes 80 S to 84 N) on WGS 84.
2
+
3
+ Polar UPS squares (bands A, B, Y, Z) are not supported.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import re
10
+ from typing import Tuple
11
+
12
+ from ._geo import wrap_lon
13
+ from .crs import lonlat_to_utm, utm_to_lonlat, utm_zone
14
+
15
+ __all__ = ["parse", "square_bounds", "encode", "band_letter"]
16
+
17
+ _BANDS = "CDEFGHJKLMNPQRSTUVWX"
18
+ _COL_SETS = ("ABCDEFGH", "JKLMNPQR", "STUVWXYZ")
19
+ _ROWS = "ABCDEFGHJKLMNPQRSTUV"
20
+ _PATTERN = re.compile(
21
+ r"^\s*(\d{1,2})\s*([C-HJ-NP-X])\s*([A-HJ-NP-Z])([A-HJ-NP-V])\s*(\d*)\s*(\d*)\s*$"
22
+ )
23
+
24
+
25
+ def band_letter(lat: float) -> str:
26
+ """MGRS latitude band letter (C..X) for -80 <= lat <= 84."""
27
+ if not -80.0 <= lat <= 84.0:
28
+ raise ValueError("MGRS/UTM covers latitudes 80 S to 84 N only")
29
+ return _BANDS[min(int((lat + 80.0) // 8.0), len(_BANDS) - 1)]
30
+
31
+
32
+ def _band_lat_range(band: str) -> Tuple[float, float]:
33
+ i = _BANDS.index(band)
34
+ south = -80.0 + 8.0 * i
35
+ return (south, 84.0 if band == "X" else south + 8.0)
36
+
37
+
38
+ def _zone_lon_range(zone: int, band: str) -> Tuple[float, float]:
39
+ west = zone * 6.0 - 186.0
40
+ east = west + 6.0
41
+ if band == "V" and zone == 31:
42
+ east = 3.0
43
+ elif band == "V" and zone == 32:
44
+ west = 3.0
45
+ elif band == "X" and zone in (31, 33, 35, 37):
46
+ west, east = {31: (0.0, 9.0), 33: (9.0, 21.0), 35: (21.0, 33.0), 37: (33.0, 42.0)}[zone]
47
+ elif band == "X" and zone in (32, 34, 36):
48
+ raise ValueError(f"MGRS zone {zone}X does not exist")
49
+ return (west, east)
50
+
51
+
52
+ def parse(text: str) -> Tuple[int, str, float, float, float]:
53
+ """Split an MGRS reference into ``(zone, band, easting, northing, size_m)``."""
54
+ m = _PATTERN.match(text.upper())
55
+ if not m:
56
+ raise ValueError(f"{text!r} is not an MGRS reference")
57
+ zone = int(m.group(1))
58
+ band, col, row = m.group(2), m.group(3), m.group(4)
59
+ digits = m.group(5) + m.group(6)
60
+ if not 1 <= zone <= 60:
61
+ raise ValueError(f"MGRS zone must be 1..60, got {zone}")
62
+ if len(digits) % 2 or len(digits) > 10:
63
+ raise ValueError(f"{text!r}: easting/northing digits must be an even count up to 10")
64
+ col_set = _COL_SETS[(zone - 1) % 3]
65
+ if col not in col_set:
66
+ raise ValueError(f"column letter {col!r} is not used in zone {zone}")
67
+ precision = len(digits) // 2
68
+ size = 10.0 ** (5 - precision)
69
+ e_off = float(digits[:precision]) * size if precision else 0.0
70
+ n_off = float(digits[precision:]) * size if precision else 0.0
71
+ easting = (col_set.index(col) + 1) * 100000.0 + e_off
72
+ row_offset = 5 if zone % 2 == 0 else 0
73
+ row_n = ((_ROWS.index(row) - row_offset) % 20) * 100000.0 + n_off
74
+ # Resolve the 2,000 km northing ambiguity with the latitude band.
75
+ band_south, _ = _band_lat_range(band)
76
+ south = band_south < 0
77
+ lon_w, lon_e = _zone_lon_range(zone, band)
78
+ band_n = min(lonlat_to_utm(lon, band_south, zone, south)[1] for lon in (lon_w, lon_e))
79
+ band_n = min(band_n, lonlat_to_utm(zone * 6.0 - 183.0, band_south, zone, south)[1])
80
+ band_n = math.floor(band_n / 100000.0) * 100000.0
81
+ northing = row_n
82
+ while northing + size <= band_n:
83
+ northing += 2000000.0
84
+ return (zone, band, easting, northing, size)
85
+
86
+
87
+ def square_bounds(text: str, densify: int = 16) -> Tuple[float, float, float, float]:
88
+ """``(west, south, east, north)`` of an MGRS square, clipped to its zone and band."""
89
+ zone, band, easting, northing, size = parse(text)
90
+ south = _band_lat_range(band)[0] < 0
91
+ lons = []
92
+ lats = []
93
+ steps = max(1, densify)
94
+ for i in range(steps + 1):
95
+ t = i / steps
96
+ for e, n in (
97
+ (easting + t * size, northing),
98
+ (easting + t * size, northing + size),
99
+ (easting, northing + t * size),
100
+ (easting + size, northing + t * size),
101
+ ):
102
+ lon, lat = utm_to_lonlat(e, n, zone, south)
103
+ lons.append(lon)
104
+ lats.append(lat)
105
+ zw, ze = _zone_lon_range(zone, band)
106
+ bs, bn = _band_lat_range(band)
107
+ west, east = max(min(lons), zw), min(max(lons), ze)
108
+ s, n = max(min(lats), bs), min(max(lats), bn)
109
+ if west >= east or s >= n:
110
+ raise ValueError(f"MGRS square {text!r} lies outside its zone and band")
111
+ w = wrap_lon(west) if west < -180.0 else west
112
+ e = wrap_lon(east) if east > 180.0 else east
113
+ return (w, s, e, n)
114
+
115
+
116
+ def encode(lon: float, lat: float, precision: int = 5) -> str:
117
+ """MGRS reference of a point; ``precision`` digits per axis (0 = 100 km)."""
118
+ if not 0 <= precision <= 5:
119
+ raise ValueError("MGRS precision must be 0..5")
120
+ band = band_letter(lat)
121
+ zone = utm_zone(lon, lat)
122
+ south = lat < 0
123
+ easting, northing = lonlat_to_utm(lon, lat, zone, south)
124
+ col_set = _COL_SETS[(zone - 1) % 3]
125
+ col_i = int(easting // 100000.0) - 1
126
+ if not 0 <= col_i < len(col_set):
127
+ raise ValueError("point is outside the UTM zone's easting range")
128
+ row_offset = 5 if zone % 2 == 0 else 0
129
+ row_i = (int(northing // 100000.0) + row_offset) % 20
130
+ ref = f"{zone:02d}{band}{col_set[col_i]}{_ROWS[row_i]}"
131
+ if precision:
132
+ size = 10.0 ** (5 - precision)
133
+ e = int((easting % 100000.0) // size)
134
+ n = int((northing % 100000.0) // size)
135
+ ref += f"{e:0{precision}d}{n:0{precision}d}"
136
+ return ref
bboxkit/nominatim.py ADDED
@@ -0,0 +1,113 @@
1
+ """Optional place-name lookup through a Nominatim server.
2
+
3
+ Nothing in bboxkit talks to the network unless you call this module
4
+ explicitly. The public server at nominatim.openstreetmap.org has a usage
5
+ policy (https://operations.osmfoundation.org/policies/nominatim/) that this
6
+ client follows:
7
+
8
+ * at most one request per second (enforced per process),
9
+ * an identifying ``User-Agent`` (set your own, ideally with a contact),
10
+ * results are cached in memory so repeated queries are not re-sent,
11
+ * no bulk geocoding: use your own Nominatim instance for that (``base_url``).
12
+
13
+ Results are (c) OpenStreetMap contributors, ODbL 1.0.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import threading
20
+ import time
21
+ from collections import OrderedDict
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+ from urllib.parse import urlencode
24
+ from urllib.request import Request, urlopen
25
+
26
+ from ._version import __version__
27
+ from .bbox import BBox
28
+
29
+ __all__ = ["NOMINATIM_URL", "DEFAULT_USER_AGENT", "search", "geocode_bbox", "clear_cache"]
30
+
31
+ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
32
+ DEFAULT_USER_AGENT = f"bboxkit/{__version__} (+https://github.com/YusufEminoglu/bboxkit_sdk)"
33
+ MIN_INTERVAL_S = 1.0
34
+ _CACHE_SIZE = 256
35
+
36
+ _lock = threading.Lock()
37
+ _last_request = 0.0
38
+ _cache: OrderedDict[Tuple[Any, ...], List[Dict[str, Any]]] = OrderedDict()
39
+
40
+
41
+ def clear_cache() -> None:
42
+ """Forget cached lookups."""
43
+ with _lock:
44
+ _cache.clear()
45
+
46
+
47
+ def _throttle() -> None:
48
+ global _last_request
49
+ wait = _last_request + MIN_INTERVAL_S - time.monotonic()
50
+ if wait > 0:
51
+ time.sleep(wait)
52
+ _last_request = time.monotonic()
53
+
54
+
55
+ def search(
56
+ query: str,
57
+ *,
58
+ limit: int = 1,
59
+ user_agent: Optional[str] = None,
60
+ email: Optional[str] = None,
61
+ timeout: float = 10.0,
62
+ base_url: str = NOMINATIM_URL,
63
+ accept_language: Optional[str] = None,
64
+ country_codes: Optional[str] = None,
65
+ ) -> List[Dict[str, Any]]:
66
+ """Run a Nominatim free-text search and return the decoded JSON results."""
67
+ q = query.strip()
68
+ if not q:
69
+ raise ValueError("empty query")
70
+ if not 1 <= limit <= 40:
71
+ raise ValueError("limit must be 1..40")
72
+ if timeout <= 0:
73
+ raise ValueError("timeout must be positive")
74
+ params: Dict[str, str] = {"q": q, "format": "jsonv2", "limit": str(limit)}
75
+ if email:
76
+ params["email"] = email
77
+ if accept_language:
78
+ params["accept-language"] = accept_language
79
+ if country_codes:
80
+ params["countrycodes"] = country_codes
81
+ key = (base_url, tuple(sorted(params.items())))
82
+ with _lock:
83
+ if key in _cache:
84
+ _cache.move_to_end(key)
85
+ return list(_cache[key])
86
+ _throttle()
87
+ url = f"{base_url}?{urlencode(params)}"
88
+ req = Request(
89
+ url,
90
+ headers={"User-Agent": user_agent or DEFAULT_USER_AGENT, "Accept": "application/json"},
91
+ )
92
+ with urlopen(req, timeout=timeout) as resp: # noqa: S310 - https URL built above
93
+ data = json.loads(resp.read().decode("utf-8"))
94
+ if not isinstance(data, list):
95
+ raise ValueError("unexpected Nominatim response")
96
+ _cache[key] = data
97
+ while len(_cache) > _CACHE_SIZE:
98
+ _cache.popitem(last=False)
99
+ return list(data)
100
+
101
+
102
+ def geocode_bbox(query: str, **kwargs: Any) -> BBox:
103
+ """Bounding box of the best Nominatim match for a place name.
104
+
105
+ Keyword arguments are passed to :func:`search`. Raises ``LookupError``
106
+ when nothing matches.
107
+ """
108
+ kwargs.setdefault("limit", 1)
109
+ results = search(query, **kwargs)
110
+ if not results:
111
+ raise LookupError(f"no Nominatim result for {query!r}")
112
+ s, n, w, e = (float(v) for v in results[0]["boundingbox"])
113
+ return BBox(w, s, e, n)