Repository: pvlib/pvlib-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.


Discussion:

Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.11.3.rst b/docs/sphinx/source/whatsnew/v0.11.3.rst
index 22aca60e9d..035c17962e 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 9259f410fa..d1c9b4e2db 100644
--- a/pvlib/location.py
+++ b/pvlib/location.py
@@ -6,6 +6,7 @@
 
 import pathlib
 import datetime
+import zoneinfo
 
 import pandas as pd
 import pytz
@@ -18,13 +19,16 @@
 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 c8d4d6e309..b08d061676 100644
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -3,10 +3,11 @@
 """
 
 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 9c43ed2faa..36d71e30be 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 @@
 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 eb9e65c895..013716549b 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