1"""GPS coordinate converter and distance calculator.
2
3Pure offline geodesy. Converts a point between decimal degrees, DMS, DDM, UTM,
4MGRS, Maidenhead and geohash, measures geodesic distance and bearing between
5two points, projects a destination point, and builds a bounding box.
6No network calls, no API keys.
7"""
8
9import math
10import re
11
12from apify import Actor
13
14WGS84_A = 6378137.0
15WGS84_F = 1.0 / 298.257223563
16WGS84_B = WGS84_A * (1.0 - WGS84_F)
17WGS84_E2 = WGS84_F * (2.0 - WGS84_F)
18MEAN_RADIUS_KM = 6371.0088
19
20GEOHASH_BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
21LAT_BANDS = "CDEFGHJKLMNPQRSTUVWX"
22COMPASS_16 = [
23 "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
24 "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
25]
26MGRS_COL_SETS = ["ABCDEFGH", "JKLMNPQR", "STUVWXYZ"]
27MGRS_ROW_SETS = ["ABCDEFGHJKLMNPQRSTUV", "FGHJKLMNPQRSTUVABCDE"]
28
29
30def rnd(value, digits):
31 if value is None:
32 return None
33 return round(value, digits)
34
35
36
37
38def decimal_to_dms(value, is_latitude, seconds_digits=2):
39 hemisphere = ("N" if value >= 0 else "S") if is_latitude else ("E" if value >= 0 else "W")
40 magnitude = abs(value)
41 degrees = int(magnitude)
42 minutes_full = (magnitude - degrees) * 60.0
43 minutes = int(minutes_full)
44 seconds = round((minutes_full - minutes) * 60.0, seconds_digits)
45 if seconds >= 60.0:
46 seconds -= 60.0
47 minutes += 1
48 if minutes >= 60:
49 minutes -= 60
50 degrees += 1
51 width = 2 if is_latitude else 3
52 return '{:0{w}d}°{:02d}\'{:0{sw}.{sd}f}"{}'.format(
53 degrees, minutes, seconds, hemisphere,
54 w=width, sw=seconds_digits + 3, sd=seconds_digits,
55 )
56
57
58def decimal_to_ddm(value, is_latitude, minute_digits=4):
59 hemisphere = ("N" if value >= 0 else "S") if is_latitude else ("E" if value >= 0 else "W")
60 magnitude = abs(value)
61 degrees = int(magnitude)
62 minutes = round((magnitude - degrees) * 60.0, minute_digits)
63 if minutes >= 60.0:
64 minutes -= 60.0
65 degrees += 1
66 width = 2 if is_latitude else 3
67 return "{:0{w}d}°{:0{mw}.{md}f}'{}".format(
68 degrees, minutes, hemisphere, w=width, mw=minute_digits + 3, md=minute_digits,
69 )
70
71
72
73
74DMS_TOKEN = re.compile(
75 r"""([NSEW])?\s*
76 (-?\d+(?:\.\d+)?)\s*(?:[°d:]|\s)\s*
77 (?:(\d+(?:\.\d+)?)\s*(?:['′m:]|\s)\s*)?
78 (?:(\d+(?:\.\d+)?)\s*(?:["″s]?)\s*)?
79 ([NSEW])?""",
80 re.VERBOSE | re.IGNORECASE,
81)
82
83
84def dms_parts_to_decimal(degrees, minutes, seconds, hemisphere):
85 value = abs(degrees) + (minutes or 0.0) / 60.0 + (seconds or 0.0) / 3600.0
86 if degrees < 0:
87 value = -value
88 if hemisphere and hemisphere.upper() in ("S", "W"):
89 value = -abs(value)
90 return value
91
92
93def parse_coordinate_text(text):
94 raw = str(text).strip()
95 if not raw:
96 raise ValueError("empty coordinate string")
97
98 cleaned = raw.replace("–", "-").replace("—", "-")
99
100 plain = re.fullmatch(r"\s*(-?\d+(?:\.\d+)?)\s*[,;/\s]\s*(-?\d+(?:\.\d+)?)\s*", cleaned)
101 if plain:
102 return float(plain.group(1)), float(plain.group(2))
103
104 lettered = re.fullmatch(
105 r"\s*(-?\d+(?:\.\d+)?)\s*°?\s*([NS])\s*[,;/\s]\s*"
106 r"(-?\d+(?:\.\d+)?)\s*°?\s*([EW])\s*",
107 cleaned, re.IGNORECASE,
108 )
109 if lettered:
110 return (
111 dms_parts_to_decimal(float(lettered.group(1)), 0, 0, lettered.group(2)),
112 dms_parts_to_decimal(float(lettered.group(3)), 0, 0, lettered.group(4)),
113 )
114
115 matches = [
116 m for m in DMS_TOKEN.finditer(cleaned)
117 if m.group(2) is not None and m.group(0).strip()
118 ]
119 if len(matches) >= 2:
120 values = []
121 for m in matches[:2]:
122 hemisphere = m.group(1) or m.group(5)
123 values.append(
124 dms_parts_to_decimal(
125 float(m.group(2)),
126 float(m.group(3)) if m.group(3) else 0.0,
127 float(m.group(4)) if m.group(4) else 0.0,
128 hemisphere,
129 )
130 )
131 first = (matches[0].group(1) or matches[0].group(5) or "").upper()
132 if first in ("E", "W"):
133 values.reverse()
134 return values[0], values[1]
135
136 raise ValueError("could not parse coordinate: {}".format(raw))
137
138
139def parse_point(value):
140 """Parse any supported notation. Returns (latitude, longitude, detected format)."""
141 raw = str(value).strip()
142 if not raw:
143 raise ValueError("empty point")
144
145 utm_match = re.fullmatch(
146 r"\s*(\d{1,2})\s*([C-HJ-NP-X]|[NS])\s+(-?\d+(?:\.\d+)?)\s*(?:m?E)?\s*[,\s]\s*"
147 r"(-?\d+(?:\.\d+)?)\s*(?:m?N)?\s*",
148 raw, re.IGNORECASE,
149 )
150 if utm_match:
151 zone = int(utm_match.group(1))
152 letter = utm_match.group(2).upper()
153 northern = (letter >= "N") if letter in LAT_BANDS else (letter == "N")
154 lat, lon = utm_to_latlon(zone, northern, float(utm_match.group(3)), float(utm_match.group(4)))
155 return lat, lon, "utm"
156
157 if re.fullmatch(r"\s*\d{1,2}[C-HJ-NP-X]\s*[A-HJ-NP-Z][A-HJ-NP-V]\s*\d{2,10}\s*", raw, re.IGNORECASE):
158 lat, lon = mgrs_to_latlon(raw)
159 return lat, lon, "mgrs"
160
161 if re.fullmatch(r"[A-Ra-r]{2}\d{2}([A-Xa-x]{2}(\d{2})?)?", raw):
162 lat, lon = maidenhead_to_latlon(raw)
163 return lat, lon, "maidenhead"
164
165 if (
166 re.fullmatch(r"[0-9bcdefghjkmnpqrstuvwxyz]{3,12}", raw.lower())
167 and not re.fullmatch(r"-?\d+(?:\.\d+)?", raw)
168 ):
169 lat, lon = geohash_decode(raw.lower())
170 return lat, lon, "geohash"
171
172 lat, lon = parse_coordinate_text(raw)
173 return lat, lon, "degrees"
174
175
176
177
178def utm_zone_for(latitude, longitude):
179 zone = int((longitude + 180.0) / 6.0) + 1
180 if 56.0 <= latitude < 64.0 and 3.0 <= longitude < 12.0:
181 zone = 32
182 if 72.0 <= latitude < 84.0:
183 if 0.0 <= longitude < 9.0:
184 zone = 31
185 elif 9.0 <= longitude < 21.0:
186 zone = 33
187 elif 21.0 <= longitude < 33.0:
188 zone = 35
189 elif 33.0 <= longitude < 42.0:
190 zone = 37
191 return zone
192
193
194def latitude_band(latitude):
195 if latitude < -80.0 or latitude > 84.0:
196 return ""
197 return LAT_BANDS[min(int((latitude + 80.0) / 8.0), len(LAT_BANDS) - 1)]
198
199
200def latlon_to_utm(latitude, longitude, zone=None):
201 if latitude < -80.0 or latitude > 84.0:
202 raise ValueError("UTM is defined only between 80S and 84N")
203
204 if zone is None:
205 zone = utm_zone_for(latitude, longitude)
206 central_meridian = math.radians((zone - 1) * 6 - 180 + 3)
207
208 lat_rad = math.radians(latitude)
209 lon_rad = math.radians(longitude)
210 k0 = 0.9996
211 e2 = WGS84_E2
212 ep2 = e2 / (1.0 - e2)
213
214 n = WGS84_A / math.sqrt(1.0 - e2 * math.sin(lat_rad) ** 2)
215 t = math.tan(lat_rad) ** 2
216 c = ep2 * math.cos(lat_rad) ** 2
217 a = math.cos(lat_rad) * (lon_rad - central_meridian)
218
219 m = WGS84_A * (
220 (1 - e2 / 4 - 3 * e2 ** 2 / 64 - 5 * e2 ** 3 / 256) * lat_rad
221 - (3 * e2 / 8 + 3 * e2 ** 2 / 32 + 45 * e2 ** 3 / 1024) * math.sin(2 * lat_rad)
222 + (15 * e2 ** 2 / 256 + 45 * e2 ** 3 / 1024) * math.sin(4 * lat_rad)
223 - (35 * e2 ** 3 / 3072) * math.sin(6 * lat_rad)
224 )
225
226 easting = k0 * n * (
227 a
228 + (1 - t + c) * a ** 3 / 6
229 + (5 - 18 * t + t ** 2 + 72 * c - 58 * ep2) * a ** 5 / 120
230 ) + 500000.0
231
232 northing = k0 * (
233 m
234 + n * math.tan(lat_rad) * (
235 a ** 2 / 2
236 + (5 - t + 9 * c + 4 * c ** 2) * a ** 4 / 24
237 + (61 - 58 * t + t ** 2 + 600 * c - 330 * ep2) * a ** 6 / 720
238 )
239 )
240 if latitude < 0:
241 northing += 10000000.0
242
243 return zone, latitude >= 0, easting, northing
244
245
246def utm_to_latlon(zone, northern, easting, northing):
247 k0 = 0.9996
248 e2 = WGS84_E2
249 e1 = (1 - math.sqrt(1 - e2)) / (1 + math.sqrt(1 - e2))
250 ep2 = e2 / (1 - e2)
251
252 x = easting - 500000.0
253 y = northing if northern else northing - 10000000.0
254
255 mu = (y / k0) / (WGS84_A * (1 - e2 / 4 - 3 * e2 ** 2 / 64 - 5 * e2 ** 3 / 256))
256 phi1 = (
257 mu
258 + (3 * e1 / 2 - 27 * e1 ** 3 / 32) * math.sin(2 * mu)
259 + (21 * e1 ** 2 / 16 - 55 * e1 ** 4 / 32) * math.sin(4 * mu)
260 + (151 * e1 ** 3 / 96) * math.sin(6 * mu)
261 + (1097 * e1 ** 4 / 512) * math.sin(8 * mu)
262 )
263
264 c1 = ep2 * math.cos(phi1) ** 2
265 t1 = math.tan(phi1) ** 2
266 n1 = WGS84_A / math.sqrt(1 - e2 * math.sin(phi1) ** 2)
267 r1 = WGS84_A * (1 - e2) / (1 - e2 * math.sin(phi1) ** 2) ** 1.5
268 d = x / (n1 * k0)
269
270 latitude = phi1 - (n1 * math.tan(phi1) / r1) * (
271 d ** 2 / 2
272 - (5 + 3 * t1 + 10 * c1 - 4 * c1 ** 2 - 9 * ep2) * d ** 4 / 24
273 + (61 + 90 * t1 + 298 * c1 + 45 * t1 ** 2 - 252 * ep2 - 3 * c1 ** 2) * d ** 6 / 720
274 )
275 longitude = (
276 d
277 - (1 + 2 * t1 + c1) * d ** 3 / 6
278 + (5 - 2 * c1 + 28 * t1 - 3 * c1 ** 2 + 8 * ep2 + 24 * t1 ** 2) * d ** 5 / 120
279 ) / math.cos(phi1)
280
281 central_meridian = math.radians((zone - 1) * 6 - 180 + 3)
282 return math.degrees(latitude), math.degrees(longitude + central_meridian)
283
284
285
286
287def latlon_to_mgrs(latitude, longitude, precision=5):
288 zone, _, easting, northing = latlon_to_utm(latitude, longitude)
289 band = latitude_band(latitude)
290 if not band:
291 raise ValueError("MGRS is defined only between 80S and 84N")
292
293 col_letter = MGRS_COL_SETS[(zone - 1) % 3][int(easting // 100000) - 1]
294 row_letter = MGRS_ROW_SETS[(zone - 1) % 2][int(northing // 100000) % 20]
295
296 factor = 10 ** (5 - precision)
297 return "{}{}{}{}{:0{p}d}{:0{p}d}".format(
298 zone, band, col_letter, row_letter,
299 int((easting % 100000) // factor), int((northing % 100000) // factor), p=precision,
300 )
301
302
303def mgrs_to_latlon(text):
304 raw = re.sub(r"\s+", "", str(text)).upper()
305 match = re.fullmatch(r"(\d{1,2})([C-HJ-NP-X])([A-HJ-NP-Z])([A-HJ-NP-V])(\d*)", raw)
306 if not match:
307 raise ValueError("invalid MGRS reference: {}".format(text))
308
309 zone = int(match.group(1))
310 band = match.group(2)
311 digits = match.group(5)
312 if len(digits) % 2 != 0:
313 raise ValueError("the MGRS numeric part must have an even number of digits")
314
315 precision = len(digits) // 2
316 if precision:
317 factor = 10 ** (5 - precision)
318 east_part = int(digits[:precision]) * factor
319 north_part = int(digits[precision:]) * factor
320 else:
321 east_part = north_part = 0
322
323 col_index = MGRS_COL_SETS[(zone - 1) % 3].index(match.group(3))
324 easting = (col_index + 1) * 100000 + east_part
325 row_index = MGRS_ROW_SETS[(zone - 1) % 2].index(match.group(4))
326
327 band_min_lat = LAT_BANDS.index(band) * 8 - 80
328 northern = band >= "N"
329 central_lon = (zone - 1) * 6 - 180 + 3
330 floor_lat = max(min(band_min_lat, 84.0), -80.0)
331 candidates = [
332 latlon_to_utm(floor_lat, central_lon)[3],
333 latlon_to_utm(floor_lat, central_lon - 2.9)[3],
334 latlon_to_utm(floor_lat, central_lon + 2.9)[3],
335 ]
336 band_floor = min(candidates)
337
338 northing = row_index * 100000 + north_part
339 while northing < band_floor - 100000:
340 northing += 2000000
341
342 return utm_to_latlon(zone, northern, easting, northing)
343
344
345
346
347def geohash_encode(latitude, longitude, precision=9):
348 lat_range = [-90.0, 90.0]
349 lon_range = [-180.0, 180.0]
350 result = []
351 bits = 0
352 bit_count = 0
353 even = True
354 while len(result) < precision:
355 if even:
356 mid = (lon_range[0] + lon_range[1]) / 2
357 if longitude > mid:
358 bits = (bits << 1) | 1
359 lon_range[0] = mid
360 else:
361 bits <<= 1
362 lon_range[1] = mid
363 else:
364 mid = (lat_range[0] + lat_range[1]) / 2
365 if latitude > mid:
366 bits = (bits << 1) | 1
367 lat_range[0] = mid
368 else:
369 bits <<= 1
370 lat_range[1] = mid
371 even = not even
372 bit_count += 1
373 if bit_count == 5:
374 result.append(GEOHASH_BASE32[bits])
375 bits = 0
376 bit_count = 0
377 return "".join(result)
378
379
380def geohash_decode(text):
381 lat_range = [-90.0, 90.0]
382 lon_range = [-180.0, 180.0]
383 even = True
384 for char in str(text).lower():
385 if char not in GEOHASH_BASE32:
386 raise ValueError("invalid geohash character: {}".format(char))
387 value = GEOHASH_BASE32.index(char)
388 for shift in range(4, -1, -1):
389 bit = (value >> shift) & 1
390 if even:
391 mid = (lon_range[0] + lon_range[1]) / 2
392 if bit:
393 lon_range[0] = mid
394 else:
395 lon_range[1] = mid
396 else:
397 mid = (lat_range[0] + lat_range[1]) / 2
398 if bit:
399 lat_range[0] = mid
400 else:
401 lat_range[1] = mid
402 even = not even
403 return (lat_range[0] + lat_range[1]) / 2, (lon_range[0] + lon_range[1]) / 2
404
405
406
407
408def latlon_to_maidenhead(latitude, longitude, pairs=3):
409 lon = longitude + 180.0
410 lat = latitude + 90.0
411 out = [chr(ord("A") + int(lon // 20)), chr(ord("A") + int(lat // 10))]
412 lon %= 20
413 lat %= 10
414 out += [str(int(lon // 2)), str(int(lat // 1))]
415 lon %= 2
416 lat %= 1
417 if pairs >= 3:
418 out += [chr(ord("a") + int(lon / (2.0 / 24))), chr(ord("a") + int(lat / (1.0 / 24)))]
419 lon %= 2.0 / 24
420 lat %= 1.0 / 24
421 if pairs >= 4:
422 out += [str(int(lon / (2.0 / 240))), str(int(lat / (1.0 / 240)))]
423 return "".join(out)
424
425
426def maidenhead_to_latlon(text):
427 raw = str(text).strip()
428 if len(raw) % 2 != 0 or len(raw) < 4:
429 raise ValueError("invalid Maidenhead locator: {}".format(text))
430
431 lon, lat = -180.0, -90.0
432 lon_size, lat_size = 20.0, 10.0
433 lon += (ord(raw[0].upper()) - ord("A")) * lon_size
434 lat += (ord(raw[1].upper()) - ord("A")) * lat_size
435
436 lon_size /= 10
437 lat_size /= 10
438 lon += int(raw[2]) * lon_size
439 lat += int(raw[3]) * lat_size
440
441 if len(raw) >= 6:
442 lon_size /= 24
443 lat_size /= 24
444 lon += (ord(raw[4].lower()) - ord("a")) * lon_size
445 lat += (ord(raw[5].lower()) - ord("a")) * lat_size
446
447 if len(raw) >= 8:
448 lon_size /= 10
449 lat_size /= 10
450 lon += int(raw[6]) * lon_size
451 lat += int(raw[7]) * lat_size
452
453 return lat + lat_size / 2, lon + lon_size / 2
454
455
456
457
458def haversine_km(lat1, lon1, lat2, lon2):
459 p1, p2 = math.radians(lat1), math.radians(lat2)
460 dp = math.radians(lat2 - lat1)
461 dl = math.radians(lon2 - lon1)
462 a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
463 return 2 * MEAN_RADIUS_KM * math.asin(math.sqrt(a))
464
465
466def vincenty_inverse(lat1, lon1, lat2, lon2):
467 """Ellipsoidal geodesic on WGS84. Returns (metres, initial bearing, final bearing)."""
468 if abs(lat1 - lat2) < 1e-12 and abs(lon1 - lon2) < 1e-12:
469 return 0.0, 0.0, 0.0
470
471 f = WGS84_F
472 L = math.radians(lon2 - lon1)
473 U1 = math.atan((1 - f) * math.tan(math.radians(lat1)))
474 U2 = math.atan((1 - f) * math.tan(math.radians(lat2)))
475 sinU1, cosU1 = math.sin(U1), math.cos(U1)
476 sinU2, cosU2 = math.sin(U2), math.cos(U2)
477
478 lam = L
479 cos2SigmaM = sinSigma = cosSigma = sigma = cosSqAlpha = 0.0
480 converged = False
481 for _ in range(200):
482 sinLam, cosLam = math.sin(lam), math.cos(lam)
483 sinSigma = math.sqrt(
484 (cosU2 * sinLam) ** 2 + (cosU1 * sinU2 - sinU1 * cosU2 * cosLam) ** 2
485 )
486 if sinSigma == 0:
487 return 0.0, 0.0, 0.0
488 cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLam
489 sigma = math.atan2(sinSigma, cosSigma)
490 sinAlpha = cosU1 * cosU2 * sinLam / sinSigma
491 cosSqAlpha = 1 - sinAlpha ** 2
492 cos2SigmaM = cosSigma - 2 * sinU1 * sinU2 / cosSqAlpha if cosSqAlpha != 0 else 0.0
493 C = f / 16 * cosSqAlpha * (4 + f * (4 - 3 * cosSqAlpha))
494 lam_prev = lam
495 lam = L + (1 - C) * f * sinAlpha * (
496 sigma + C * sinSigma * (cos2SigmaM + C * cosSigma * (-1 + 2 * cos2SigmaM ** 2))
497 )
498 if abs(lam - lam_prev) < 1e-12:
499 converged = True
500 break
501
502 if not converged:
503 raise ValueError("the geodesic did not converge, the points are nearly antipodal")
504
505 uSq = cosSqAlpha * (WGS84_A ** 2 - WGS84_B ** 2) / (WGS84_B ** 2)
506 A = 1 + uSq / 16384 * (4096 + uSq * (-768 + uSq * (320 - 175 * uSq)))
507 B = uSq / 1024 * (256 + uSq * (-128 + uSq * (74 - 47 * uSq)))
508 deltaSigma = B * sinSigma * (
509 cos2SigmaM
510 + B / 4 * (
511 cosSigma * (-1 + 2 * cos2SigmaM ** 2)
512 - B / 6 * cos2SigmaM * (-3 + 4 * sinSigma ** 2) * (-3 + 4 * cos2SigmaM ** 2)
513 )
514 )
515 distance = WGS84_B * A * (sigma - deltaSigma)
516
517 sinLam, cosLam = math.sin(lam), math.cos(lam)
518 initial = math.degrees(math.atan2(cosU2 * sinLam, cosU1 * sinU2 - sinU1 * cosU2 * cosLam)) % 360
519 final = math.degrees(math.atan2(cosU1 * sinLam, -sinU1 * cosU2 + cosU1 * sinU2 * cosLam)) % 360
520 return distance, initial, final
521
522
523def destination_point(latitude, longitude, bearing_deg, distance_km):
524 angular = distance_km / MEAN_RADIUS_KM
525 theta = math.radians(bearing_deg)
526 phi1 = math.radians(latitude)
527 lam1 = math.radians(longitude)
528 phi2 = math.asin(
529 math.sin(phi1) * math.cos(angular) + math.cos(phi1) * math.sin(angular) * math.cos(theta)
530 )
531 lam2 = lam1 + math.atan2(
532 math.sin(theta) * math.sin(angular) * math.cos(phi1),
533 math.cos(angular) - math.sin(phi1) * math.sin(phi2),
534 )
535 return math.degrees(phi2), (math.degrees(lam2) + 540) % 360 - 180
536
537
538def midpoint(lat1, lon1, lat2, lon2):
539 phi1, phi2 = math.radians(lat1), math.radians(lat2)
540 lam1 = math.radians(lon1)
541 dl = math.radians(lon2 - lon1)
542 bx = math.cos(phi2) * math.cos(dl)
543 by = math.cos(phi2) * math.sin(dl)
544 phi3 = math.atan2(
545 math.sin(phi1) + math.sin(phi2),
546 math.sqrt((math.cos(phi1) + bx) ** 2 + by ** 2),
547 )
548 lam3 = lam1 + math.atan2(by, math.cos(phi1) + bx)
549 return math.degrees(phi3), (math.degrees(lam3) + 540) % 360 - 180
550
551
552def compass_direction(bearing_deg):
553 return COMPASS_16[int((bearing_deg % 360) / 22.5 + 0.5) % 16]
554
555
556def bounding_box(latitude, longitude, radius_km):
557 lat_delta = math.degrees(radius_km / MEAN_RADIUS_KM)
558 cos_lat = math.cos(math.radians(latitude))
559 lon_delta = 180.0 if abs(cos_lat) < 1e-9 else math.degrees(radius_km / MEAN_RADIUS_KM / cos_lat)
560 return (
561 max(latitude - lat_delta, -90.0),
562 min(latitude + lat_delta, 90.0),
563 max(longitude - lon_delta, -180.0),
564 min(longitude + lon_delta, 180.0),
565 )
566
567
568
569
570def resolve_point(item, point_key, lat_key, lon_key):
571 """Take a point from either a free-form string or a latitude/longitude pair."""
572 text = item.get(point_key)
573 if text not in (None, ""):
574 return parse_point(text)
575
576 lat = item.get(lat_key)
577 lon = item.get(lon_key)
578 if lat in (None, "") or lon in (None, ""):
579 raise ValueError(
580 "provide either {} as text or both {} and {}".format(point_key, lat_key, lon_key)
581 )
582 return float(lat), float(lon), "degrees"
583
584
585def validate(latitude, longitude):
586 if not -90.0 <= latitude <= 90.0:
587 raise ValueError("latitude {} is outside -90 to 90".format(latitude))
588 if not -180.0 <= longitude <= 180.0:
589 raise ValueError("longitude {} is outside -180 to 180".format(longitude))
590
591
592def formats_for(latitude, longitude, geohash_precision, mgrs_precision):
593 """Every supported representation of a single point."""
594 row = {
595 "latitude": rnd(latitude, 8),
596 "longitude": rnd(longitude, 8),
597 "latitudeDMS": decimal_to_dms(latitude, True),
598 "longitudeDMS": decimal_to_dms(longitude, False),
599 "dms": "{} {}".format(decimal_to_dms(latitude, True), decimal_to_dms(longitude, False)),
600 "latitudeDDM": decimal_to_ddm(latitude, True),
601 "longitudeDDM": decimal_to_ddm(longitude, False),
602 "geohash": geohash_encode(latitude, longitude, geohash_precision),
603 "maidenhead": latlon_to_maidenhead(latitude, longitude, 4),
604 "hemisphereNS": "North" if latitude >= 0 else "South",
605 "hemisphereEW": "East" if longitude >= 0 else "West",
606 "googleMapsUrl": "https://www.google.com/maps?q={},{}".format(
607 rnd(latitude, 7), rnd(longitude, 7)
608 ),
609 "openStreetMapUrl": "https://www.openstreetmap.org/?mlat={}&mlon={}#map=15/{}/{}".format(
610 rnd(latitude, 7), rnd(longitude, 7), rnd(latitude, 7), rnd(longitude, 7)
611 ),
612 }
613
614 try:
615 zone, northern, easting, northing = latlon_to_utm(latitude, longitude)
616 band = latitude_band(latitude)
617 row["utmZone"] = zone
618 row["utmBand"] = band
619 row["utmHemisphere"] = "N" if northern else "S"
620 row["utmEasting"] = rnd(easting, 3)
621 row["utmNorthing"] = rnd(northing, 3)
622 row["utm"] = "{}{} {} {}".format(zone, band, round(easting), round(northing))
623 row["mgrs"] = latlon_to_mgrs(latitude, longitude, mgrs_precision)
624 except ValueError as exc:
625 row["utmZone"] = None
626 row["utmBand"] = ""
627 row["utmHemisphere"] = ""
628 row["utmEasting"] = None
629 row["utmNorthing"] = None
630 row["utm"] = ""
631 row["mgrs"] = ""
632 row["note"] = str(exc)
633
634 return row
635
636
637def build_row(item, geohash_precision, mgrs_precision):
638 calc_type = str(item.get("type") or "convert").strip().lower()
639 row = {
640 "type": calc_type,
641 "label": item.get("label") or "",
642 "inputPoint": str(item.get("point") or "") or None,
643 "inputFormat": None,
644 "error": None,
645 }
646
647 latitude, longitude, detected = resolve_point(item, "point", "latitude", "longitude")
648 validate(latitude, longitude)
649 row["inputFormat"] = detected
650 row.update(formats_for(latitude, longitude, geohash_precision, mgrs_precision))
651
652 if calc_type in ("distance", "bearing"):
653 lat2, lon2, detected2 = resolve_point(item, "point2", "latitude2", "longitude2")
654 validate(lat2, lon2)
655 metres, initial, final = vincenty_inverse(latitude, longitude, lat2, lon2)
656 mid_lat, mid_lon = midpoint(latitude, longitude, lat2, lon2)
657 row.update({
658 "inputPoint2": str(item.get("point2") or "") or None,
659 "inputFormat2": detected2,
660 "latitude2": rnd(lat2, 8),
661 "longitude2": rnd(lon2, 8),
662 "latitude2DMS": decimal_to_dms(lat2, True),
663 "longitude2DMS": decimal_to_dms(lon2, False),
664 "distanceMeters": rnd(metres, 3),
665 "distanceKm": rnd(metres / 1000.0, 6),
666 "distanceMiles": rnd(metres / 1609.344, 6),
667 "distanceNauticalMiles": rnd(metres / 1852.0, 6),
668 "distanceFeet": rnd(metres / 0.3048, 3),
669 "greatCircleKm": rnd(haversine_km(latitude, longitude, lat2, lon2), 6),
670 "initialBearing": rnd(initial, 5),
671 "finalBearing": rnd(final, 5),
672 "compassDirection": compass_direction(initial),
673 "midpointLatitude": rnd(mid_lat, 8),
674 "midpointLongitude": rnd(mid_lon, 8),
675 })
676
677 elif calc_type == "destination":
678 bearing = float(item.get("bearing", 0.0))
679 distance_km = float(item.get("distanceKm", 0.0))
680 dest_lat, dest_lon = destination_point(latitude, longitude, bearing, distance_km)
681 row.update({
682 "bearing": rnd(bearing, 5),
683 "distanceKm": rnd(distance_km, 6),
684 "resultLatitude": rnd(dest_lat, 8),
685 "resultLongitude": rnd(dest_lon, 8),
686 "resultDMS": "{} {}".format(
687 decimal_to_dms(dest_lat, True), decimal_to_dms(dest_lon, False)
688 ),
689 "resultGeohash": geohash_encode(dest_lat, dest_lon, geohash_precision),
690 "compassDirection": compass_direction(bearing),
691 })
692
693 elif calc_type in ("bbox", "boundingbox"):
694 radius_km = float(item.get("radiusKm", 1.0))
695 if radius_km <= 0:
696 raise ValueError("radiusKm must be greater than 0")
697 min_lat, max_lat, min_lon, max_lon = bounding_box(latitude, longitude, radius_km)
698 row.update({
699 "radiusKm": rnd(radius_km, 6),
700 "minLatitude": rnd(min_lat, 8),
701 "maxLatitude": rnd(max_lat, 8),
702 "minLongitude": rnd(min_lon, 8),
703 "maxLongitude": rnd(max_lon, 8),
704 "bbox": "{},{},{},{}".format(
705 rnd(min_lon, 7), rnd(min_lat, 7), rnd(max_lon, 7), rnd(max_lat, 7)
706 ),
707 "bboxLatLon": "{},{},{},{}".format(
708 rnd(min_lat, 7), rnd(min_lon, 7), rnd(max_lat, 7), rnd(max_lon, 7)
709 ),
710 })
711
712 elif calc_type != "convert":
713 raise ValueError(
714 "unknown type '{}', use convert, distance, destination or bbox".format(calc_type)
715 )
716
717 return row
718
719
720async def main() -> None:
721 async with Actor:
722 actor_input = await Actor.get_input() or {}
723 calculations = actor_input.get("calculations") or []
724 geohash_precision = int(actor_input.get("geohashPrecision") or 9)
725 mgrs_precision = int(actor_input.get("mgrsPrecision") or 5)
726 geohash_precision = max(1, min(geohash_precision, 12))
727 mgrs_precision = max(1, min(mgrs_precision, 5))
728
729 if not isinstance(calculations, list) or not calculations:
730 raise ValueError("Provide at least one entry in the calculations list.")
731
732 Actor.log.info("Processing %d calculation(s).", len(calculations))
733 pushed = 0
734
735 for index, item in enumerate(calculations, start=1):
736 if not isinstance(item, dict):
737 item = {"point": item}
738 try:
739 row = build_row(item, geohash_precision, mgrs_precision)
740 except Exception as exc:
741 row = {
742 "type": str(item.get("type") or "convert"),
743 "label": item.get("label") or "",
744 "inputPoint": str(item.get("point") or "") or None,
745 "error": str(exc),
746 }
747 Actor.log.warning("Calculation %d failed: %s", index, exc)
748
749 await Actor.push_data(row)
750 pushed += 1
751 try:
752 await Actor.charge("result")
753 except Exception:
754 pass
755
756 Actor.log.info("Done. Pushed %d row(s).", pushed)