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)
```

Hints from issue comments:

Solution patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 745e365..e88bdc3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,18 +17,17 @@ jobs:
       fail-fast: true
       matrix:
         python-version:
+          - "3.12"
+          - "3.11"
+          - "3.10"
           - "3.9"
           - "3.8"
-          - "3.7"
-          - "3.6"
-          - "3.5"
-          - "2.7"
         numpy:
           - true
           - false
 
     name: "Tests (Python v${{ matrix.python-version }}, NumPy: ${{ matrix.numpy }})"
-    runs-on: ubuntu-22.04
+    runs-on: ubuntu-24.04
     steps:
       - uses: actions/checkout@v4
 
@@ -54,11 +53,11 @@ jobs:
 
       - uses: actions/setup-python@v5
         with:
-          python-version: 3.9
+          python-version: "3.12"
 
       - run: python setup.py sdist
 
-      - uses: pypa/gh-action-pypi-publish@v1.8.11
+      - uses: pypa/gh-action-pypi-publish@v1.10.3
         with:
           user: __token__
           password: ${{ secrets.PYPI_TOKEN }}
diff --git a/numpy-requirements.txt b/numpy-requirements.txt
index 9dd707f..8758635 100644
--- a/numpy-requirements.txt
+++ b/numpy-requirements.txt
@@ -1 +1,2 @@
-numpy==1.16.6
\ No newline at end of file
+numpy==1.24.4; python_version < '3.9'
+numpy==1.26.3; python_version >= '3.9'
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 29a5b26..549199a 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==8.3.3
+pytest-cov==5.0.0
\ No newline at end of file
diff --git a/setup.py b/setup.py
index f44cba9..1be6803 100644
--- a/setup.py
+++ b/setup.py
@@ -1,7 +1,10 @@
-from distutils.core import setup
+from setuptools import setup
 
 from utm._version import __version__
 
+from pathlib import Path
+this_directory = Path(__file__).parent
+long_description = (this_directory / "README.rst").read_text()
 
 setup(
     name='utm',
@@ -10,6 +13,8 @@
     author_email='Tobias.Bieniek@gmx.de',
     url='https://github.com/Turbo87/utm',
     description='Bidirectional UTM-WGS84 converter for python',
+    long_description=long_description,
+    long_description_content_type='text/x-rst',
     keywords=['utm', 'wgs84', 'coordinate', 'converter'],
     classifiers=[
         'Programming Language :: Python',
diff --git a/utm/conversion.py b/utm/conversion.py
index f2e2fc0..d3d6a6f 100644
--- a/utm/conversion.py
+++ b/utm/conversion.py
@@ -133,10 +133,7 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
         northern = (zone_letter >= 'N')
 
     x = easting - 500000
-    y = northing
-
-    if not northern:
-        y -= 10000000
+    y = northing if northern else northing - 10000000
 
     m = y / K0
     mu = m / (R * M1)
@@ -307,9 +304,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..58dd711 100755
--- a/test/test_utm.py
+++ b/test/test_utm.py
@@ -2,6 +2,7 @@
 
 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 @@ def assert_latlon_equal(a, b):
         (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):
@@ -356,3 +394,16 @@ def test_force_west():
 
 def test_version():
     assert isinstance(UTM.__version__, str) and "." in UTM.__version__
+
+
+@pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+def test_numpy_args_not_modified():
+    TEST_EASTING = 387358.0
+    TEST_NORTHING = 8145567.0
+    easting = np.array(TEST_EASTING)
+    northing = np.array(TEST_NORTHING)
+    zone = 55
+    letter = "K"
+    UTM.to_latlon(easting, northing, zone, letter)
+    assert easting == TEST_EASTING
+    assert northing == TEST_NORTHING