Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for geoJSON #14

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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).
2 changes: 1 addition & 1 deletion src/maidenhead/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
21 changes: 17 additions & 4 deletions src/maidenhead/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand All @@ -39,18 +46,24 @@ 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",
help="output lat lon of the center of provided maidenhead grid square "
"(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:
loc = args.loc[0]
else:
loc = args.loc

main(loc, args.precision, args.url, args.center)
main(loc, args.precision, args.url, args.center, args.geojson, args.nosquare)
5 changes: 4 additions & 1 deletion src/maidenhead/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
2 changes: 1 addition & 1 deletion src/maidenhead/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
131 changes: 91 additions & 40 deletions src/maidenhead/to_location.py
Original file line number Diff line number Diff line change
@@ -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]:
Expand All @@ -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
Expand All @@ -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
50 changes: 28 additions & 22 deletions src/maidenhead/to_maiden.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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