[Git][debian-gis-team/utm][master] 7 commits: New upstream version 0.8.0

Antonio Valentino (@antonio.valentino) gitlab at salsa.debian.org
Sat Feb 1 10:17:06 GMT 2025



Antonio Valentino pushed to branch master at Debian GIS Project / utm


Commits:
b70a4808 by Antonio Valentino at 2025-02-01T10:55:38+01:00
New upstream version 0.8.0
- - - - -
519987f0 by Antonio Valentino at 2025-02-01T10:55:38+01:00
Update upstream source from tag 'upstream/0.8.0'

Update to upstream version '0.8.0'
with Debian dir 49df393730aa36c88204acbbf6057510dda5d8bf
- - - - -
0fd90629 by Antonio Valentino at 2025-02-01T10:56:19+01:00
New upstream release

- - - - -
6cf85537 by Antonio Valentino at 2025-02-01T11:00:16+01:00
Drop 0001-Do-not-use-distutils.patch

- - - - -
7ba51323 by Antonio Valentino at 2025-02-01T10:09:11+00:00
Add build-dependency on python3-pytest

- - - - -
28cc98a2 by Antonio Valentino at 2025-02-01T10:12:03+00:00
Update dates in d/copyright

- - - - -
168e1cdd by Antonio Valentino at 2025-02-01T10:12:03+00:00
Set distribution to unstable

- - - - -


21 changed files:

- + .github/FUNDING.yml
- .github/workflows/ci.yml
- CHANGELOG.rst
- README.rst
- debian/changelog
- debian/control
- debian/copyright
- − debian/patches/0001-Do-not-use-distutils.patch
- − debian/patches/series
- + numpy-1.x-requirements.txt
- + numpy-2.x-requirements.txt
- + release-requirements.txt
- + renovate.json
- − requirements-numpy.txt
- requirements.txt
- scripts/utm-converter
- setup.py
- test/test_utm.py
- utm/__init__.py
- + utm/_version.py
- utm/conversion.py


Changes:

=====================================
.github/FUNDING.yml
=====================================
@@ -0,0 +1,2 @@
+github: Turbo87
+custom: https://paypal.me/tobiasbieniek


=====================================
.github/workflows/ci.yml
=====================================
@@ -6,6 +6,7 @@ on:
       - master
       - "v*"
     tags:
+      - "v*"
   pull_request:
   schedule:
     - cron: '0 3 * * *' # daily, at 3am
@@ -16,29 +17,35 @@ jobs:
       fail-fast: true
       matrix:
         python-version:
+          - "3.13"
+          - "3.12"
+          - "3.11"
+          - "3.10"
           - "3.9"
           - "3.8"
-          - "3.7"
-          - "3.6"
-          - "3.5"
-          - "2.7"
-        numpy:
-          - true
+        numpy-version:
           - false
+          - "1.x"
+          - "2.x"
+        exclude:
+          - python-version: "3.13"
+            numpy-version: "1.x"
+          - python-version: "3.8"
+            numpy-version: "2.x"
 
-    name: "Tests (Python v${{ matrix.python-version }}, NumPy: ${{ matrix.numpy }})"
-    runs-on: ubuntu-latest
+    name: "Tests (Python v${{ matrix.python-version }}, NumPy: ${{ matrix.numpy-version }})"
+    runs-on: ubuntu-24.04
     steps:
-      - uses: actions/checkout at v2
+      - uses: actions/checkout at v4
 
-      - uses: actions/setup-python at v1
+      - uses: actions/setup-python at v5
         with:
           python-version: ${{ matrix.python-version }}
 
       - run: pip install -r requirements.txt
 
-      - run: pip install -r requirements-numpy.txt
-        if: matrix.numpy == true
+      - run: pip install -r numpy-${{ matrix.numpy-version }}-requirements.txt
+        if: matrix.numpy-version != false
 
       - run: pytest -v --cov=utm --color=yes
 
@@ -49,15 +56,17 @@ jobs:
     name: Release
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout at v2
+      - uses: actions/checkout at v4
 
-      - uses: actions/setup-python at v1
+      - uses: actions/setup-python at v5
         with:
-          python-version: 3.9
+          python-version: "3.13"
+
+      - run: pip install -r release-requirements.txt
 
       - run: python setup.py sdist
 
-      - uses: pypa/gh-action-pypi-publish at v1.4.1
+      - uses: pypa/gh-action-pypi-publish at v1.12.4
         with:
           user: __token__
           password: ${{ secrets.PYPI_TOKEN }}


=====================================
CHANGELOG.rst
=====================================
@@ -1,6 +1,24 @@
 Changelog
 =========
 
