diff --git a/README.md b/README.md index 211c92f..3707c30 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Maidenhead provides 4 levels of increasing accuracy 2 | Regional 3 | Metropolis 4 | City + 5 | Street + 6 | 1m precision ```sh pip install maidenhead @@ -53,6 +55,15 @@ takes Maidenhead location string and returns top-left lat, lon of Maidenhead gri The `center=True` option outputs lat lon of the center of provided maidenhead grid square, instead of the default southwest corner. + +Maidenhead locator to [geoJSON](https://geojson.org/) ([RFC 7946](https://tools.ietf.org/html/rfc7946)) + +```python +geo_obj = mh.to_geoJSONObject('AB01cd', center=True, nosquare=False) +geoJSON = json.dumps(geo_obj, indent=2) +``` + + ## Command Line The command line interface takes either decimal degrees for "latitude longitude" or the Maidenhead locator string: @@ -77,7 +88,21 @@ python -m maidenhead 65.0 -148.0 The `--center` option outputs lat lon of the center of provided maidenhead grid square, instead of the default southwest corner. + +```sh +python -m maidenhead --center --geojson EN35ld +``` + +> {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {"QTHLocator_Centerpoint": "EN35ld"}, "geometry": {"type": "Point", "coordinates": [-93.04166666666667, 45.145833333333336]}}, {"type": "Feature", "properties": {"QTHLocator": "EN35ld"}, "geometry": {"type": "Polygon", "coordinates": [[[-93.08333333333333, 45.125], [-93.08333333333333, 45.166666666666664], [-93.0, 45.166666666666664], [-93.0, 45.125], [-93.08333333333333, 45.125]]]}}]} + +The `--center` option enables adding center point of the grid square as Point feature. + +The `--nosquare` option disables adding Polygon feature for the requested grid square + + ## Alternatives Open Location Codes a.k.a Plus Codes are in [Python code by Google](https://github.com/google/open-location-code/tree/master/python). + +Web convertor [Earth Point - Tools for Google Earth](https://www.earthpoint.us/Convert.aspx). diff --git a/src/maidenhead/__init__.py b/src/maidenhead/__init__.py index 8099694..767965d 100644 --- a/src/maidenhead/__init__.py +++ b/src/maidenhead/__init__.py @@ -7,7 +7,7 @@ Beyond 8 characters is not defined for Maidenhead. """ -from .to_location import to_location +from .to_location import to_location, to_location_rect, to_geoJSONObject from .to_maiden import to_maiden diff --git a/src/maidenhead/__main__.py b/src/maidenhead/__main__.py index 3c6a1ed..29f7487 100644 --- a/src/maidenhead/__main__.py +++ b/src/maidenhead/__main__.py @@ -3,19 +3,26 @@ from copy import copy import maidenhead +import json def main( loc: str | tuple[float, float], - precision: int = 3, + precision: int = 6, url: bool = False, center: bool = False, + geojson: bool = False, + nosquare: bool = False ) -> str | tuple[float, float]: if isinstance(loc, str): # maidenhead maiden = copy(loc) - loc = maidenhead.to_location(loc, center) - print(f"{loc[0]:.4f} {loc[1]:.4f}") + if geojson: + obj = maidenhead.to_geoJSONObject(loc, center=center, square=not nosquare) + print(json.dumps(obj, indent=None)) + else: + loc = maidenhead.to_location(loc, center) + print(f"{loc[0]:.7f} {loc[1]:.7f}") elif len(loc) == 2: # lat lon if isinstance(loc[0], str): loc = (float(loc[0]), float(loc[1])) @@ -39,6 +46,7 @@ def main( ) p.add_argument("-p", "--precision", help="maidenhead precision", type=int, default=3) p.add_argument("--url", help="also output Google Maps URL", action="store_true") +p.add_argument("--geojson", help="Output the geoJSON that describes given QTH lcoator", action="store_true") p.add_argument( "-c", "--center", @@ -46,6 +54,11 @@ def main( "(default output: lat lon of it's south-west corner)", action="store_true", ) +p.add_argument( + "--nosquare", + help="Do not create square feature for geoJSON", + action="store_true", +) args = p.parse_args() if len(args.loc) == 1: @@ -53,4 +66,4 @@ def main( else: loc = args.loc -main(loc, args.precision, args.url, args.center) +main(loc, args.precision, args.url, args.center, args.geojson, args.nosquare) diff --git a/src/maidenhead/tests/conftest.py b/src/maidenhead/tests/conftest.py index 6ccf89a..111f008 100644 --- a/src/maidenhead/tests/conftest.py +++ b/src/maidenhead/tests/conftest.py @@ -4,6 +4,8 @@ mcmurdo = (-77.8419, 166.6863) washington_monument = (38.8895, -77.0353) giza_pyramid = (29.9792, 31.1342) +rounding_issue = (37.1, -80.1) +positiveonly = (37.1, 279.9) class Loc: @@ -13,7 +15,8 @@ def __init__(self, latlon, maiden): @pytest.fixture( - params=[(mcmurdo, "RB32id27"), (washington_monument, "FM18lv53"), (giza_pyramid, "KL59nx65")] + params=[(mcmurdo, "RB32id27"), (washington_monument, "FM18lv53"), (giza_pyramid, "KL59nx65"), + (rounding_issue, "EM97wc84"), (positiveonly, "EM97wc84")] ) def location(request): return Loc(*request.param) diff --git a/src/maidenhead/tests/test_all.py b/src/maidenhead/tests/test_all.py index 520e708..e02e6c8 100755 --- a/src/maidenhead/tests/test_all.py +++ b/src/maidenhead/tests/test_all.py @@ -12,7 +12,7 @@ def test_latlon2maiden(location): def test_maiden2latlon(location): lat, lon = maidenhead.to_location(location.maiden) assert lat == approx(location.latlon[0], rel=0.0001) - assert lon == approx(location.latlon[1], rel=0.0001) + assert lon == approx(location.latlon[1] if location.latlon[1] <= 180 else location.latlon[1] - 360, rel=0.0001) @pytest.mark.parametrize("invalid", [None, 1, True, False]) diff --git a/src/maidenhead/to_location.py b/src/maidenhead/to_location.py index 2ac66cc..3df3266 100644 --- a/src/maidenhead/to_location.py +++ b/src/maidenhead/to_location.py @@ -1,4 +1,41 @@ from __future__ import annotations +import functools + + +def to_location_rect(maiden: str) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]: + maiden = maiden.strip().upper() + N = len(maiden) + if N < 2 or N % 2: + raise ValueError("Maidenhead locator requires even number of characters") + + precision = N//2 + + def cvt(x: str) -> int: return ord(x) - ord('A') + + cvts = [cvt if not (i % 2) else lambda x: int(x) for i in range(precision)] + weights = [24 if i % 2 else 10 for i in range(precision - 1)] + [1] + + def convert(maiden: str) -> tuple[int, int]: + val = [c(v) for c, v in zip(cvts, maiden)] + if any(0 > v >= 24 for v in val): + raise ValueError("Locator uses A through X characters") + if val[0] >= 18: + raise ValueError("Locator uses A through R characters for the first pair") + return functools.reduce(lambda ac, v: ((ac[0]+v[0])*v[1], ac[1]*v[1]), list(zip(val, weights)), (0, 1)) + + lat_nom, lat_den = convert(maiden[1::2]) + lon_nom, lon_den = convert(maiden[::2]) + + center_offset_lat = 5 + center_offset_lon = 10 + + lat_1 = (10 * (lat_nom - 9 * lat_den)) / lat_den + lon_1 = (20 * (lon_nom - 9 * lon_den)) / lon_den + lat_c = (10 * (lat_nom - 9 * lat_den) + center_offset_lat) / lat_den + lon_c = (20 * (lon_nom - 9 * lon_den) + center_offset_lon) / lon_den + lat_2 = (10 * (lat_nom - 9 * lat_den) + 2*center_offset_lat) / lat_den + lon_2 = (20 * (lon_nom - 9 * lon_den) + 2*center_offset_lon) / lon_den + return ((lat_1, lon_1), (lat_2, lon_2), (lat_c, lon_c)) def to_location(maiden: str, center: bool = False) -> tuple[float, float]: @@ -9,7 +46,7 @@ def to_location(maiden: str, center: bool = False) -> tuple[float, float]: ---------- maiden : str - Maidenhead grid locator of length 2 to 8 + Maidenhead grid locator center : bool If true, return the center of provided maidenhead grid square, instead of default south-west corner @@ -21,45 +58,59 @@ def to_location(maiden: str, center: bool = False) -> tuple[float, float]: latLon : tuple of float Geographic latitude, longitude """ + bottomleft_point, (_, _), center_point = to_location_rect(maiden) - maiden = maiden.strip().upper() + return center_point if center else bottomleft_point - N = len(maiden) - if not ((8 >= N >= 2) and (N % 2 == 0)): - raise ValueError("Maidenhead locator requires 2-8 characters, even number of characters") - - Oa = ord("A") - lon = -180.0 - lat = -90.0 - # %% first pair - lon += (ord(maiden[0]) - Oa) * 20 - lat += (ord(maiden[1]) - Oa) * 10 - # %% second pair - if N >= 4: - lon += int(maiden[2]) * 2 - lat += int(maiden[3]) * 1 - # %% - if N >= 6: - lon += (ord(maiden[4]) - Oa) * 5.0 / 60 - lat += (ord(maiden[5]) - Oa) * 2.5 / 60 - # %% - if N >= 8: - lon += int(maiden[6]) * 5.0 / 600 - lat += int(maiden[7]) * 2.5 / 600 - - # %% move lat lon to the center (if requested) + +def to_geoJSONObject(maiden: str, center: bool = True, square: bool = True) -> dict: + """ + convert Maidenhead grid to geoJSON object as dictionary, ready to be serialized into JSON string + + Parameters + ---------- + + maiden : str + Maidenhead grid locator + + center : bool + If true, add Point feature for the center of provided maidenhead grid square + + square : bool + If true, add Polygon feature for the rectangle of provided maidenhead grid square + + Returns + ------- + + geo : geoJSON dict object. use json module to serialize it to string + """ + loc1, loc2, locc = to_location_rect(maiden) + center_point = { + "type": "Feature", + "properties": {"QTHLocator_Centerpoint": maiden}, + "geometry": { + "type": "Point", + "coordinates": [locc[1], locc[0]] + } + } + + rect = { + "type": "Feature", + "properties": {"QTHLocator": maiden}, + "geometry": { + "type": "Polygon", + "coordinates": [[(loc1[1], loc1[0]), (loc2[1], loc1[0]), (loc2[1], loc2[0]), (loc1[1], loc2[0]), (loc1[1], loc1[0])]] + } + } + + features: list = [] + geo = { + "type": "FeatureCollection", + "features": features + } if center: - if N == 2: - lon += 20 / 2 - lat += 10 / 2 - elif N == 4: - lon += 2 / 2 - lat += 1.0 / 2 - elif N == 6: - lon += 5.0 / 60 / 2 - lat += 2.5 / 60 / 2 - elif N >= 8: - lon += 5.0 / 600 / 2 - lat += 2.5 / 600 / 2 - - return lat, lon + features.append(center_point) + if square: + features.append(rect) + # geo["features"] = features + return geo diff --git a/src/maidenhead/to_maiden.py b/src/maidenhead/to_maiden.py index 6cb2fc8..6997515 100644 --- a/src/maidenhead/to_maiden.py +++ b/src/maidenhead/to_maiden.py @@ -1,3 +1,8 @@ +import itertools +import operator +import functools + + def to_maiden(lat: float, lon: float = None, precision: int = 3) -> str: """ Returns a maidenhead string for latitude, longitude at specified level. @@ -19,27 +24,28 @@ def to_maiden(lat: float, lon: float = None, precision: int = 3) -> str: Maidenhead grid string of specified precision """ - A = ord("A") - a = divmod(lon + 180, 20) - b = divmod(lat + 90, 10) - maiden = chr(A + int(a[0])) + chr(A + int(b[0])) - lon = a[1] / 2.0 - lat = b[1] - i = 1 - while i < precision: - i += 1 - a = divmod(lon, 1) - b = divmod(lat, 1) - if not (i % 2): - maiden += str(int(a[0])) + str(int(b[0])) - lon = 24 * a[1] - lat = 24 * b[1] - else: - maiden += chr(A + int(a[0])) + chr(A + int(b[0])) - lon = 10 * a[1] - lat = 10 * b[1] - - if len(maiden) >= 6: - maiden = maiden[:4] + maiden[4:6].lower() + maiden[6:] + # The QTH locator encoding can be treated as a mixed radix integer number. + # The floating point values will be converted into integers by applying + # a multiplier that is chosen based on the required level of precision + # in order to retain accuracy. + + # Do the conversion according to radix starting from right most position + # returns a generaror which produce values for each position (from least to most significant) + # I.e in reverse order. + def convert(val, radix): + while radix: + p, q = divmod(val, radix[-1]) + base = ord("a") if len(radix) == 3 else ord("A") + yield str(q) if radix[-1] == 10 else chr(q + base) + val = p + radix = radix[:-1] + + radix = [18] + [24 if i % 2 else 10 for i in range(precision - 1)] + multiplier = functools.reduce(operator.mul, radix) + + int_lat = int((lat + 90) * multiplier + .5) // functools.reduce(operator.mul, radix[:2]) + int_lon = int(((lon + 180) % 360) * (multiplier//2) + .5) // functools.reduce(operator.mul, radix[:2]) + + maiden = "".join(reversed(list(itertools.chain(*zip(convert(int_lat, radix), convert(int_lon, radix)))))) return maiden