Repository: space-physics/maidenhead

Issue:
Erroneous Maidenhead locator for longitudes > 180˚
Consider the following US location 37°N 80°W:

    import maidenhead as mh
    mh.to_maiden(37, -80)
    'FM07aa'

Which for scientific applications can also be expressed in solely positive degrees as 37˚N 280°E:

    mh.to_maiden(37, 280)
    'XM07aa'

Thank you in advance for fixing this error.

Conversely, an option for `to_location` to output solely positive longitudes would also be well appreciated.

Hints from issue comments:

Solution patch:
diff --git a/README.md b/README.md
index 211c92f..297efbd 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
@@ -81,3 +83,5 @@ The `--center` option outputs lat lon of the center of provided maidenhead grid
 
 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/__main__.py b/src/maidenhead/__main__.py
index 3c6a1ed..b69f3fd 100644
--- a/src/maidenhead/__main__.py
+++ b/src/maidenhead/__main__.py
@@ -7,7 +7,7 @@ import maidenhead
 
 def main(
     loc: str | tuple[float, float],
-    precision: int = 3,
+    precision: int = 6,
     url: bool = False,
     center: bool = False,
 ) -> str | tuple[float, float]:
@@ -15,7 +15,7 @@ def main(
     if isinstance(loc, str):  # maidenhead
         maiden = copy(loc)
         loc = maidenhead.to_location(loc, center)
-        print(f"{loc[0]:.4f} {loc[1]:.4f}")
+        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]))
diff --git a/src/maidenhead/to_location.py b/src/maidenhead/to_location.py
index 2ac66cc..6d0465f 100644
--- a/src/maidenhead/to_location.py
+++ b/src/maidenhead/to_location.py
@@ -1,4 +1,5 @@
 from __future__ import annotations
+import functools
 
 
 def to_location(maiden: str, center: bool = False) -> tuple[float, float]:
@@ -9,7 +10,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
@@ -23,43 +24,32 @@ def to_location(maiden: str, center: bool = False) -> tuple[float, float]:
     """
 
     maiden = maiden.strip().upper()
-
     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)
-    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
+    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 if center else 0
+    center_offset_lon = 10 if center else 0
+
+    lat = (10 * (lat_nom - 9 * lat_den) + center_offset_lat) / lat_den
+    lon = (20 * (lon_nom - 9 * lon_den) + center_offset_lon) / lon_den
+
+    return (lat, lon)
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


Test patch:
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 @@ import pytest
 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 @@ class Loc:
 
 
 @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])