From adcc887a6cfa0b3aa09381500289d839e2dd9533 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Sat, 25 Jul 2026 16:56:28 +0200 Subject: [PATCH 1/5] fix(timezone): Make datetime usage timezone-aware for DTZ lint rules - Use datetime.now(timezone.utc).astimezone() for local time with UTC offset - Update all datetime.fromtimestamp() calls with tz parameter - Store parameter cache timestamps as timezone-aware local time - Parameter snapshot 'ts' field now includes UTC offset (e.g. '2024-01-01 13:00:00+01:00') - Fix buggy manual timezone computation in AWG70000A driver - Update all test files to use timezone-aware datetimes - Update snapshot parsing to use fromisoformat() for ISO 8601 format Fixes DTZ005, DTZ006, DTZ007 ruff lint violations. Existing datasets remain unaffected; only new ones have explicit timezone info. --- .../DataSet/Linking to parent datasets.ipynb | 2 +- pyproject.toml | 2 +- src/qcodes/dataset/sqlite/db_overview.py | 2 +- .../instrument_drivers/Keysight/Infiniium.py | 4 +-- .../instrument_drivers/tektronix/AWG70000A.py | 30 +++++-------------- src/qcodes/interactive_widget.py | 6 ++-- src/qcodes/logger/logger.py | 4 +-- src/qcodes/parameters/cache.py | 6 ++-- src/qcodes/parameters/parameter_base.py | 2 +- tests/dataset/test_links.py | 6 ++-- tests/parameter/test_get_latest.py | 12 ++++---- tests/parameter/test_group_parameter.py | 14 ++++----- tests/parameter/test_parameter_cache.py | 18 +++++------ tests/parameter/test_snapshot.py | 12 ++++---- 14 files changed, 54 insertions(+), 66 deletions(-) diff --git a/docs/examples/DataSet/Linking to parent datasets.ipynb b/docs/examples/DataSet/Linking to parent datasets.ipynb index b6e2092b2c12..1a23976dcdda 100644 --- a/docs/examples/DataSet/Linking to parent datasets.ipynb +++ b/docs/examples/DataSet/Linking to parent datasets.ipynb @@ -65,7 +65,7 @@ } ], "source": [ - "now = str(datetime.datetime.now())\n", + "now = str(datetime.datetime.now(datetime.UTC))\n", "tutorial_db_path = Path.cwd().parent / \"example_output\" / \"linking_datasets_tutorial.db\"\n", "initialise_or_create_database_at(tutorial_db_path)\n", "load_or_create_experiment(\"tutorial \" + now, \"no sample\")" diff --git a/pyproject.toml b/pyproject.toml index 95c0ac1af8d9..f444edf31a16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,7 +258,7 @@ extend-select = [ # it may be worth fixing some or these in the future # PYI036 disable until https://github.com/astral-sh/ruff/issues/9794 is fixed ignore = ["E501", "G004", "PLR2004", "PLR0913", "PLR0911", "PLR0912", "PLR0915", "PLW0602", "PLW0603", "PLW2901", "PYI036", "PLR0917", - "DTZ007", "DTZ005", "DTZ006", "N999", "BLE001", "TRY004", "TRY002", "S110"] # new defaults of of 0.16.0 that we don't want to enable yet + "N999", "BLE001", "TRY004", "TRY002", "S110"] # new defaults of of 0.16.0 that we don't want to enable yet # we want to explicitly use the micro symbol # not the greek letter diff --git a/src/qcodes/dataset/sqlite/db_overview.py b/src/qcodes/dataset/sqlite/db_overview.py index 08150f018224..e596439f46aa 100644 --- a/src/qcodes/dataset/sqlite/db_overview.py +++ b/src/qcodes/dataset/sqlite/db_overview.py @@ -79,7 +79,7 @@ def _format_timestamp(ts: float | None) -> tuple[str, str]: if ts is None or ts == 0: return "", "" try: - dt = datetime.datetime.fromtimestamp(ts) + dt = datetime.datetime.fromtimestamp(ts, tz=datetime.UTC).astimezone() except (OSError, ValueError, OverflowError): return "", "" return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M:%S") diff --git a/src/qcodes/instrument_drivers/Keysight/Infiniium.py b/src/qcodes/instrument_drivers/Keysight/Infiniium.py index 3c42542aa796..c6bae311b312 100644 --- a/src/qcodes/instrument_drivers/Keysight/Infiniium.py +++ b/src/qcodes/instrument_drivers/Keysight/Infiniium.py @@ -1,5 +1,5 @@ import re -from datetime import datetime +from datetime import UTC, datetime from io import BytesIO from os.path import splitext from pathlib import Path @@ -1266,7 +1266,7 @@ def screenshot( if isinstance(path, Path): path = str(path) - time_str = datetime.now().strftime(time_fmt) if with_time else "" + time_str = datetime.now(UTC).strftime(time_fmt) if with_time else "" img_name, img_type = splitext(path) img_path = ( f"{img_name}{divider if with_time else ''}{time_str}{img_type.lower()}" diff --git a/src/qcodes/instrument_drivers/tektronix/AWG70000A.py b/src/qcodes/instrument_drivers/tektronix/AWG70000A.py index 42f6483e0aac..15c5e4972c0b 100644 --- a/src/qcodes/instrument_drivers/tektronix/AWG70000A.py +++ b/src/qcodes/instrument_drivers/tektronix/AWG70000A.py @@ -923,17 +923,10 @@ def _makeWFMXFileHeader(num_samples: int, markers_included: bool) -> str: raise ValueError("num_samples must be at least 2400.") # form the timestamp string - timezone = time.timezone - tz_m, _ = divmod(timezone, 60) # returns (minutes, seconds) - tz_h, tz_m = divmod(tz_m, 60) - if np.sign(tz_h) == -1: - signstr = "-" - tz_h *= -1 - else: - signstr = "+" - timestr = dt.datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - timestr += signstr - timestr += f"{tz_h:02.0f}:{tz_m:02.0f}" + now = dt.datetime.now(dt.UTC).astimezone() + tz_str = now.strftime("%z") # e.g. "+0100" + timestr = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + timestr += tz_str[:3] + ":" + tz_str[3:] # "+01:00" hdr = ET.Element( "DataFile", attrib={"offset": "0" * offsetdigits, "version": "0.1"} @@ -1485,17 +1478,10 @@ def _makeSMLFile( N = lstlens[0] # form the timestamp string - timezone = time.timezone - tz_m, _ = divmod(timezone, 60) - tz_h, tz_m = divmod(tz_m, 60) - if np.sign(tz_h) == -1: - signstr = "-" - tz_h *= -1 - else: - signstr = "+" - timestr = dt.datetime.now().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] - timestr += signstr - timestr += f"{tz_h:02.0f}:{tz_m:02.0f}" + now = dt.datetime.now(dt.UTC).astimezone() + tz_str = now.strftime("%z") # e.g. "+0100" + timestr = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + timestr += tz_str[:3] + ":" + tz_str[3:] # "+01:00" datafile = ET.Element( "DataFile", attrib={"offset": "0" * offsetdigits, "version": "0.1"} diff --git a/src/qcodes/interactive_widget.py b/src/qcodes/interactive_widget.py index b1d085be482c..4138e7e12470 100644 --- a/src/qcodes/interactive_widget.py +++ b/src/qcodes/interactive_widget.py @@ -6,7 +6,7 @@ import io import operator import traceback -from datetime import datetime +from datetime import UTC, datetime from functools import partial, reduce from pathlib import Path from typing import TYPE_CHECKING, Any, Literal @@ -389,8 +389,8 @@ def _get_timestamp_button(ds: DataSetProtocol) -> Box: end_timestamp = ds.completed_timestamp_raw if start_timestamp is not None and end_timestamp is not None: total_time = str( - datetime.fromtimestamp(end_timestamp) - - datetime.fromtimestamp(start_timestamp) + datetime.fromtimestamp(end_timestamp, tz=UTC) + - datetime.fromtimestamp(start_timestamp, tz=UTC) ) else: total_time = "?" diff --git a/src/qcodes/logger/logger.py b/src/qcodes/logger/logger.py index 1b77143bf45c..5999ee131a57 100644 --- a/src/qcodes/logger/logger.py +++ b/src/qcodes/logger/logger.py @@ -14,7 +14,7 @@ from collections import OrderedDict from contextlib import contextmanager from copy import copy -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any, Self if TYPE_CHECKING: @@ -163,7 +163,7 @@ def generate_log_file_name() -> str: """ pid = str(os.getpid()) - dt_str = datetime.now().strftime("%y%m%d") + dt_str = datetime.now(UTC).strftime("%y%m%d") python_log_name = f"{dt_str}-{pid}-{PYTHON_LOG_NAME}" return python_log_name diff --git a/src/qcodes/parameters/cache.py b/src/qcodes/parameters/cache.py index 6a43badb127f..bbdea60fa534 100644 --- a/src/qcodes/parameters/cache.py +++ b/src/qcodes/parameters/cache.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, overload from typing_extensions import TypeVar @@ -187,7 +187,7 @@ def _update_with( self._value = value self._raw_value = raw_value if timestamp is None: - self._timestamp = datetime.now() + self._timestamp = datetime.now(UTC).astimezone() else: self._timestamp = timestamp self._marked_valid = True @@ -199,7 +199,7 @@ def _timestamp_expired(self) -> bool: if self._max_val_age is None: # parameter cannot expire return False - oldest_accepted_timestamp = datetime.now() - timedelta( + oldest_accepted_timestamp = datetime.now(UTC).astimezone() - timedelta( seconds=self._max_val_age ) too_old = self._timestamp < oldest_accepted_timestamp diff --git a/src/qcodes/parameters/parameter_base.py b/src/qcodes/parameters/parameter_base.py index d8752d7ddc0c..8887f38b39e0 100644 --- a/src/qcodes/parameters/parameter_base.py +++ b/src/qcodes/parameters/parameter_base.py @@ -754,7 +754,7 @@ def snapshot_base( if isinstance(state["ts"], datetime): dttime: datetime = state["ts"] - state["ts"] = dttime.strftime("%Y-%m-%d %H:%M:%S") + state["ts"] = dttime.isoformat(sep=" ", timespec="seconds") for attr in set(self._meta_attrs): if attr == "instrument" and self._instrument is not None: diff --git a/tests/dataset/test_links.py b/tests/dataset/test_links.py index 3db8d88e65df..e0b3571299f5 100644 --- a/tests/dataset/test_links.py +++ b/tests/dataset/test_links.py @@ -1,7 +1,7 @@ import json import random import re -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta import hypothesis.strategies as hst import pytest @@ -27,7 +27,9 @@ def _timestamp() -> int: Return a random timestamp that is approximately one day in the past. """ - timestamp = datetime.now() - timedelta(days=1, seconds=random.randint(1, 1000)) + timestamp = datetime.now(UTC) - timedelta( + days=1, seconds=random.randint(1, 1000) + ) return round(timestamp.timestamp() * 1000) known_types = ("fit", "analysis", "step") diff --git a/tests/parameter/test_get_latest.py b/tests/parameter/test_get_latest.py index d2c6780afa54..856f7443933f 100644 --- a/tests/parameter/test_get_latest.py +++ b/tests/parameter/test_get_latest.py @@ -1,5 +1,5 @@ import time -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any import pytest @@ -15,11 +15,11 @@ def test_get_latest() -> None: # Create a gettable parameter local_parameter = Parameter("test_param", set_cmd=None, get_cmd=None) - before_set = datetime.now() + before_set = datetime.now(UTC) time.sleep(sleep_delta) local_parameter.set(1) time.sleep(sleep_delta) - after_set = datetime.now() + after_set = datetime.now(UTC) # Check we return last set value, with the correct timestamp assert local_parameter.get_latest() == 1 @@ -67,7 +67,7 @@ def test_get_latest_unknown() -> None: local_parameter.cache._value = value # type: ignore[attr-defined] local_parameter.cache._raw_value = value # type: ignore[attr-defined] assert local_parameter.get_latest.get_timestamp() is None - before_get = datetime.now() + before_get = datetime.now(UTC) assert local_parameter._get_count == 0 assert local_parameter.get_latest() == value assert local_parameter._get_count == 1 @@ -89,7 +89,7 @@ def test_get_latest_known() -> None: value = 1 local_parameter = BetterGettableParam("test_param", set_cmd=None, get_cmd=None) # fake a parameter that has a value acquired 10 sec ago - start = datetime.now() + start = datetime.now(UTC) set_time = start - timedelta(seconds=10) local_parameter.cache._update_with(value=value, raw_value=value, timestamp=set_time) assert local_parameter._get_count == 0 @@ -128,7 +128,7 @@ def test_get_latest_no_get() -> None: def test_max_val_age() -> None: value = 1 - start = datetime.now() + start = datetime.now(UTC) local_parameter = BetterGettableParam( "test_param", set_cmd=None, max_val_age=1, initial_value=value ) diff --git a/tests/parameter/test_group_parameter.py b/tests/parameter/test_group_parameter.py index 6c580e036ce0..9464b031e1cc 100644 --- a/tests/parameter/test_group_parameter.py +++ b/tests/parameter/test_group_parameter.py @@ -1,5 +1,5 @@ import re -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING, Any import pytest @@ -213,9 +213,9 @@ def test_update_group_parameter_reflected_in_cache_of_all_params() -> None: assert dummy.a.cache.get(get_if_invalid=False) is None assert dummy.b.cache.get(get_if_invalid=False) is None - before = datetime.now() + before = datetime.now(UTC) group.update() - after = datetime.now() + after = datetime.now(UTC) assert dummy.a.cache.timestamp is not None assert before <= dummy.a.cache.timestamp @@ -237,9 +237,9 @@ def test_get_group_param_updates_cache_of_other_param() -> None: assert dummy.a.cache.get(get_if_invalid=False) is None assert dummy.b.cache.get(get_if_invalid=False) is None - before = datetime.now() + before = datetime.now(UTC) assert dummy.a.get() == 0 - after = datetime.now() + after = datetime.now(UTC) assert dummy.a.cache.timestamp is not None assert before <= dummy.a.cache.timestamp @@ -261,9 +261,9 @@ def test_set_group_param_updates_cache_of_other_param() -> None: assert dummy.a.cache.get(get_if_invalid=False) is None assert dummy.b.cache.get(get_if_invalid=False) is None - before = datetime.now() + before = datetime.now(UTC) dummy.a.set(10) - after = datetime.now() + after = datetime.now(UTC) assert dummy.a.cache.timestamp is not None assert before <= dummy.a.cache.timestamp diff --git a/tests/parameter/test_parameter_cache.py b/tests/parameter/test_parameter_cache.py index 1768b7de72f5..ace9e1e5b4b5 100644 --- a/tests/parameter/test_parameter_cache.py +++ b/tests/parameter/test_parameter_cache.py @@ -1,7 +1,7 @@ from __future__ import annotations import time -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Literal import pytest @@ -53,11 +53,11 @@ def test_get_cache() -> None: # Create a gettable parameter local_parameter = Parameter("test_param", set_cmd=None, get_cmd=None) - before_set = datetime.now() + before_set = datetime.now(UTC) time.sleep(sleep_delta) local_parameter.set(1) time.sleep(sleep_delta) - after_set = datetime.now() + after_set = datetime.now(UTC) # Check we return last set value, with the correct timestamp assert local_parameter.cache.get() == 1 @@ -103,7 +103,7 @@ def test_get_cache_unknown() -> None: local_parameter.cache._value = value # type: ignore[attr-defined] local_parameter.cache._raw_value = value # type: ignore[attr-defined] assert local_parameter.cache.timestamp is None - before_get = datetime.now() + before_get = datetime.now(UTC) assert local_parameter._get_count == 0 assert local_parameter.cache.get() == value assert local_parameter._get_count == 1 @@ -124,7 +124,7 @@ def test_get_cache_known() -> None: value = 1 local_parameter = BetterGettableParam("test_param", set_cmd=None, get_cmd=None) # fake a parameter that has a value acquired 10 sec ago - start = datetime.now() + start = datetime.now(UTC) set_time = start - timedelta(seconds=10) local_parameter.cache._update_with(value=value, raw_value=value, timestamp=set_time) assert local_parameter._get_count == 0 @@ -165,9 +165,9 @@ def test_set_raw_value_on_cache() -> None: value = 1 scale = 10 local_parameter = BetterGettableParam("test_param", set_cmd=None, scale=scale) - before = datetime.now() + before = datetime.now(UTC) local_parameter.cache._set_from_raw_value(value * scale) - after = datetime.now() + after = datetime.now(UTC) assert local_parameter.cache.get(get_if_invalid=False) == value assert local_parameter.cache.raw_value == value * scale timestamp = local_parameter.cache.timestamp @@ -178,7 +178,7 @@ def test_set_raw_value_on_cache() -> None: def test_max_val_age() -> None: value = 1 - start = datetime.now() + start = datetime.now(UTC) local_parameter = BetterGettableParam( "test_param", set_cmd=None, max_val_age=1, initial_value=value ) @@ -232,7 +232,7 @@ def __init__(self, *args: Any, **kwargs: Any): self.set = self._wrap_set(self.set_raw) local_parameter = LocalParameter("test_param", instrument=None, max_val_age=1) - start = datetime.now() + start = datetime.now(UTC) set_time = start - timedelta(seconds=10) local_parameter.cache._update_with(value=value, raw_value=value, timestamp=set_time) diff --git a/tests/parameter/test_snapshot.py b/tests/parameter/test_snapshot.py index bd2dc32c3261..72ec0e37330a 100644 --- a/tests/parameter/test_snapshot.py +++ b/tests/parameter/test_snapshot.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Literal, TypeVar from typing_extensions import ParamSpec @@ -137,7 +137,7 @@ def test_snapshot_timestamp_of_non_gettable_depends_only_on_cache_validity( if cache_is_valid: assert t0 is not None - ts = datetime.strptime(s["ts"], "%Y-%m-%d %H:%M:%S") + ts = datetime.fromisoformat(s["ts"]) t0_up_to_seconds = t0.replace(microsecond=0) assert ts >= t0_up_to_seconds else: @@ -159,7 +159,7 @@ def test_snapshot_timestamp_for_valid_cache_depends_on_cache_update( assert timestamp is not None p.cache._timestamp = timestamp - timedelta(days=31) # type: ignore[attr-defined] - tu = datetime.now() + tu = datetime.now(UTC) assert p.cache.timestamp is not None assert p.cache.timestamp < tu # pre-condition if update != NOT_PASSED: @@ -167,7 +167,7 @@ def test_snapshot_timestamp_for_valid_cache_depends_on_cache_update( else: s = p.snapshot() - ts = datetime.strptime(s["ts"], "%Y-%m-%d %H:%M:%S") + ts = datetime.fromisoformat(s["ts"]) tu_up_to_seconds = tu.replace(microsecond=0) cache_gets_updated_on_snapshot_call = ( @@ -197,7 +197,7 @@ def test_snapshot_timestamp_for_invalid_cache_depends_only_on_snapshot_flags( ) if cache_gets_updated_on_snapshot_call: - tu = datetime.now() + tu = datetime.now(UTC) else: tu = None @@ -207,7 +207,7 @@ def test_snapshot_timestamp_for_invalid_cache_depends_only_on_snapshot_flags( s = p.snapshot() if cache_gets_updated_on_snapshot_call: - ts = datetime.strptime(s["ts"], "%Y-%m-%d %H:%M:%S") + ts = datetime.fromisoformat(s["ts"]) assert tu is not None tu_up_to_seconds = tu.replace(microsecond=0) assert ts >= tu_up_to_seconds From c5c9f6debbe47f5a132db6174ab75ecc52c2c688 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Fri, 31 Jul 2026 12:44:59 +0200 Subject: [PATCH 2/5] fix(timezone): Include UTC offset in dataset timestamp strings Timestamps are stored in the QCoDeS database as POSIX timestamps, which do not capture the timezone in which the measurement was performed. Rendering them with `time.localtime` and a format without a UTC offset therefore produced ambiguous strings, in particular once exported to NetCDF/xarray. - `raw_time_to_str_time` now uses `datetime.fromtimestamp(ts, tz=UTC)` followed by `astimezone()` instead of `time.localtime` - Default format of `raw_time_to_str_time`, `run_timestamp` and `completed_timestamp` changed to `%Y-%m-%d %H:%M:%S%z` - Add tests covering the xarray/NetCDF export of these attributes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82a9243d-3f0d-43fd-b3fb-861b37591755 --- docs/changes/newsfragments/8345.breaking | 15 ++++ src/qcodes/dataset/data_set_protocol.py | 24 +++-- src/qcodes/dataset/sqlite/queries.py | 26 +++++- tests/dataset/test_dataset_export.py | 110 +++++++++++++++++++++++ tests/dataset/test_dataset_loading.py | 11 ++- 5 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 docs/changes/newsfragments/8345.breaking diff --git a/docs/changes/newsfragments/8345.breaking b/docs/changes/newsfragments/8345.breaking new file mode 100644 index 000000000000..89b2f25c2bcd --- /dev/null +++ b/docs/changes/newsfragments/8345.breaking @@ -0,0 +1,15 @@ +The default format of ``DataSetProtocol.run_timestamp``, +``DataSetProtocol.completed_timestamp`` and +``qcodes.dataset.sqlite.queries.raw_time_to_str_time`` changed from +``"%Y-%m-%d %H:%M:%S"`` to ``"%Y-%m-%d %H:%M:%S%z"``, i.e. the rendered +timestamps now include the UTC offset of the local timezone, e.g. +``"2026-07-31 10:27:25+0200"``. + +Timestamps are stored in the QCoDeS database as POSIX timestamps, which do not +capture the timezone in which the measurement was performed. They are therefore +still rendered in the local timezone of the machine reading the dataset, but +the resulting string is no longer ambiguous. This also applies to the +``run_timestamp`` and ``completed_timestamp`` attributes written when exporting +a dataset to NetCDF or xarray. + +Pass an explicit ``fmt`` argument to restore the previous behaviour. diff --git a/src/qcodes/dataset/data_set_protocol.py b/src/qcodes/dataset/data_set_protocol.py index c12dbc3d4956..cd31082e8809 100644 --- a/src/qcodes/dataset/data_set_protocol.py +++ b/src/qcodes/dataset/data_set_protocol.py @@ -149,12 +149,12 @@ def exp_id(self) -> int: ... @property def sample_name(self) -> str: ... - def run_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None: ... + def run_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S%z") -> str | None: ... @property def run_timestamp_raw(self) -> float | None: ... - def completed_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None: ... + def completed_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S%z") -> str | None: ... @property def completed_timestamp_raw(self) -> float | None: ... @@ -505,7 +505,7 @@ def _reshape_array_for_cache( new_data = param_data.ravel() return new_data - def run_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None: + def run_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S%z") -> str | None: """ Returns run timestamp in a human-readable format @@ -513,18 +513,30 @@ def run_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None: started. If the run has not yet been started, this function returns None. - Consult with :func:`time.strftime` for information about the format. + The timestamp is rendered in the local timezone of the machine reading + the dataset since the raw timestamp stored in the dataset is a POSIX + timestamp which does not capture the timezone of the measurement. The + default format therefore includes the UTC offset. + + Consult with :meth:`datetime.datetime.strftime` for information about + the format. """ return raw_time_to_str_time(self.run_timestamp_raw, fmt) - def completed_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S") -> str | None: + def completed_timestamp(self, fmt: str = "%Y-%m-%d %H:%M:%S%z") -> str | None: """ Returns timestamp when measurement run was completed in a human-readable format If the run (or the dataset) is not completed, then returns None. - Consult with ``time.strftime`` for information about the format. + The timestamp is rendered in the local timezone of the machine reading + the dataset since the raw timestamp stored in the dataset is a POSIX + timestamp which does not capture the timezone of the measurement. The + default format therefore includes the UTC offset. + + Consult with :meth:`datetime.datetime.strftime` for information about + the format. """ return raw_time_to_str_time(self.completed_timestamp_raw, fmt) diff --git a/src/qcodes/dataset/sqlite/queries.py b/src/qcodes/dataset/sqlite/queries.py index 07b6f5a77dd9..31292df97ebf 100644 --- a/src/qcodes/dataset/sqlite/queries.py +++ b/src/qcodes/dataset/sqlite/queries.py @@ -5,6 +5,7 @@ from __future__ import annotations +import datetime import logging import sqlite3 import time @@ -2288,12 +2289,33 @@ def get_raw_run_attributes( def raw_time_to_str_time( - raw_timestamp: float | None, fmt: str = "%Y-%m-%d %H:%M:%S" + raw_timestamp: float | None, fmt: str = "%Y-%m-%d %H:%M:%S%z" ) -> str | None: + """ + Convert a raw (POSIX) timestamp into a human readable string. + + The timestamp is rendered in the local timezone of the machine that reads + the dataset. Note that raw timestamps stored in the database are POSIX + timestamps and therefore do not capture the timezone in which the + measurement was performed. The default format therefore includes the UTC + offset of the local timezone to make the resulting string unambiguous. + + Args: + raw_timestamp: POSIX timestamp to convert or None. + fmt: Format to render the timestamp in. + Consult :meth:`datetime.datetime.strftime` for the format options. + + Returns: + The formatted timestamp or None if the raw timestamp was None. + + """ if raw_timestamp is None: return None else: - return time.strftime(fmt, time.localtime(raw_timestamp)) + local_time = datetime.datetime.fromtimestamp( + raw_timestamp, tz=datetime.UTC + ).astimezone() + return local_time.strftime(fmt) def _check_if_table_found(conn: AtomicConnection, table_name: str) -> bool: diff --git a/tests/dataset/test_dataset_export.py b/tests/dataset/test_dataset_export.py index bf1036e348f3..bf7e21911689 100644 --- a/tests/dataset/test_dataset_export.py +++ b/tests/dataset/test_dataset_export.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import json import logging import os @@ -1440,6 +1441,115 @@ def _assert_xarray_metadata_is_as_expected( ) +def _assert_timestamp_str_matches_raw( + timestamp_str: object, raw_timestamp: float +) -> None: + """ + Assert that an exported timestamp string is timezone aware and describes the + same instant as the raw (POSIX) timestamp it was generated from. + """ + assert isinstance(timestamp_str, str) + # parsing with an explicit ``%z`` fails unless the string carries a UTC offset + parsed = datetime.datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S%z") + assert parsed.tzinfo is not None + expected_offset = ( + datetime.datetime.fromtimestamp(raw_timestamp, tz=datetime.UTC) + .astimezone() + .utcoffset() + ) + assert parsed.utcoffset() == expected_offset + # the exported string is truncated to whole seconds + assert 0 <= raw_timestamp - parsed.timestamp() < 1 + + +def test_export_to_xarray_timestamps_are_timezone_aware( + mock_dataset: DataSet, +) -> None: + run_timestamp_raw = mock_dataset.run_timestamp_raw + completed_timestamp_raw = mock_dataset.completed_timestamp_raw + assert run_timestamp_raw is not None + assert completed_timestamp_raw is not None + + xr_ds = mock_dataset.to_xarray_dataset() + + _assert_timestamp_str_matches_raw(xr_ds.attrs["run_timestamp"], run_timestamp_raw) + _assert_timestamp_str_matches_raw( + xr_ds.attrs["completed_timestamp"], completed_timestamp_raw + ) + assert xr_ds.attrs["run_timestamp_raw"] == run_timestamp_raw + assert xr_ds.attrs["completed_timestamp_raw"] == completed_timestamp_raw + + +def test_export_to_xarray_dataset_dict_timestamps_are_timezone_aware( + mock_dataset: DataSet, +) -> None: + run_timestamp_raw = mock_dataset.run_timestamp_raw + completed_timestamp_raw = mock_dataset.completed_timestamp_raw + assert run_timestamp_raw is not None + assert completed_timestamp_raw is not None + + xr_dss = mock_dataset.to_xarray_dataset_dict() + + for xr_ds in xr_dss.values(): + _assert_timestamp_str_matches_raw( + xr_ds.attrs["run_timestamp"], run_timestamp_raw + ) + _assert_timestamp_str_matches_raw( + xr_ds.attrs["completed_timestamp"], completed_timestamp_raw + ) + + +def test_export_netcdf_timestamps_are_timezone_aware( + tmp_path_factory: TempPathFactory, mock_dataset: DataSet +) -> None: + run_timestamp_raw = mock_dataset.run_timestamp_raw + completed_timestamp_raw = mock_dataset.completed_timestamp_raw + assert run_timestamp_raw is not None + assert completed_timestamp_raw is not None + + path = str(tmp_path_factory.mktemp("export_netcdf_timestamps")) + mock_dataset.export(export_type="netcdf", path=path, prefix="qcodes_") + file_path = os.path.join( + path, f"qcodes_{mock_dataset.captured_run_id}_{mock_dataset.guid}.nc" + ) + + loaded_ds = xr.open_dataset(file_path) + _assert_timestamp_str_matches_raw( + loaded_ds.attrs["run_timestamp"], run_timestamp_raw + ) + _assert_timestamp_str_matches_raw( + loaded_ds.attrs["completed_timestamp"], completed_timestamp_raw + ) + + reloaded_dataset = load_from_netcdf(file_path) + assert reloaded_dataset.run_timestamp_raw == run_timestamp_raw + assert reloaded_dataset.completed_timestamp_raw == completed_timestamp_raw + assert reloaded_dataset.run_timestamp() == mock_dataset.run_timestamp() + assert reloaded_dataset.completed_timestamp() == mock_dataset.completed_timestamp() + + +def test_export_to_xarray_timestamps_of_incomplete_dataset( + experiment: Experiment, +) -> None: + dataset = new_data_set("dataset") + xparam = ParamSpecBase("x", "numeric") + yparam = ParamSpecBase("y", "numeric") + idps = InterDependencies_(dependencies={yparam: (xparam,)}) + dataset.set_interdependencies(idps) + dataset.mark_started() + dataset.add_results([{"x": 0, "y": 1}]) + + run_timestamp_raw = dataset.run_timestamp_raw + assert run_timestamp_raw is not None + assert dataset.completed_timestamp_raw is None + + xr_ds = dataset.to_xarray_dataset() + + _assert_timestamp_str_matches_raw(xr_ds.attrs["run_timestamp"], run_timestamp_raw) + assert xr_ds.attrs["completed_timestamp"] == "" + assert xr_ds.attrs["completed_timestamp_raw"] == -1 + + def test_multi_index_options_grid(mock_dataset_grid: DataSet) -> None: assert mock_dataset_grid.description.shapes is None diff --git a/tests/dataset/test_dataset_loading.py b/tests/dataset/test_dataset_loading.py index 37a37970254b..ebdb732f2553 100644 --- a/tests/dataset/test_dataset_loading.py +++ b/tests/dataset/test_dataset_loading.py @@ -1,5 +1,6 @@ from __future__ import annotations +import datetime import time from math import floor @@ -175,7 +176,9 @@ def test_run_timestamp_with_default_format() -> None: run_ts = ds.run_timestamp() assert run_ts is not None # Note that here we also test the default format of `run_timestamp` - actual_run_timestamp_raw = time.mktime(time.strptime(run_ts, "%Y-%m-%d %H:%M:%S")) + actual_run_timestamp_raw = datetime.datetime.strptime( + run_ts, "%Y-%m-%d %H:%M:%S%z" + ).timestamp() # Note that because the default format precision is 1 second, we add this # second to the right side of the comparison @@ -231,9 +234,9 @@ def test_completed_timestamp_with_default_format() -> None: assert completed_ts is not None # Note that here we also test the default format of `completed_timestamp` - actual_completed_timestamp_raw = time.mktime( - time.strptime(completed_ts, "%Y-%m-%d %H:%M:%S") - ) + actual_completed_timestamp_raw = datetime.datetime.strptime( + completed_ts, "%Y-%m-%d %H:%M:%S%z" + ).timestamp() # Note that because the default format precision is 1 second, we add this # second to the right side of the comparison From 8ca3517e3f97d232171eb85dc5ee138e3b09c0bc Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Fri, 31 Jul 2026 12:57:45 +0200 Subject: [PATCH 3/5] docs: Extend newsfragments to cover all timezone changes on this branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82a9243d-3f0d-43fd-b3fb-861b37591755 --- docs/changes/newsfragments/8345.breaking | 11 +++++++++++ docs/changes/newsfragments/8345.improved_driver | 5 +++++ 2 files changed, 16 insertions(+) create mode 100644 docs/changes/newsfragments/8345.improved_driver diff --git a/docs/changes/newsfragments/8345.breaking b/docs/changes/newsfragments/8345.breaking index 89b2f25c2bcd..f0dde66245ac 100644 --- a/docs/changes/newsfragments/8345.breaking +++ b/docs/changes/newsfragments/8345.breaking @@ -13,3 +13,14 @@ the resulting string is no longer ambiguous. This also applies to the a dataset to NetCDF or xarray. Pass an explicit ``fmt`` argument to restore the previous behaviour. + +Parameter cache timestamps are now timezone aware. ``ParameterBase.cache.timestamp`` +(and therefore ``Parameter.get_latest.get_timestamp()``) returns a +``datetime`` with ``tzinfo`` set to the local timezone rather than a naive +``datetime``. Code that compares these timestamps to naive ``datetime`` objects +will raise ``TypeError`` and must be updated to use timezone aware datetimes. + +Consequently the ``"ts"`` field of a parameter snapshot changed from +``"%Y-%m-%d %H:%M:%S"`` to ISO 8601 format including the UTC offset, e.g. +``"2026-07-31 10:27:25+02:00"``. Snapshots can be parsed with +``datetime.datetime.fromisoformat``. diff --git a/docs/changes/newsfragments/8345.improved_driver b/docs/changes/newsfragments/8345.improved_driver new file mode 100644 index 000000000000..caf3a6092c90 --- /dev/null +++ b/docs/changes/newsfragments/8345.improved_driver @@ -0,0 +1,5 @@ +Fixed the timezone offset written into the waveform and sequence files +generated by the Tektronix AWG70000A drivers. The offset was computed manually +from ``time.timezone``, which gave the offset the wrong sign and ignored +daylight saving time, so e.g. a machine in UTC+02:00 wrote ``-02:00``. The +offset is now taken from the local timezone of the timestamp itself. From 39a5b1d593c4758ab3db1c839a67f2f25a1535a4 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Fri, 31 Jul 2026 13:07:59 +0200 Subject: [PATCH 4/5] fix(timezone): Keep log file and screenshot names in local time `generate_log_file_name` and the Keysight Infiniium screenshot helper were changed to `datetime.now(UTC)` while silencing the DTZ lint rules, which switched their timestamps from local time to UTC. Use `.astimezone()` to restore the previous local time behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82a9243d-3f0d-43fd-b3fb-861b37591755 --- src/qcodes/instrument_drivers/Keysight/Infiniium.py | 4 +++- src/qcodes/logger/logger.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/qcodes/instrument_drivers/Keysight/Infiniium.py b/src/qcodes/instrument_drivers/Keysight/Infiniium.py index c6bae311b312..8fdb6ee41adb 100644 --- a/src/qcodes/instrument_drivers/Keysight/Infiniium.py +++ b/src/qcodes/instrument_drivers/Keysight/Infiniium.py @@ -1266,7 +1266,9 @@ def screenshot( if isinstance(path, Path): path = str(path) - time_str = datetime.now(UTC).strftime(time_fmt) if with_time else "" + time_str = ( + datetime.now(UTC).astimezone().strftime(time_fmt) if with_time else "" + ) img_name, img_type = splitext(path) img_path = ( f"{img_name}{divider if with_time else ''}{time_str}{img_type.lower()}" diff --git a/src/qcodes/logger/logger.py b/src/qcodes/logger/logger.py index 5999ee131a57..83a0c0a1af2d 100644 --- a/src/qcodes/logger/logger.py +++ b/src/qcodes/logger/logger.py @@ -163,7 +163,7 @@ def generate_log_file_name() -> str: """ pid = str(os.getpid()) - dt_str = datetime.now(UTC).strftime("%y%m%d") + dt_str = datetime.now(UTC).astimezone().strftime("%y%m%d") python_log_name = f"{dt_str}-{pid}-{PYTHON_LOG_NAME}" return python_log_name From e6ff91278ed58d7742a39363afea2308c5973249 Mon Sep 17 00:00:00 2001 From: Jens Hedegaard Nielsen Date: Fri, 31 Jul 2026 14:15:33 +0200 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f444edf31a16..0ff7239f30e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -258,7 +258,7 @@ extend-select = [ # it may be worth fixing some or these in the future # PYI036 disable until https://github.com/astral-sh/ruff/issues/9794 is fixed ignore = ["E501", "G004", "PLR2004", "PLR0913", "PLR0911", "PLR0912", "PLR0915", "PLW0602", "PLW0603", "PLW2901", "PYI036", "PLR0917", - "N999", "BLE001", "TRY004", "TRY002", "S110"] # new defaults of of 0.16.0 that we don't want to enable yet + "N999", "BLE001", "TRY004", "TRY002", "S110"] # new defaults of 0.16.0 that we don't want to enable yet # we want to explicitly use the micro symbol # not the greek letter