Repository: pvlib/pvlib-python (python)

Issue:
Add tests for time conversions in tools package
**Is your feature request related to a problem? Please describe.**
I am looking into replacing `pytz` with the standard library's `zoneinfo`, and I found three untested functions that use `pytz`:
- pvlib.tools.localize_to_utc
- pvlib.tools.datetime_to_djd
- pvlib.tools.djd_to_datetime

**Describe the solution you'd like**
Add tests for these three functions before attempting to replace `pytz` in a refactor.

**Describe alternatives you've considered**
Adding tests should be a welcome endeavor, no matter if `pytz` is replaced or not.

**Additional context**
None.

UPDATE: #2341 that addresses this issue moves to the Python 3.9+ standard library's `zoneinfo.ZoneInfo` as the single source of truth that represents a timezone internally in `Location` objects. Also, the interface to this object was made consistent between the `Location` intializer and updating the `tz` string attribute after object initialization (and `tz` is now always a string, as previous advertised). However, `Location` objects still all existing types for initializing `tz`, as well as still exposing a `pytz` representation of the Location's timezone as the read-only `pytz` attribute.

Pull request description:
Add tests for time conversions in tools package
<!-- Thank you for your contribution! The following items must be addressed before the code can be merged. Please don't hesitate to ask for help if you're unsure of how to accomplish any of the items. Feel free to remove checklist items that are not relevant to your change. -->

 - [x] Closes #2340
 - [x] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing/index.html)
 - [x] Tests added
 - [x] Updates entries in [`docs/sphinx/source/reference`](https://github.com/pvlib/pvlib-python/blob/main/docs/sphinx/source/reference) for API changes.
 - [x] Adds description and name entries in the appropriate "what's new" file in [`docs/sphinx/source/whatsnew`](https://github.com/pvlib/pvlib-python/tree/main/docs/sphinx/source/whatsnew) for all changes. Includes link to the GitHub Issue with `` :issue:`num` `` or this Pull Request with `` :pull:`num` ``. Includes contributor name and/or GitHub username (link with `` :ghuser:`user` ``).
 - [x] New code is fully documented. Includes [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) compliant docstrings, examples, and comments where necessary.
 - [x] Pull request is nearly complete and ready for detailed review.
 - [x] Maintainer: Appropriate GitHub Labels (including `remote-data`) and Milestone are assigned to the Pull Request and linked Issue.

<!-- Brief description of the problem and proposed solution (if not already fully described in the issue linked to above): -->


Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.11.3.rst b/docs/sphinx/source/whatsnew/v0.11.3.rst
index 22aca60..035c179 100644
--- a/docs/sphinx/source/whatsnew/v0.11.3.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.3.rst
@@ -13,6 +13,11 @@ Bug fixes
 * :py:class:`~pvlib.modelchain.ModelChain` now requires only a minimal set of
   parameters to run the SAPM electrical model. (:issue:`2369`, :pull:`2393`)
 * Correct keys for First Solar modules in `~pvlib.spectrum.spectral_factor_pvspec` (:issue:`2398`, :pull:`2400`)
+* Ensure proper tz and pytz types in pvlib.location.Location. To ensure that
+  the time zone in pvlib.location.Location remains internally consistent
+  if/when the time zone is updated, the tz attribute is now the single source
+  of time-zone truth, is the single time-zone setter interface, and its getter 
+  returns an IANA string. (:issue:`2340`, :pull:`2341`)
 
 
 Deprecations
@@ -40,6 +45,9 @@ Testing
 * Moved tests folder to `/tests` and data exclusively used for testing to `/tests/data`.
   (:issue:`2271`, :pull:`2277`)
 * Added Python 3.13 to test suite. (:pull:`2258`)
+* Add tests for all input types for the pvlib.location.Location.tz attribute.
+  (:issue:`2340`, :pull:`2341`)
+* Add tests for time-conversion functions in pvlib.tools. (:issue:`2340`, :pull:`2341`)
 
 
 Requirements
@@ -53,11 +61,15 @@ Maintenance
 * asv 0.4.2 upgraded to asv 0.6.4 to fix CI failure due to pinned older conda.
   (:pull:`2352`)
 
+Breaking Changes
+~~~~~~~~~~~~~~~~
+* The pvlib.location.Location.pytz attribute is now read only. The
+  pytz attribute is now set internally to be consistent with the
+  pvlib.location.Location.tz attribute. (:issue:`2340`, :pull:`2341`)
 
 Contributors
 ~~~~~~~~~~~~
 * Rajiv Daxini (:ghuser:`RDaxini`)
-* Mark Campanelli (:ghuser:`markcampanelli`)
 * Cliff Hansen (:ghuser:`cwhanse`)
 * Jason Lun Leung (:ghuser:`jason-rpkt`)
 * Manoj K S (:ghuser:`manojks1999`)
@@ -65,3 +77,4 @@ Contributors
 * Ayush jariyal (:ghuser:`ayushjariyal`)
 * Kevin Anderson (:ghuser:`kandersolar`)
 * Echedey Luis (:ghuser:`echedey-ls`)
+* Mark Campanelli (:ghuser:`markcampanelli`)
diff --git a/pvlib/location.py b/pvlib/location.py
index 9259f41..d1c9b4e 100644
--- a/pvlib/location.py
+++ b/pvlib/location.py
@@ -6,6 +6,7 @@ This module contains the Location class.
 
 import pathlib
 import datetime
+import zoneinfo
 
 import pandas as pd
 import pytz
@@ -18,13 +19,16 @@ from pvlib.tools import _degrees_to_index
 class Location:
     """
     Location objects are convenient containers for latitude, longitude,
-    timezone, and altitude data associated with a particular
-    geographic location. You can also assign a name to a location object.
+    time zone, and altitude data associated with a particular geographic
+    location. You can also assign a name to a location object.
 
-    Location objects have two timezone attributes:
+    Location objects have two time-zone attributes:
 
-        * ``tz`` is a IANA timezone string.
-        * ``pytz`` is a pytz timezone object.
+        * ``tz`` is an IANA time-zone string.
+        * ``pytz`` is a pytz-based time-zone object (read only).
+
+    The read-only ``pytz`` attribute will stay in sync with any changes made
+    using ``tz``.
 
     Location objects support the print method.
 
@@ -38,12 +42,16 @@ class Location:
         Positive is east of the prime meridian.
         Use decimal degrees notation.
 
-    tz : str, int, float, or pytz.timezone, default 'UTC'.
-        See
-        http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
-        for a list of valid time zones.
-        pytz.timezone objects will be converted to strings.
-        ints and floats must be in hours from UTC.
+    tz : time zone as str, int, float, or datetime.tzinfo, default 'UTC'.
+        See http://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a
+        list of valid name strings. An `int` or `float` must be a whole-number
+        hour offsets from UTC that can be converted to the IANA-supported
+        'Etc/GMT-N' format. (Note the limited range of the offset N and its
+        sign-change convention.) Time zones from the pytz and zoneinfo packages
+        may also be passed here, as they are subclasses of datetime.tzinfo.
+
+        The `tz` attribute is represented as a valid IANA time zone name
+        string.
 
     altitude : float, optional
         Altitude from sea level in meters.
@@ -54,43 +62,75 @@ class Location:
     name : string, optional
         Sets the name attribute of the Location object.
 
+    Raises
+    ------
+    ValueError
+        when the time zone ``tz`` cannot be converted.
+
+    zoneinfo.ZoneInfoNotFoundError
+        when the time zone ``tz`` is not recognizable as an IANA time zone by
+        the ``zoneinfo.ZoneInfo`` initializer used for internal time-zone
+        representation.
+
     See also
     --------
     pvlib.pvsystem.PVSystem
     """
 
-    def __init__(self, latitude, longitude, tz='UTC', altitude=None,
-                 name=None):
-
+    def __init__(
+        self, latitude, longitude, tz='UTC', altitude=None, name=None
+    ):
         self.latitude = latitude
         self.longitude = longitude
-
-        if isinstance(tz, str):
-            self.tz = tz
-            self.pytz = pytz.timezone(tz)
-        elif isinstance(tz, datetime.timezone):
-            self.tz = 'UTC'
-            self.pytz = pytz.UTC
-        elif isinstance(tz, datetime.tzinfo):
-            self.tz = tz.zone
-            self.pytz = tz
-        elif isinstance(tz, (int, float)):
-            self.tz = tz
-            self.pytz = pytz.FixedOffset(tz*60)
-        else:
-            raise TypeError('Invalid tz specification')
+        self.tz = tz
 
         if altitude is None:
             altitude = lookup_altitude(latitude, longitude)
 
         self.altitude = altitude
-
         self.name = name
 
     def __repr__(self):
         attrs = ['name', 'latitude', 'longitude', 'altitude', 'tz']
+        # Use None as getattr default in case __repr__ is called during
+        # initialization before all attributes have been assigned.
         return ('Location: \n  ' + '\n  '.join(
-            f'{attr}: {getattr(self, attr)}' for attr in attrs))
+            f'{attr}: {getattr(self, attr, None)}' for attr in attrs))
+
+    @property
+    def tz(self):
+        """The location's IANA time-zone string."""
+        return str(self._zoneinfo)
+
+    @tz.setter
+    def tz(self, tz_):
+        # self._zoneinfo holds single source of time-zone truth as IANA name.
+        if isinstance(tz_, str):
+            self._zoneinfo = zoneinfo.ZoneInfo(tz_)
+        elif isinstance(tz_, int):
+            self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-tz_:+d}")
+        elif isinstance(tz_, float):
+            if tz_ % 1 != 0:
+                raise TypeError(
+                    "Floating-point tz has non-zero fractional part: "
+                    f"{tz_}. Only whole-number offsets are supported."
+                )
+
+            self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-int(tz_):+d}")
+        elif isinstance(tz_, datetime.tzinfo):
+            # Includes time zones generated by pytz and zoneinfo packages.
+            self._zoneinfo = zoneinfo.ZoneInfo(str(tz_))
+        else:
+            raise TypeError(
+                f"invalid tz specification: {tz_}, must be an IANA time zone "
+                "string, a whole-number int/float UTC offset, or a "
+                "datetime.tzinfo object (including subclasses)"
+            )
+
+    @property
+    def pytz(self):
+        """The location's pytz time zone (read only)."""
+        return pytz.timezone(str(self._zoneinfo))
 
     @classmethod
     def from_tmy(cls, tmy_metadata, tmy_data=None, **kwargs):