+v0.8.0
+------
+
+* Add support for Python 3.10, 3.11, 3.12 and 3.13
+* Drop support for Python 2.7, 3.5, 3.6, 3.7 and 3.8
+* Add version (#62)
+* Convert all tests to pytest (#65)
+* Port to setuptools (#89)
+* Add long description for PyPi (#99)
+* Fix numpy array being modified in place (#86)
+* Fix ``latlon_to_zone_number()`` returning bogus zone 61 for longitude 180 (#110)
+* Fix forcing zones around equator and add ``force_northern`` in ``from_latlon()`` (#124)
+* Improve ``to_latlon()`` accuracy (#120)
+* Update all (test) dependencies, taking into account supported Python versions (e.g. #116, #128)
+* Add ``zone_letter_to_central_latitude()`` as a counterpart to ``zone_number_to_central_longitude()`` (#130)
+* Bring CI script into the 2024 realm
+
+
 v0.7.0
 ------
 
@@ -14,7 +32,7 @@ v0.6.0
 * Drop support for Python 2.6 and 3.3 (#53)
 * Improve documentation (#50)
 * Fix issue near anti-meridian when forcing zones (#47)
-* Improve `to_latlon()` accuracy (#49)
+* Improve ``to_latlon()`` accuracy (#49)
 
 
 v0.5.0


=====================================
README.rst
=====================================
@@ -1,12 +1,6 @@
 utm
 ===
 
-.. image:: https://travis-ci.org/Turbo87/utm.png
-
-.. image:: https://img.shields.io/badge/License-MIT-yellow.svg
-   :target: https://github.com/Turbo87/utm/blob/master/LICENSE
-
-
 Bidirectional UTM-WGS84 converter for python
 
 Usage


=====================================
debian/changelog
=====================================
@@ -1,9 +1,17 @@
-utm (0.7.0-4) UNRELEASED; urgency=medium
+utm (0.8.0-1) unstable; urgency=medium
 
-  * Team upload.
+  [ Bas Couwenberg ]
   * Bump Standards-Version to 4.7.0, no changes.
 
- -- Bas Couwenberg <sebastic at debian.org>  Sun, 28 Jul 2024 20:06:34 +0200
+  [ Antonio Valentino ]
+  * New upstream release.
+  * debian/patches:
+    - Drop 0001-Do-not-use-distutils.patch, applied upstream.
+  * debian/control:
+    - Add buld-dependency on python3-pytest.
+  * Update dates in d/copyright.
+
+ -- Antonio Valentino <antonio.valentino at tiscali.it>  Sat, 01 Feb 2025 10:03:10 +0000
 
 utm (0.7.0-3) unstable; urgency=medium
 


=====================================
debian/control
=====================================
@@ -7,6 +7,7 @@ Build-Depends: debhelper-compat (= 13),
                dh-python,
                dh-sequence-python3,
                python3-all,
+               python3-pytest <!nocheck>,
                python3-setuptools
 Standards-Version: 4.7.0
 Testsuite: autopkgtest-pkg-pybuild


=====================================
debian/copyright
=====================================
@@ -8,7 +8,7 @@ Copyright: 2012-2023, Tobias Bieniek <Tobias.Bieniek at gmx.de>
 License: Expat
 
 Files: debian/*
-Copyright: 2023, Antonio Valentino <antonio.valentino at tiscali.it>
+Copyright: 2023-2025, Antonio Valentino <antonio.valentino at tiscali.it>
 License: Expat
 
 License: Expat


=====================================
debian/patches/0001-Do-not-use-distutils.patch deleted
=====================================
@@ -1,19 +0,0 @@
-From: Antonio Valentino <antonio.valentino at tiscali.it>
-Date: Wed, 9 Aug 2023 16:16:12 +0000
-Subject: Do not use distutils
-
-Forwarded: https://github.com/Turbo87/utm/pull/89
----
- setup.py | 2 +-
- 1 file changed, 1 insertion(+), 1 deletion(-)
-
-diff --git a/setup.py b/setup.py
-index d4b0be5..213c401 100644
---- a/setup.py
-+++ b/setup.py
-@@ -1,4 +1,4 @@
--from distutils.core import setup
-+from setuptools import setup
- 
- setup(
-     name='utm',


=====================================
debian/patches/series deleted
=====================================
@@ -1 +0,0 @@
-0001-Do-not-use-distutils.patch


=====================================
numpy-1.x-requirements.txt
=====================================
@@ -0,0 +1,2 @@
+numpy==1.24.4; python_version < '3.9'
+numpy==1.26.4; python_version >= '3.9' and python_version < '3.13'


=====================================
numpy-2.x-requirements.txt
=====================================
@@ -0,0 +1,3 @@
+numpy==2.0.0; python_version >= '3.9' and python_version < '3.10'
+numpy==2.1.0; python_version >= '3.10' and python_version < '3.13'
+numpy==2.2.0; python_version >= '3.13'


=====================================
release-requirements.txt
=====================================
@@ -0,0 +1 @@
+setuptools >= 75, < 76


=====================================
renovate.json
=====================================
@@ -0,0 +1,7 @@
+{
+  "extends": [
+    "config:base",
+    ":dependencyDashboard",
+    ":semanticCommitsDisabled"
+  ]
+}


=====================================
requirements-numpy.txt deleted
=====================================
@@ -1 +0,0 @@
-numpy==1.16.6
\ No newline at end of file


=====================================
requirements.txt
=====================================
@@ -1,2 +1,3 @@
-pytest==4.6.11
-pytest-cov==2.10.1
\ No newline at end of file
+pytest==8.3.4
+pytest-cov==5.0.0; python_version < '3.9'
+pytest-cov==6.0.0; python_version >= '3.9'


=====================================
scripts/utm-converter
=====================================
@@ -1,5 +1,7 @@
 #!/usr/bin/env python
 
+from __future__ import print_function
+
 import argparse
 import utm
 
@@ -21,13 +23,13 @@ args = parser.parse_args()
 if all(arg in args for arg in ['easting', 'northing', 'zone_number', 'zone_letter']):
     if args.zone_letter == '':
         parser_utm.print_usage()
-        print "utm-converter utm: error: too few arguments"
+        print("utm-converter utm: error: too few arguments")
         exit()
 
     coordinate = utm.to_latlon(args.easting, args.northing,
-                        args.zone_number, args.zone_letter)
+                               args.zone_number, args.zone_letter)
 
 elif all(arg in args for arg in ['latitude', 'longitude']):
     coordinate = utm.from_latlon(args.latitude, args.longitude)
 
-print ','.join([str(component) for component in coordinate])
+print(','.join(str(component) for component in coordinate))


=====================================
setup.py
=====================================
@@ -1,12 +1,20 @@
-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',
-    version='0.7.0',
+    version=__version__,
     author='Tobias Bieniek',
     author_email='Tobias.Bieniek at 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',


=====================================
test/test_utm.py
=====================================
@@ -1,356 +1,510 @@
+from __future__ import division
+
 import utm as UTM
-import unittest
+
+import functools
+import pytest
 
 try:
     import numpy as np
+
     use_numpy = True
 except ImportError:
     use_numpy = False
 
 
-class UTMTestCase(unittest.TestCase):
-    def assert_utm_equal(self, a, b):
-        if use_numpy and isinstance(b[0], np.ndarray):
-            self.assertTrue(np.allclose(a[0], b[0]))
-            self.assertTrue(np.allclose(a[1], b[1]))
-        else:
-            self.assertAlmostEqual(a[0], b[0], 0)
-            self.assertAlmostEqual(a[1], b[1], 0)
-        self.assertEqual(a[2], b[2])
-        self.assertEqual(a[3].upper(), b[3].upper())
-
-    def assert_latlon_equal(self, a, b):
-        if use_numpy and isinstance(b[0], np.ndarray):
-            self.assertTrue(np.allclose(a[0], b[0], rtol=1e-4, atol=1e-4))
-            self.assertTrue(np.allclose(a[1], b[1], rtol=1e-4, atol=1e-4))
-        else:
-            self.assertAlmostEqual(a[0], b[0], 4)
-            self.assertAlmostEqual(a[1], b[1], 4)
+def assert_utm_equal(a, b):
+    if use_numpy and isinstance(b[0], np.ndarray):
+        assert np.allclose(a[0], b[0])
+        assert np.allclose(a[1], b[1])
+    else:
+        assert a[0] == pytest.approx(b[0], abs=1)
+        assert a[1] == pytest.approx(b[1], abs=1)
+    assert a[2] == b[2]
+    assert a[3].upper() == b[3].upper()
 
 
-class KnownValues(UTMTestCase):
-    known_values = [
-        # Aachen, Germany
-        (
-            (50.77535, 6.08389),
-            (294409, 5628898, 32, 'U'),
-            {'northern': True},
-        ),
-        # New York, USA
-        (
-            (40.71435, -74.00597),
-            (583960, 4507523, 18, 'T'),
-            {'northern': True},
-        ),
-        # Wellington, New Zealand
-        (
-            (-41.28646, 174.77624),
-            (313784, 5427057, 60, 'G'),
-            {'northern': False},
-        ),
-        # Capetown, South Africa
-        (
-            (-33.92487, 18.42406),
-            (261878, 6243186, 34, 'H'),
-            {'northern': False},
-        ),
-        # Mendoza, Argentina
-        (
-            (-32.89018, -68.84405),
-            (514586, 6360877, 19, 'h'),
-            {'northern': False},
-        ),
-        # Fairbanks, Alaska, USA
-        (
-            (64.83778, -147.71639),
-            (466013, 7190568, 6, 'W'),
-            {'northern': True},
-        ),
-        # Ben Nevis, Scotland, UK
-        (
-            (56.79680, -5.00601),
-            (377486, 6296562, 30, 'V'),
-            {'northern': True},
-        ),
-        # Latitude 84
+def assert_latlon_equal(a, b):
+    if use_numpy and isinstance(b[0], np.ndarray):
+        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)
+
+        assert np.allclose(a[0], b[0], 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) or
+            a[1] == pytest.approx(b[1] - 360, 4) or
+            a[1] == pytest.approx(b[1] + 360, 4)
+        )
+
+
+known_values = [
+    # Aachen, Germany
+    (
+        (50.77535, 6.08389),
+        (294409, 5628898, 32, "U"),
+        {"northern": True},
+    ),
+    # New York, USA
+    (
+        (40.71435, -74.00597),
+        (583960, 4507523, 18, "T"),
+        {"northern": True},
+    ),
+    # Wellington, New Zealand
+    (
+        (-41.28646, 174.77624),
+        (313784, 5427057, 60, "G"),
+        {"northern": False},
+    ),
+    # Capetown, South Africa
+    (
+        (-33.92487, 18.42406),
+        (261878, 6243186, 34, "H"),
+        {"northern": False},
+    ),
+    # Mendoza, Argentina
+    (
+        (-32.89018, -68.84405),
+        (514586, 6360877, 19, "h"),
+        {"northern": False},
+    ),
+    # Fairbanks, Alaska, USA
+    (
+        (64.83778, -147.71639),
+        (466013, 7190568, 6, "W"),
+        {"northern": True},
+    ),
+    # Ben Nevis, Scotland, UK
+    (
+        (56.79680, -5.00601),
+        (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}
+    ),
+]
+
+
+ at pytest.mark.parametrize("latlon, utm, utm_kw", known_values)
+def test_from_latlon(latlon, utm, utm_kw):
+    """from_latlon should give known result with known input"""
+    result = UTM.from_latlon(*latlon)
+    assert_utm_equal(utm, result)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+ at pytest.mark.parametrize("latlon, utm, utm_kw", known_values)
+def test_from_latlon_numpy(latlon, utm, utm_kw):
+    result = UTM.from_latlon(*[np.array([x]) for x in latlon])
+    assert_utm_equal(utm, result)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+def test_from_latlon_numpy_static():
+    lats = np.array([0.0, 3.0, 6.0])
+    lons = np.array([0.0, 1.0, 3.4])
+    result = UTM.from_latlon(lats, lons)
+    assert_utm_equal(
         (
-            (84, -5.00601),
-            (476594, 9328501, 30, 'X'),
-            {'northern': True},
+            np.array(
+                [166021.44317933032, 277707.83075574087, 544268.12794623]
+            ),
+            np.array([0.0, 331796.29167519242, 663220.7198366751]),
+            31,
+            "N",
         ),
-    ]
-
-    def test_from_latlon(self):
-        '''from_latlon should give known result with known input'''
-        for latlon, utm, _ in self.known_values:
-            result = UTM.from_latlon(*latlon)
-            self.assert_utm_equal(utm, result)
-
-    def test_from_latlon_numpy(self):
-        if not use_numpy:
-            return
-        lats = np.array([0.0, 3.0, 6.0])
-        lons = np.array([0.0, 1.0, 3.4])
-        result = UTM.from_latlon(lats, lons)
-        self.assert_utm_equal((np.array([166021.44317933032,
-                                         277707.83075574087,
-                                         544268.12794623]),
-                               np.array([0.0,
-                                         331796.29167519242,
-                                         663220.7198366751]),
-                               31, 'N'), result)
-
-        for latlon, utm, _ in self.known_values:
-            result = UTM.from_latlon(*[np.array([x]) for x in latlon])
-            self.assert_utm_equal(utm, result)
-
-    def test_to_latlon(self):
-        '''to_latlon should give known result with known input'''
-        for latlon, utm, utm_kw in self.known_values:
-            result = UTM.to_latlon(*utm)
-            self.assert_latlon_equal(latlon, result)
-
-            result = UTM.to_latlon(*utm[0:3], **utm_kw)
-            self.assert_latlon_equal(latlon, result)
-
-    def test_to_latlon_numpy(self):
-        if not use_numpy:
-            return
-        result = UTM.to_latlon(np.array([166021.44317933032,
-                                         277707.83075574087,
-                                         544268.12794623]),
-                               np.array([0.0,
-                                         331796.29167519242,
-                                         663220.7198366751]),
-                               31, northern=True)
-        self.assert_latlon_equal((np.array([0.0, 3.0, 6.0]),
-                                  np.array([0.0, 1.0, 3.4])),
-                                 result)
-
-        for latlon, utm, utm_kw in self.known_values:
-            utm = [np.array([x]) for x in utm[:2]] + list(utm[2:])
-            result = UTM.to_latlon(*utm)
-            self.assert_latlon_equal(latlon, result)
-
-
-class BadInput(UTMTestCase):
-    def test_from_latlon_range_checks(self):
-        '''from_latlon should fail with out-of-bounds input'''
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, -100, 0)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, -80.1, 0)
-        for i in range(-8000, 8400):
-            UTM.from_latlon(i / 100.0, 0)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 84.1, 0)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 100, 0)
-
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 0, -300)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 0, -180.1)
-        for i in range(-18000, 18000):
-            UTM.from_latlon(0, i / 100.0)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 0, 180.1)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 0, 300)
-
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, -100, -300)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 100, -300)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, -100, 300)
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 100, 300)
-
-        # test forcing zone ranges
-        # NYC should be zone 18T
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 40.71435, -74.00597, 70, 'T')
-        self.assertRaises(UTM.OutOfRangeError, UTM.from_latlon, 40.71435, -74.00597, 18, 'A')
-
-    def test_to_latlon_range_checks(self):
-        '''to_latlon should fail with out-of-bounds input'''
-
-        # test easting range
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 0, 5000000, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 99999, 5000000, 32, 'U')
-
-        for i in range(100000, 999999, 1000):
-            UTM.to_latlon(i, 5000000, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 1000000, 5000000, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 100000000000, 5000000, 32, 'U')
-
-        # test northing range
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, -100000, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, -1, 32, 'U')
-        for i in range(10, 10000000, 1000):
-            UTM.to_latlon(500000, i, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 10000001, 32, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 50000000, 32, 'U')
-
-        # test zone numbers
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 0, 'U')
-
-        for i in range(1, 60):
-            UTM.to_latlon(500000, 5000000, i, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 61, 'U')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 1000, 'U')
-
-        # test zone letters
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'A')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'B')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'I')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'O')
-
-        for i in range(ord('C'), ord('X')):
-            i = chr(i)
-            if i != 'I' and i != 'O':
-                UTM.to_latlon(500000, 5000000, 32, i)
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'Y')
-
-        self.assertRaises(
-            UTM.OutOfRangeError, UTM.to_latlon, 500000, 5000000, 32, 'Z')
-
-
-class Zone32V(unittest.TestCase):
-
-    def assert_zone_equal(self, result, expected_number, expected_letter):
-        self.assertEqual(result[2], expected_number)
-        self.assertEqual(result[3].upper(), expected_letter.upper())
-
-    def test_inside(self):
-        self.assert_zone_equal(UTM.from_latlon(56, 3), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(56, 6), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(56, 9), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(56, 11.999999), 32, 'V')
-
-        self.assert_zone_equal(UTM.from_latlon(60, 3), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(60, 6), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(60, 9), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(60, 11.999999), 32, 'V')
-
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 3), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 6), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 9), 32, 'V')
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 11.999999), 32, 'V')
-
-    def test_left_of(self):
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 2.999999), 31, 'U')
-        self.assert_zone_equal(UTM.from_latlon(56, 2.999999), 31, 'V')
-        self.assert_zone_equal(UTM.from_latlon(60, 2.999999), 31, 'V')
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 2.999999), 31, 'V')
-        self.assert_zone_equal(UTM.from_latlon(64, 2.999999), 31, 'W')
-
-    def test_right_of(self):
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 12), 33, 'U')
-        self.assert_zone_equal(UTM.from_latlon(56, 12), 33, 'V')
-        self.assert_zone_equal(UTM.from_latlon(60, 12), 33, 'V')
-        self.assert_zone_equal(UTM.from_latlon(63.999999, 12), 33, 'V')
-        self.assert_zone_equal(UTM.from_latlon(64, 12), 33, 'W')
-
-    def test_below(self):
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 3), 31, 'U')
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 6), 32, 'U')
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 9), 32, 'U')
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 11.999999), 32, 'U')
-        self.assert_zone_equal(UTM.from_latlon(55.999999, 12), 33, 'U')
-
-    def test_above(self):
-        self.assert_zone_equal(UTM.from_latlon(64, 3), 31, 'W')
-        self.assert_zone_equal(UTM.from_latlon(64, 6), 32, 'W')
-        self.assert_zone_equal(UTM.from_latlon(64, 9), 32, 'W')
-        self.assert_zone_equal(UTM.from_latlon(64, 11.999999), 32, 'W')
-        self.assert_zone_equal(UTM.from_latlon(64, 12), 33, 'W')
-
-
-class TestRightBoundaries(unittest.TestCase):
-
-    def assert_zone_equal(self, result, expected_number):
-        self.assertEqual(result[2], expected_number)
-
-    def test_limits(self):
-        self.assert_zone_equal(UTM.from_latlon(40, 0), 31)
-        self.assert_zone_equal(UTM.from_latlon(40, 5.999999), 31)
-        self.assert_zone_equal(UTM.from_latlon(40, 6), 32)
-
-        self.assert_zone_equal(UTM.from_latlon(72, 0), 31)
-        self.assert_zone_equal(UTM.from_latlon(72, 5.999999), 31)
-        self.assert_zone_equal(UTM.from_latlon(72, 6), 31)
-        self.assert_zone_equal(UTM.from_latlon(72, 8.999999), 31)
-        self.assert_zone_equal(UTM.from_latlon(72, 9), 33)
+        result,
+    )
+
+
+ at pytest.mark.parametrize("latlon, utm, utm_kw", known_values)
+def test_to_latlon(latlon, utm, utm_kw):
+    """to_latlon should give known result with known input"""
+    result = UTM.to_latlon(*utm)
+    assert_latlon_equal(latlon, result)
+
+    result = UTM.to_latlon(*utm[0:3], **utm_kw)
+    assert_latlon_equal(latlon, result)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+ at pytest.mark.parametrize("latlon, utm, utm_kw", known_values)
+def test_to_latlon_numpy(latlon, utm, utm_kw):
+    utm = [np.array([x]) for x in utm[:2]] + list(utm[2:])
+    result = UTM.to_latlon(*utm)
+    assert_latlon_equal(latlon, result)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+def test_to_latlon_numpy_static():
+    result = UTM.to_latlon(
+        np.array([166021.44317933032, 277707.83075574087, 544268.12794623]),
+        np.array([0.0, 331796.29167519242, 663220.7198366751]),
+        31,
+        northern=True,
+    )
+    assert_latlon_equal(
+        (np.array([0.0, 3.0, 6.0]), np.array([0.0, 1.0, 3.4])), result
+    )
+
+
+def test_from_latlon_range_ok():
+    """from_latlon should work for good values"""
+    for i in range(-8000, 8400):
+        assert UTM.from_latlon(i / 100, 0)
+    for i in range(-18000, 18000):
+        assert UTM.from_latlon(0, i / 100)
+
+
+ at pytest.mark.parametrize(
+    "lat, lon",
+    [
+        (-100, 0),
+        (-80.1, 0),
+        (84.1, 0),
+        (100, 0),
+        (0, -300),
+        (0, -180.1),
+        (0, 180.1),
+        (0, 300),
+        (-100, -300),
+        (100, -300),
+        (-100, 300),
+        (100, 300),
+    ],
+)
+def test_from_latlon_range_fails(lat, lon):
+    """from_latlon should fail with out-of-bounds input"""
+    with pytest.raises(UTM.OutOfRangeError):
+        UTM.from_latlon(lat, lon)
+
+
+ at pytest.mark.parametrize(
+    "lat, lon, force_zone_number, force_zone_letter",
+    [(40.71435, -74.00597, 70, "T"), (40.71435, -74.00597, 18, "A")],
+)
+def test_from_latlon_range_forced_fails(
+    lat, lon, force_zone_number, force_zone_letter
+):
+    """from_latlon should fail with out-of-bounds input"""
+    with pytest.raises(UTM.OutOfRangeError):
+        UTM.from_latlon(lat, lon, force_zone_number, force_zone_letter)
+
+
+def test_to_latlon_range_ok():
+    """to_latlon should work for good values"""
+    for i in range(100000, 999999, 1000):
+        assert UTM.to_latlon(i, 5000000, 32, "U")
+    for i in range(10, 10000000, 1000):
+        assert UTM.to_latlon(500000, i, 32, "U")
+    for i in range(1, 60):
+        assert UTM.to_latlon(500000, 5000000, i, "U")
+    for i in range(ord("C"), ord("X")):
+        i = chr(i)
+        if i != "I" and i != "O":
+            UTM.to_latlon(500000, 5000000, 32, i)
+
+
+ at pytest.mark.parametrize(
+    "easting, northing, zone_number, zone_letter",
+    [
+        (0, 5000000, 32, "U"),
+        (99999, 5000000, 32, "U"),
+        (1000000, 5000000, 32, "U"),
+        (100000000000, 5000000, 32, "U"),
+        (500000, -100000, 32, "U"),
+        (500000, -1, 32, "U"),
+        (500000, 10000001, 32, "U"),
+        (500000, 50000000, 32, "U"),
+        (500000, 5000000, 0, "U"),
+        (500000, 5000000, 61, "U"),
+        (500000, 5000000, 1000, "U"),
+        (500000, 5000000, 32, "A"),
+        (500000, 5000000, 32, "B"),
+        (500000, 5000000, 32, "I"),
+        (500000, 5000000, 32, "O"),
+        (500000, 5000000, 32, "Y"),
+        (500000, 5000000, 32, "Z"),
+    ],
+)
+def test_to_latlon_range_checks(easting, northing, zone_number, zone_letter):
+    """to_latlon should fail with out-of-bounds input"""
+    with pytest.raises(UTM.OutOfRangeError):
+        UTM.to_latlon(0, 5000000, 32, "U")
+
+
+ at pytest.mark.parametrize(
+    "lat, lon, expected_number, expected_letter",
+    [
+        # test inside:
+        (56, 3, 32, "V"),
+        (56, 6, 32, "V"),
+        (56, 9, 32, "V"),
+        (56, 11.999999, 32, "V"),
+        (60, 3, 32, "V"),
+        (60, 6, 32, "V"),
+        (60, 9, 32, "V"),
+        (60, 11.999999, 32, "V"),
+        (63.999999, 3, 32, "V"),
+        (63.999999, 6, 32, "V"),
+        (63.999999, 9, 32, "V"),
+        (63.999999, 11.999999, 32, "V"),
+        # test left of:
+        (55.999999, 2.999999, 31, "U"),
+        (56, 2.999999, 31, "V"),
+        (60, 2.999999, 31, "V"),
+        (63.999999, 2.999999, 31, "V"),
+        (64, 2.999999, 31, "W"),
+        # test right of:
+        (55.999999, 12, 33, "U"),
+        (56, 12, 33, "V"),
+        (60, 12, 33, "V"),
+        (63.999999, 12, 33, "V"),
+        (64, 12, 33, "W"),
+        # test below:
+        (55.999999, 3, 31, "U"),
+        (55.999999, 6, 32, "U"),
+        (55.999999, 9, 32, "U"),
+        (55.999999, 11.999999, 32, "U"),
+        (55.999999, 12, 33, "U"),
+        # test above:
+        (64, 3, 31, "W"),
+        (64, 6, 32, "W"),
+        (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):
+    result = UTM.from_latlon(lat, lon)
+    assert result[2] == expected_number
+    assert result[3].upper() == expected_letter.upper()
+
+
+ at pytest.mark.parametrize(
+    "lat, lon, expected_number",
+    [
+        (40, 0, 31),
+        (40, 5.999999, 31),
+        (40, 6, 32),
+        (72, 0, 31),
+        (72, 5.999999, 31),
+        (72, 6, 31),
+        (72, 8.999999, 31),
+        (72, 9, 33),
+    ],
+)
+def test_limits(lat, lon, expected_number):
+    assert UTM.from_latlon(lat, lon)[2] == expected_number
+
+
+ at pytest.mark.parametrize(
+    "zone_number, zone_letter",
+    [
+        (10, "C"),
+        (10, "X"),
+        (10, "p"),
+        (10, "q"),
+        (20, "X"),
+        (1, "X"),
+        (60, "e"),
+    ],
+)
+def test_valid_zones(zone_number, zone_letter):
+    # should not raise any exceptions
+    assert UTM.check_valid_zone(zone_number, zone_letter) is None
+
+
+ at pytest.mark.parametrize(
+    "zone_number, zone_letter", [(-100, "C"), (20, "I"), (20, "O"), (0, "O")]
+)
+def test_invalid_zones(zone_number, zone_letter):
+    with pytest.raises(UTM.OutOfRangeError):
+        UTM.check_valid_zone(zone_number, zone_letter)
+
+
+ at pytest.mark.parametrize(
+    "lat, lon, utm, utm_kw, expected_number, expected_letter",
+    [
+        (40.71435, -74.00597, 19, "T", 19, "T"),
+        (40.71435, -74.00597, 17, "T", 17, "T"),
+        (40.71435, -74.00597, 18, "u", 18, "U"),
+        (40.71435, -74.00597, 18, "S", 18, "S"),
+    ],
+)
+def test_force_zone(lat, lon, utm, utm_kw, expected_number, expected_letter):
+    # test forcing zone ranges
+    # NYC should be zone 18T
+    result = UTM.from_latlon(lat, lon, utm, utm_kw)
+    assert result[2] == expected_number
+    assert result[3].upper() == expected_letter.upper()
+
+
+def assert_equal_lat(result, expected_lat, northern=None):
+    args = result[:3] if northern else result[:4]
+    lat, _ = UTM.to_latlon(*args, northern=northern, strict=False)
+    assert lat == pytest.approx(expected_lat, abs=0.001)
+
+
+def assert_equal_lon(result, expected_lon):
+    _, lon = UTM.to_latlon(*result[:4], strict=False)
+    assert lon == pytest.approx(expected_lon, abs=0.001)
+
+
+def test_force_east():
+    # Force point just west of anti-meridian to east zone 1
+    assert_equal_lon(UTM.from_latlon(0, 179.9, 1, "N"), 179.9)
+
+
+def test_force_west():
+    # Force point just east of anti-meridian to west zone 60
+    assert_equal_lon(UTM.from_latlon(0, -179.9, 60, "N"), -179.9)
+
+
+def test_force_north():
+    # Force southern point to northern zone letter
+    assert_equal_lat(UTM.from_latlon(-0.1, 0, 31, 'N'), -0.1)
+
+    # Again, using force northern
+    assert_equal_lat(
+        UTM.from_latlon(-0.1, 0, 31, force_northern=True), -0.1, northern=True)
+
+
+def test_force_south():
+    # Force northern point to southern zone letter
+    assert_equal_lat(UTM.from_latlon(0.1, 0, 31, 'M'), 0.1)
+
+    # Again, using force northern as False
+    assert_equal_lat(
+        UTM.from_latlon(0.1, 0, 31, force_northern=True), 0.1, northern=True)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+def test_no_force_numpy():
+    # Point above and below equator
+    lats = np.array([-0.1, 0.1])
+    with pytest.raises(ValueError,
+                       match="latitudes must all have the same sign"):
+      UTM.from_latlon(lats, np.array([0, 0]))
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+ at pytest.mark.parametrize("zone", ('N', 'M'))
+def test_force_numpy(zone):
+    # Point above and below equator
+    lats = np.array([-0.1, 0.1])
+
+    result = UTM.from_latlon(
+        lats, np.array([0, 0]), force_zone_letter=zone)
+    for expected_lat, easting, northing in zip(lats, *result[:2]):
+        assert_equal_lat(
+            (easting, northing, result[2], result[3]), expected_lat)
+
+
+ at pytest.mark.skipif(not use_numpy, reason="numpy not installed")
+ at pytest.mark.parametrize("force_northern", (True, False))
+def test_force_numpy_force_northern_true(force_northern):
+    # Point above and below equator
+    lats = np.array([-0.1, 0.1])
+
+    result = UTM.from_latlon(
+        lats, np.array([0, 0]), force_northern=force_northern)
+    for expected_lat, easting, northing in zip(lats, *result[:2]):
+        assert_equal_lat(
+            (easting, northing, result[2], result[3]), expected_lat,
+            northern=force_northern)
+
+
+def test_force_both():
+    # Force both letter and northern not allowed
+    with pytest.raises(ValueError, match="set either force_zone_letter or "
+                                         "force_northern, but not both"):
+        UTM.from_latlon(-0.1, 0, 31, 'N', True)
+
+
+def test_version():
+    assert isinstance(UTM.__version__, str) and "." in UTM.__version__
+
+
+ at 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
 
 
-class TestValidZones(unittest.TestCase):
-    def test_valid_zones(self):
-        # should not raise any exceptions
-        UTM.check_valid_zone(10, 'C')
-        UTM.check_valid_zone(10, 'X')
-        UTM.check_valid_zone(10, 'p')
-        UTM.check_valid_zone(10, 'q')
-        UTM.check_valid_zone(20, 'X')
-        UTM.check_valid_zone(1, 'X')
-        UTM.check_valid_zone(60, 'e')
-
-    def test_invalid_zones(self):
-        self.assertRaises(UTM.OutOfRangeError, UTM.check_valid_zone, -100, 'C')
-        self.assertRaises(UTM.OutOfRangeError, UTM.check_valid_zone, 20, 'I')
-        self.assertRaises(UTM.OutOfRangeError, UTM.check_valid_zone, 20, 'O')
-        self.assertRaises(UTM.OutOfRangeError, UTM.check_valid_zone, 0, 'O')
-
-
-class TestForcingZones(unittest.TestCase):
-    def assert_zone_equal(self, result, expected_number, expected_letter):
-        self.assertEqual(result[2], expected_number)
-        self.assertEqual(result[3].upper(), expected_letter.upper())
-
-    def test_force_zone(self):
-        # test forcing zone ranges
-        # NYC should be zone 18T
-        self.assert_zone_equal(UTM.from_latlon(40.71435, -74.00597, 19, 'T'), 19, 'T')
-        self.assert_zone_equal(UTM.from_latlon(40.71435, -74.00597, 17, 'T'), 17, 'T')
-        self.assert_zone_equal(UTM.from_latlon(40.71435, -74.00597, 18, 'u'), 18, 'U')
-        self.assert_zone_equal(UTM.from_latlon(40.71435, -74.00597, 18, 'S'), 18, 'S')
-
-
-class TestForcingAntiMeridian(unittest.TestCase):
-    def assert_equal_lon(self, result, expected_lon):
-        _, lon = UTM.to_latlon(*result[:4], strict=False)
-        self.assertAlmostEqual(lon, expected_lon, 4)
-
-    def test_force_east(self):
-        # Force point just west of anti-meridian to east zone 1
-        self.assert_equal_lon(
-            UTM.from_latlon(0, 179.9, 1, 'N'), 179.9)
-
-    def test_force_west(self):
-        # Force point just east of anti-meridian to west zone 60
-        self.assert_equal_lon(
-            UTM.from_latlon(0, -179.9, 60, 'N'), -179.9)
-
-
-if __name__ == '__main__':
-    unittest.main()
+ at pytest.mark.parametrize(
+    "zone_number, expected_lon",
+    [
+        (1,  -177),
+        (12, -111),
+        (16,  -87),
+        (31,    3),
+        (37,   39),
+    ],
+)
+def test_zone_number_to_central_longitude(zone_number, expected_lon):
+    lon = UTM.zone_number_to_central_longitude(zone_number)
+    assert lon == expected_lon
+
+
+ at pytest.mark.parametrize(
+    "zone_letter, expected_lat",
+    [
+        ("X",  78),
+        ("C", -76),
+        ("E", -60),
+        ("F", -52),
+        ("Q",  20),
+    ],
+)
+def test_zone_letter_to_central_latitude(zone_letter, expected_lat):
+    lat = UTM.zone_letter_to_central_latitude(zone_letter)
+    assert lat == expected_lat


=====================================
utm/__init__.py
=====================================
@@ -1,2 +1,3 @@
-from utm.conversion import to_latlon, from_latlon, latlon_to_zone_number, latitude_to_zone_letter, check_valid_zone
+from utm.conversion import to_latlon, from_latlon, latlon_to_zone_number, latitude_to_zone_letter, check_valid_zone, zone_number_to_central_longitude, zone_letter_to_central_latitude
 from utm.error import OutOfRangeError
+from utm._version import __version__


=====================================
utm/_version.py
=====================================
@@ -0,0 +1 @@
+__version__ = "0.8.0"


=====================================
utm/conversion.py
=====================================
@@ -1,3 +1,4 @@
+from __future__ import division
 from utm.error import OutOfRangeError
 
 # For most use cases in this module, numpy is indistinguishable
@@ -16,7 +17,7 @@ K0 = 0.9996
 E = 0.00669438
 E2 = E * E
 E3 = E2 * E
-E_P2 = E / (1.0 - E)
+E_P2 = E / (1 - E)
 
 SQRT_E = mathlib.sqrt(1 - E)
 _E = (1 - SQRT_E) / (1 + SQRT_E)
@@ -30,10 +31,10 @@ M2 = (3 * E / 8 + 3 * E2 / 32 + 45 * E3 / 1024)
 M3 = (15 * E2 / 256 + 45 * E3 / 1024)
 M4 = (35 * E3 / 3072)
 
-P2 = (3. / 2 * _E - 27. / 32 * _E3 + 269. / 512 * _E5)
-P3 = (21. / 16 * _E2 - 55. / 32 * _E4)
-P4 = (151. / 96 * _E3 - 417. / 128 * _E5)
-P5 = (1097. / 512 * _E4)
+P2 = (3 / 2 * _E - 27 / 32 * _E3 + 269 / 512 * _E5)
+P3 = (21 / 16 * _E2 - 55 / 32 * _E4)
+P4 = (151 / 96 * _E3 - 417 / 128 * _E5)
+P5 = (1097 / 512 * _E4)
 
 R = 6378137
 
@@ -50,27 +51,27 @@ def in_bounds(x, lower, upper, upper_strict=False):
     return lower <= x <= upper
 
 
-def check_valid_zone(zone_number, zone_letter):
+def check_valid_zone_letter(zone_letter):
+    zone_letter = zone_letter.upper()
+    if not 'C' <= zone_letter <= 'X' or zone_letter in ['I', 'O']:
+        raise OutOfRangeError('zone letter out of range (must be between C and X)')
+
+
+def check_valid_zone_number(zone_number):
     if not 1 <= zone_number <= 60:
         raise OutOfRangeError('zone number out of range (must be between 1 and 60)')
 
-    if zone_letter:
-        zone_letter = zone_letter.upper()
 
-        if not 'C' <= zone_letter <= 'X' or zone_letter in ['I', 'O']:
-            raise OutOfRangeError('zone letter out of range (must be between C and X)')
+def check_valid_zone(zone_number, zone_letter):
+    check_valid_zone_number(zone_number)
+    if zone_letter:
+        check_valid_zone_letter(zone_letter)
 
 
 def mixed_signs(x):
     return use_numpy and mathlib.min(x) < 0 and mathlib.max(x) >= 0
 
 
-def negative(x):
-    if use_numpy:
-        return mathlib.max(x) < 0
-    return x < 0
-
-
 def mod_angle(value):
     """Returns angle in radians to be between -pi and pi"""
     return (value + mathlib.pi) % (2 * mathlib.pi) - mathlib.pi
@@ -96,7 +97,8 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
             designators can be seen in [1]_
 
         northern: bool
-            You can set True or False to set this parameter. Default is None
+            You can set True (North) or False (South) as an alternative to
+            providing a zone letter. Default is None
 
         strict: bool
             Raise an OutOfRangeError if outside of bounds
@@ -115,7 +117,6 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
     """
     if not zone_letter and northern is None:
         raise ValueError('either zone_letter or northern needs to be set')
-
     elif zone_letter and northern is not None:
         raise ValueError('set either zone_letter or northern, but not both')
 
@@ -124,18 +125,15 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
             raise OutOfRangeError('easting out of range (must be between 100,000 m and 999,999 m)')
         if not in_bounds(northing, 0, 10000000):
             raise OutOfRangeError('northing out of range (must be between 0 m and 10,000,000 m)')
-    
+
     check_valid_zone(zone_number, zone_letter)
-    
+
     if zone_letter:
         zone_letter = zone_letter.upper()
         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)
@@ -171,9 +169,9 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
     d5 = d4 * d
     d6 = d5 * d
 
-    latitude = (p_rad - (p_tan / r) *
-                (d2 / 2 -
-                 d4 / 24 * (5 + 3 * p_tan2 + 10 * c - 4 * c2 - 9 * E_P2)) +
+    latitude = p_rad - (p_tan / r) * (
+                 d2 / 2 -
+                 d4 / 24 * (5 + 3 * p_tan2 + 10 * c - 4 * c2 - 9 * E_P2) +
                  d6 / 720 * (61 + 90 * p_tan2 + 298 * c + 45 * p_tan4 - 252 * E_P2 - 3 * c2))
 
     longitude = (d -
@@ -186,7 +184,7 @@ def to_latlon(easting, northing, zone_number, zone_letter=None, northern=None, s
             mathlib.degrees(longitude))
 
 
-def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=None):
+def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=None, force_northern=None):
     """This function converts Latitude and Longitude to UTM coordinate
 
         Parameters
@@ -206,6 +204,11 @@ def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=N
             You may force conversion to be included within one UTM zone
             letter.  For more information see utmzones [1]_
 
+        force_northern: bool
+            You can set True (North) or False (South) as an alternative to
+            forcing with a zone letter. When set, the returned zone_letter will
+            be None. Default is None
+
         Returns
         -------
         easting: float or NumPy array
@@ -225,10 +228,12 @@ def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=N
 
        .. _[1]: http://www.jaworski.ca/utmzones.htm
     """
-    if not in_bounds(latitude, -80.0, 84.0):
+    if not in_bounds(latitude, -80, 84):
         raise OutOfRangeError('latitude out of range (must be between 80 deg S and 84 deg N)')
-    if not in_bounds(longitude, -180.0, 180.0):
+    if not in_bounds(longitude, -180, 180):
         raise OutOfRangeError('longitude out of range (must be between 180 deg W and 180 deg E)')
+    if force_zone_letter and force_northern is not None:
+        raise ValueError('set either force_zone_letter or force_northern, but not both')
     if force_zone_number is not None:
         check_valid_zone(force_zone_number, force_zone_letter)
 
@@ -245,11 +250,16 @@ def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=N
     else:
         zone_number = force_zone_number
 
-    if force_zone_letter is None:
+    if force_zone_letter is None and force_northern is None:
         zone_letter = latitude_to_zone_letter(latitude)
     else:
         zone_letter = force_zone_letter
 
+    if force_northern is None:
+        northern = (zone_letter >= 'N')
+    else:
+        northern = force_northern
+
     lon_rad = mathlib.radians(longitude)
     central_lon = zone_number_to_central_longitude(zone_number)
     central_lon_rad = mathlib.radians(central_lon)
@@ -276,10 +286,10 @@ def from_latlon(latitude, longitude, force_zone_number=None, force_zone_letter=N
     northing = K0 * (m + n * lat_tan * (a2 / 2 +
                                         a4 / 24 * (5 - lat_tan2 + 9 * c + 4 * c**2) +
                                         a6 / 720 * (61 - 58 * lat_tan2 + lat_tan4 + 600 * c - 330 * E_P2)))
-
-    if mixed_signs(latitude):
+    check_signs = force_northern is None and force_zone_letter is None
+    if check_signs and mixed_signs(latitude):
         raise ValueError("latitudes must all have the same sign")
-    elif negative(latitude):
+    elif not northern:
         northing += 10000000
 
     return easting, northing, zone_number, zone_letter
@@ -306,9 +316,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
@@ -323,4 +337,13 @@ def latlon_to_zone_number(latitude, longitude):
 
 
 def zone_number_to_central_longitude(zone_number):
+    check_valid_zone_number(zone_number)
     return (zone_number - 1) * 6 - 180 + 3
+
+def zone_letter_to_central_latitude(zone_letter):
+    check_valid_zone_letter(zone_letter)
+    zone_letter = zone_letter.upper()
+    if zone_letter == 'X':
+        return 78
+    else:
+        return -76 + (ZONE_LETTERS.index(zone_letter) * 8)



View it on GitLab: https://salsa.debian.org/debian-gis-team/utm/-/compare/a0ca4fed8d181abd20e6e089bfab7b2050b14b21...168e1cdd803669a7b628b980f3879db883728f6e

-- 
View it on GitLab: https://salsa.debian.org/debian-gis-team/utm/-/compare/a0ca4fed8d181abd20e6e089bfab7b2050b14b21...168e1cdd803669a7b628b980f3879db883728f6e
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20250201/b3ded10a/attachment-0001.htm>


More information about the Pkg-grass-devel mailing list