Repository: Turbo87/utm

Issue:
`utm.from_latlon` returns bogus zone 61 for `(0, 180)`
Using `utm==0.7.0`:

```python
utm.from_latlon(0, 180)
(166021.4431793313, 0.0, 61, 'N')
```

UTM Zone numbers are in range `[1, 60]`.


Notably, `check_valid_zone` still works:
```python
utm.check_valid_zone(61, 'N')
Traceback (most recent call last):
  File "<REDACTED>/.local/share/JetBrains/Toolbox/apps/pycharm-professional/plugins/python/helpers/pydev/pydevconsole.py", line 364, in runcode
    coro = func()
           ^^^^^^
  File "<input>", line 1, in <module>
  File "<REDACTED>/venv/lib/python3.11/site-packages/utm/conversion.py", line 55, in check_valid_zone
    raise OutOfRangeError('zone number out of range (must be between 1 and 60)')
utm.error.OutOfRangeError: zone number out of range (must be between 1 and 60)
```

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 745e365..5e5dc90 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -28,11 +28,11 @@ jobs:
           - false
 
     name: "Tests (Python v${{ matrix.python-version }}, NumPy: ${{ matrix.numpy }})"
-    runs-on: ubuntu-22.04
+    runs-on: ubuntu-20.04
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python-version }}
 
@@ -50,15 +50,15 @@ jobs:
     name: Release
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v3
 
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@v4
         with:
           python-version: 3.9
 
       - run: python setup.py sdist
 
-      - uses: pypa/gh-action-pypi-publish@v1.8.11
+      - uses: pypa/gh-action-pypi-publish@v1.6.4
         with:
           user: __token__
           password: ${{ secrets.PYPI_TOKEN }}
diff --git a/requirements.txt b/requirements.txt
index 29a5b26..d28e039 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
-pytest==8.0.0
-pytest-cov==4.1.0
\ No newline at end of file
+pytest==4.6.11
+pytest-cov==2.12.1
\ No newline at end of file
diff --git a/utm/conversion.py b/utm/conversion.py
index f2e2fc0..c84c6e7 100644
--- a/utm/conversion.py
+++ b/utm/conversion.py
@@ -307,9 +307,13 @@ def latlon_to_zone_number(latitude, longitude):
         if isinstance(longitude, mathlib.ndarray):
             longitude = longitude.flat[0]
 
+    # Normalize longitude to be in the range [-180, 180)
+    longitude = (longitude % 360 + 540) % 360 - 180
+
+    # Special zone for Norway
     if 56 <= latitude < 64 and 3 <= longitude < 12:
         return 32
-
+    # Special zones for Svalbard
     if 72 <= latitude <= 84 and longitude >= 0:
         if longitude < 9:
             return 31


Test patch:
diff --git a/test/test_utm.py b/test/test_utm.py
index f2aa4a5..6b339f2 100755
--- a/test/test_utm.py
+++ b/test/test_utm.py
@@ -2,6 +2,7 @@ from __future__ import division
 
 import utm as UTM
 
+import functools
 import pytest
 
 try:
@@ -24,12 +25,20 @@ def assert_utm_equal(a, b):
 
 
 def assert_latlon_equal(a, b):
+    def longitude_close(lon1, lon2, rtol=1e-4, atol=1e-4):
+        # Check if longitudes are close after normalization
+        is_close = functools.partial(np.isclose, lon1, rtol=rtol, atol=atol)
+        return is_close(lon2) or is_close(lon2 - 360) or is_close(lon2 + 360)
+
     if use_numpy and isinstance(b[0], np.ndarray):
         assert np.allclose(a[0], b[0], rtol=1e-4, atol=1e-4)
-        assert np.allclose(a[1], b[1], rtol=1e-4, atol=1e-4)
+        if isinstance(a[1], np.ndarray):
+            assert all(longitude_close(lon_a, lon_b) for lon_a, lon_b in zip(a[1].flatten(), b[1].flatten()))
+        else:
+            assert all(longitude_close(a[1], lon_b) for lon_b in b[1].flatten())
     else:
         assert a[0] == pytest.approx(b[0], 4)
-        assert a[1] == pytest.approx(b[1], 4)
+        assert longitude_close(a[1], b[1])
 
 
 known_values = [
@@ -75,12 +84,36 @@ known_values = [
         (377486, 6296562, 30, "V"),
         {"northern": True},
     ),
+    # Bergen, Norway
+    (
+        (60.38952, 5.320675),
+        (297264, 6700454, 32, "V"),
+        {"northern": True},
+    ),
+    # Alkefjellet, Spitsbergen, Svalbard
+    (
+        (79.45574, 18.76338),
+        (576830, 8823320, 33, "X"),
+        {"northern": True},
+    ),
     # Latitude 84
     (
         (84, -5.00601),
         (476594, 9328501, 30, "X"),
         {"northern": True},
     ),
+    # East-most point on the Equator
+    (
+        (0, 180),
+        (166021, 0, 1, "N"),
+        {"northern": True},
+    ),
+    # West-most point on the Equator
+    (
+        (0, -180),
+        (166021, 0, 1, "N"),
+        {"northern": True}
+    ),
 ]
 
 
@@ -272,6 +305,11 @@ def test_to_latlon_range_checks(easting, northing, zone_number, zone_letter):
         (64, 9, 32, "W"),
         (64, 11.999999, 32, "W"),
         (64, 12, 33, "W"),
+        # test edge:
+        (0, 180, 1, "N"),
+        (0, -180, 1, "N"),
+        (84, 180, 1, "X"),
+        (84, -180, 1, "X"),
     ],
 )
 def test_from_latlon_zones(lat, lon, expected_number, expected_letter):