diff --git a/pvlib/tools.py b/pvlib/tools.py
index c8d4d6e..b08d061 100644
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -3,10 +3,11 @@ Collection of functions used in pvlib_python
 """
 
 import datetime as dt
+import warnings
+
 import numpy as np
 import pandas as pd
 import pytz
-import warnings
 
 
 def cosd(angle):
@@ -119,21 +120,21 @@ def atand(number):
 
 def localize_to_utc(time, location):
     """
-    Converts or localizes a time series to UTC.
+    Converts ``time`` to UTC, localizing if necessary using location.
 
     Parameters
     ----------
     time : datetime.datetime, pandas.DatetimeIndex,
            or pandas.Series/DataFrame with a DatetimeIndex.
-    location : pvlib.Location object
+    location : pvlib.Location object (unused if ``time`` is localized)
 
     Returns
     -------
-    pandas object localized to UTC.
+    datetime.datetime or pandas object localized to UTC.
     """
     if isinstance(time, dt.datetime):
         if time.tzinfo is None:
-            time = pytz.timezone(location.tz).localize(time)
+            time = location.pytz.localize(time)
         time_utc = time.astimezone(pytz.utc)
     else:
         try:


Test patch:
diff --git a/tests/test_location.py b/tests/test_location.py
index 9c43ed2..36d71e3 100644
--- a/tests/test_location.py
+++ b/tests/test_location.py
@@ -1,5 +1,6 @@
 import datetime
 from unittest.mock import ANY
+import zoneinfo
 
 import numpy as np
 from numpy import nan
@@ -9,7 +10,6 @@ from .conftest import assert_frame_equal, assert_index_equal
 import pytest
 
 import pytz
-from pytz.exceptions import UnknownTimeZoneError
 
 import pvlib
 from pvlib import location
@@ -27,22 +27,63 @@ def test_location_all():
     Location(32.2, -111, 'US/Arizona', 700, 'Tucson')
 
 
-@pytest.mark.parametrize('tz', [
-    pytz.timezone('US/Arizona'), 'America/Phoenix',  -7, -7.0,
-    datetime.timezone.utc
-])
-def test_location_tz(tz):
-    Location(32.2, -111, tz)
-
-
-def test_location_invalid_tz():
-    with pytest.raises(UnknownTimeZoneError):
-        Location(32.2, -111, 'invalid')
-
-
-def test_location_invalid_tz_type():
+@pytest.mark.parametrize(
+    'tz,tz_expected', [
+        pytest.param('UTC', 'UTC'),
+        pytest.param('Etc/GMT+5', 'Etc/GMT+5'),
+        pytest.param('US/Mountain', 'US/Mountain'),
+        pytest.param('America/Phoenix', 'America/Phoenix'),
+        pytest.param('Asia/Kathmandu', 'Asia/Kathmandu'),
+        pytest.param('Asia/Yangon', 'Asia/Yangon'),
+        pytest.param(datetime.timezone.utc, 'UTC'),
+        pytest.param(zoneinfo.ZoneInfo('Etc/GMT-7'), 'Etc/GMT-7'),
+        pytest.param(pytz.timezone('US/Arizona'), 'US/Arizona'),
+        pytest.param(-6, 'Etc/GMT+6'),
+        pytest.param(-11.0, 'Etc/GMT+11'),
+        pytest.param(12, 'Etc/GMT-12'),
+    ],
+)
+def test_location_tz(tz, tz_expected):
+    loc = Location(32.2, -111, tz)
+    assert isinstance(loc.pytz, datetime.tzinfo)  # Abstract base class.
+    assert isinstance(loc.pytz, pytz.tzinfo.BaseTzInfo)
+    assert type(loc.tz) is str
+    assert loc.tz == tz_expected
+
+
+def test_location_tz_update():
+    loc = Location(32.2, -111, -11)
+    assert loc.tz == 'Etc/GMT+11'
+    assert loc.pytz == pytz.timezone('Etc/GMT+11')  # Deprecated attribute.
+
+    # Updating Location's tz updates read-only time-zone attributes.
+    loc.tz = 7
+    assert loc.tz == 'Etc/GMT-7'
+    assert loc.pytz == pytz.timezone('Etc/GMT-7')  # Deprecated attribute.
+
+
+@pytest.mark.parametrize(
+    'tz', [
+        'invalid',
+        'Etc/GMT+20',  # offset too large.
+        20,  # offset too large.
+    ]
+)
+def test_location_invalid_tz(tz):
+    with pytest.raises(zoneinfo.ZoneInfoNotFoundError):
+        Location(32.2, -111, tz)
+
+
+@pytest.mark.parametrize(
+    'tz', [
+        -9.5,  # float with non-zero fractional part.
+        b"bytes not str",
+        [5],
+    ]
+)
+def test_location_invalid_tz_type(tz):
     with pytest.raises(TypeError):
-        Location(32.2, -111, [5])
+        Location(32.2, -111, tz)
 
 
 def test_location_print_all():
diff --git a/tests/test_tools.py b/tests/test_tools.py
index eb9e65c..0137165 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -1,9 +1,12 @@
-import pytest
+from datetime import datetime
+from zoneinfo import ZoneInfo
 
-from pvlib import tools
 import numpy as np
-import pandas as pd
 from numpy.testing import assert_allclose
+import pandas as pd
+import pytest
+
+from pvlib import location, tools
 
 
 @pytest.mark.parametrize('keys, input_dict, expected', [
@@ -144,3 +147,108 @@ def test_get_pandas_index(args, args_idx):
 def test_normalize_max2one(data_in, expected):
     result = tools.normalize_max2one(data_in)
     assert_allclose(result, expected)
+
+
+def test_localize_to_utc():
+    lat, lon = 43.2, -77.6
+    tz = "Etc/GMT+5"
+    loc = location.Location(lat, lon, tz=tz)
+    year, month, day, hour, minute, second = 1974, 6, 22, 18, 30, 15
+    hour_utc = hour + 5
+
+    # Test all combinations of supported inputs.
+    dt_time_aware_utc = datetime(
+        year, month, day, hour_utc, minute, second, tzinfo=ZoneInfo("UTC")
+    )
+    dt_time_aware = datetime(
+        year, month, day, hour, minute, second, tzinfo=ZoneInfo(tz)
+    )
+    assert tools.localize_to_utc(dt_time_aware, None) == dt_time_aware_utc
+    dt_time_naive = datetime(year, month, day, hour, minute, second)
+    assert tools.localize_to_utc(dt_time_naive, loc) == dt_time_aware_utc
+
+    # FIXME Derive timestamp strings from above variables.
+    dt_index_aware_utc = pd.DatetimeIndex(
+        [dt_time_aware_utc.strftime("%Y-%m-%dT%H:%M:%S")], tz=ZoneInfo("UTC")
+    )
+    dt_index_aware = pd.DatetimeIndex(
+        [dt_time_aware.strftime("%Y-%m-%dT%H:%M:%S")], tz=ZoneInfo(tz)
+    )
+    assert tools.localize_to_utc(dt_index_aware, None) == dt_index_aware_utc
+    dt_index_naive = pd.DatetimeIndex(
+        [dt_time_naive.strftime("%Y-%m-%dT%H:%M:%S")]
+    )
+    assert tools.localize_to_utc(dt_index_naive, loc) == dt_index_aware_utc
+
+    # Older pandas versions have wonky dtype equality check on timestamp
+    # index, so check the values as numpy.ndarray and indices one by one.
+    series_time_aware_utc_expected = pd.Series([24.42], dt_index_aware_utc)
+    series_time_aware = pd.Series([24.42], index=dt_index_aware)
+    series_time_aware_utc_got = tools.localize_to_utc(series_time_aware, None)
+    np.testing.assert_array_equal(
+        series_time_aware_utc_got.to_numpy(),
+        series_time_aware_utc_expected.to_numpy(),
+    )
+
+    for index_got, index_expected in zip(
+        series_time_aware_utc_got.index, series_time_aware_utc_expected.index
+    ):
+        assert index_got == index_expected
+
+    series_time_naive = pd.Series([24.42], index=dt_index_naive)
+    series_time_naive_utc_got = tools.localize_to_utc(series_time_naive, loc)
+    np.testing.assert_array_equal(
+        series_time_naive_utc_got.to_numpy(),
+        series_time_aware_utc_expected.to_numpy(),
+    )
+
+    for index_got, index_expected in zip(
+        series_time_naive_utc_got.index, series_time_aware_utc_expected.index
+    ):
+        assert index_got == index_expected
+
+    # Older pandas versions have wonky dtype equality check on timestamp
+    # index, so check the values as numpy.ndarray and indices one by one.
+    df_time_aware_utc_expected = pd.DataFrame([[24.42]], dt_index_aware)
+    df_time_naive = pd.DataFrame([[24.42]], index=dt_index_naive)
+    df_time_naive_utc_got = tools.localize_to_utc(df_time_naive, loc)
+    np.testing.assert_array_equal(
+        df_time_naive_utc_got.to_numpy(),
+        df_time_aware_utc_expected.to_numpy(),
+    )
+
+    for index_got, index_expected in zip(
+        df_time_naive_utc_got.index, df_time_aware_utc_expected.index
+    ):
+        assert index_got == index_expected
+
+    df_time_aware = pd.DataFrame([[24.42]], index=dt_index_aware)
+    df_time_aware_utc_got = tools.localize_to_utc(df_time_aware, None)
+    np.testing.assert_array_equal(
+        df_time_aware_utc_got.to_numpy(),
+        df_time_aware_utc_expected.to_numpy(),
+    )
+
+    for index_got, index_expected in zip(
+        df_time_aware_utc_got.index, df_time_aware_utc_expected.index
+    ):
+        assert index_got == index_expected
+
+
+def test_datetime_to_djd():
+    expected = 27201.47934027778
+    dt_aware = datetime(1974, 6, 22, 18, 30, 15, tzinfo=ZoneInfo("Etc/GMT+5"))
+    assert tools.datetime_to_djd(dt_aware) == expected
+    dt_naive_utc = datetime(1974, 6, 22, 23, 30, 15)
+    assert tools.datetime_to_djd(dt_naive_utc) == expected
+
+
+def test_djd_to_datetime():
+    djd = 27201.47934027778
+    tz = "Etc/GMT+5"
+
+    expected = datetime(1974, 6, 22, 18, 30, 15, tzinfo=ZoneInfo(tz))
+    assert tools.djd_to_datetime(djd, tz) == expected
+
+    expected = datetime(1974, 6, 22, 23, 30, 15, tzinfo=ZoneInfo("UTC"))
+    assert tools.djd_to_datetime(djd) == expected