Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/changes/newsfragments/8345.breaking
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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.

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``.
5 changes: 5 additions & 0 deletions docs/changes/newsfragments/8345.improved_driver
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/examples/DataSet/Linking to parent datasets.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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\")"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 0.16.0 that we don't want to enable yet

# we want to explicitly use the micro symbol
# not the greek letter
Expand Down
24 changes: 18 additions & 6 deletions src/qcodes/dataset/data_set_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand Down Expand Up @@ -505,26 +505,38 @@ 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

The run timestamp is the moment when the measurement for this run
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)

Expand Down
2 changes: 1 addition & 1 deletion src/qcodes/dataset/sqlite/db_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
26 changes: 24 additions & 2 deletions src/qcodes/dataset/sqlite/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import datetime
import logging
import sqlite3
import time
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions src/qcodes/instrument_drivers/Keysight/Infiniium.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -1266,7 +1266,9 @@ 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).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()}"
Expand Down
30 changes: 8 additions & 22 deletions src/qcodes/instrument_drivers/tektronix/AWG70000A.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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"
Comment thread
jenshnielsen marked this conversation as resolved.

datafile = ET.Element(
"DataFile", attrib={"offset": "0" * offsetdigits, "version": "0.1"}
Expand Down
6 changes: 3 additions & 3 deletions src/qcodes/interactive_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = "?"
Expand Down
4 changes: 2 additions & 2 deletions src/qcodes/logger/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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).astimezone().strftime("%y%m%d")
python_log_name = f"{dt_str}-{pid}-{PYTHON_LOG_NAME}"
return python_log_name

Expand Down
6 changes: 3 additions & 3 deletions src/qcodes/parameters/cache.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/qcodes/parameters/parameter_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading