diff --git a/continuous-integration/requirements-3.11.txt b/continuous-integration/requirements-3.11.txt index 0ea4ac20..ab48a414 100644 --- a/continuous-integration/requirements-3.11.txt +++ b/continuous-integration/requirements-3.11.txt @@ -51,6 +51,8 @@ flake8==7.3.0 # via emsarray (pyproject.toml) fonttools==4.59.1 # via matplotlib +freezegun==1.5.5 + # via emsarray (pyproject.toml) fsspec==2025.7.0 # via dask geojson==3.2.0 @@ -188,6 +190,7 @@ pytest-mpl==0.17.0 # via emsarray (pyproject.toml) python-dateutil==2.9.0.post0 # via + # freezegun # matplotlib # pandas pytz==2025.2 diff --git a/continuous-integration/requirements-3.12.txt b/continuous-integration/requirements-3.12.txt index 592edaa1..f1eb3cec 100644 --- a/continuous-integration/requirements-3.12.txt +++ b/continuous-integration/requirements-3.12.txt @@ -51,6 +51,8 @@ flake8==7.3.0 # via emsarray (pyproject.toml) fonttools==4.59.1 # via matplotlib +freezegun==1.5.5 + # via emsarray (pyproject.toml) fsspec==2025.7.0 # via dask geojson==3.2.0 @@ -186,6 +188,7 @@ pytest-mpl==0.17.0 # via emsarray (pyproject.toml) python-dateutil==2.9.0.post0 # via + # freezegun # matplotlib # pandas pytz==2025.2 diff --git a/continuous-integration/requirements-3.13.txt b/continuous-integration/requirements-3.13.txt index ad5f2587..98feef1b 100644 --- a/continuous-integration/requirements-3.13.txt +++ b/continuous-integration/requirements-3.13.txt @@ -51,6 +51,8 @@ flake8==7.3.0 # via emsarray (pyproject.toml) fonttools==4.59.1 # via matplotlib +freezegun==1.5.5 + # via emsarray (pyproject.toml) fsspec==2025.7.0 # via dask geojson==3.2.0 @@ -186,6 +188,7 @@ pytest-mpl==0.17.0 # via emsarray (pyproject.toml) python-dateutil==2.9.0.post0 # via + # freezegun # matplotlib # pandas pytz==2025.2 diff --git a/continuous-integration/requirements-minimum.txt b/continuous-integration/requirements-minimum.txt index 380e0da9..03b96783 100644 --- a/continuous-integration/requirements-minimum.txt +++ b/continuous-integration/requirements-minimum.txt @@ -194,6 +194,6 @@ zipp~=3.20.2 # emsarray # pytz # tzdata -pytz tzdata certifi +pytz diff --git a/docs/releases/development.rst b/docs/releases/development.rst index e18e1fd6..65d5a188 100644 --- a/docs/releases/development.rst +++ b/docs/releases/development.rst @@ -9,3 +9,6 @@ Next release (in development) :ref:`how to set the clim parameter in plots ` (:pr:`179`). * Bumped pinned dependencies (:pr:`183`). +* Fixed :func:`emsarray.utils.datetime_from_np_time` + when the system timezone is not UTC and a specific timezone is requested + (:issue:`176`, :pr:`183`). diff --git a/pyproject.toml b/pyproject.toml index 1173fb3d..6f233104 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ testing = [ "types-pytz", "flake8", "isort", + "freezegun", ] [project.scripts] diff --git a/src/emsarray/utils.py b/src/emsarray/utils.py index 642c022a..c0f757e9 100644 --- a/src/emsarray/utils.py +++ b/src/emsarray/utils.py @@ -695,15 +695,17 @@ def datetime_from_np_time( The timezone that the numpy datetime is in. Defaults to UTC, as xarray will convert all time variables to UTC when opening files. + The returned Python datetime will be in this timezone. Returns ======= datetime.datetime A timezone aware Python datetime.datetime instance. """ - epoc = numpy.datetime64('1970-01-01') - timestamp = (np_time - epoc).astype('timedelta64[ns]') - return datetime.datetime.fromtimestamp(timestamp.astype(float) / 1e9, tz=tz) + np_epoc = numpy.datetime64('1970-01-01') + ns_since_epoc = (np_time - np_epoc).astype('timedelta64[ns]') + py_epoc = datetime.datetime(1970, 1, 1, tzinfo=tz) + return py_epoc + datetime.timedelta(seconds=ns_since_epoc.astype(float) / 1e9) class RequiresExtraException(Exception): diff --git a/tests/test_utils.py b/tests/test_utils.py index f6a6d3d5..8a727139 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ import pathlib from importlib.metadata import version +import freezegun import netCDF4 import numpy import numpy.testing @@ -569,3 +570,25 @@ def test_wind_dimension_renamed(): ) wound = utils.wind_dimension(data_array, ['y', 'x'], [5, 4], linear_dimension='ix') xarray.testing.assert_equal(wound, expected) + + +@pytest.mark.parametrize('tz_offset', [0, 10, -4]) +def test_datetime_from_np_time(tz_offset: int): + # Change the system timezone to `tz_offset` + with freezegun.freeze_time(tz_offset=tz_offset): + np_time = numpy.datetime64('2025-08-18T12:05:00.123456') + + # Test that converting works using the UTC default timezone, + # regardless of system timezone + py_time_utc = utils.datetime_from_np_time(np_time) + assert py_time_utc == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=datetime.UTC) + + # Test that converting works when interpreted in the system timezone. + py_tz_system = datetime.timezone(datetime.timedelta(hours=tz_offset)) + py_time_local = utils.datetime_from_np_time(np_time, tz=py_tz_system) + assert py_time_local == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_system) + + # Test that converting works when using some other arbitrary timezone. + py_tz_eucla = datetime.timezone(datetime.timedelta(hours=8, minutes=45)) + py_time_eucla = utils.datetime_from_np_time(np_time, tz=py_tz_eucla) + assert py_time_eucla == datetime.datetime(2025, 8, 18, 12, 5, 0, 123456, tzinfo=py_tz_eucla)