Skip to content
18 changes: 14 additions & 4 deletions plottr/data/qcodes_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,28 @@ def _get_names_of_standalone_parameters(paramspecs: List['ParamSpec']
def _split_timestamp(ts: Optional[str]) -> Tuple[str, str]:
"""Split a qcodes timestamp string into (date, time) components.

Uses datetime parsing instead of string slicing for robustness.
Uses datetime parsing instead of string slicing for robustness. This
handles both the legacy qcodes timestamp format (``"YYYY-MM-DD HH:MM:SS"``)
and the newer one that includes the UTC offset of the local timezone
(``"YYYY-MM-DD HH:MM:SS%z"``, e.g. ``"2026-07-31 10:27:25+0200"``).

:param ts: timestamp string as returned by ``ds.run_timestamp()``
(typically ``"YYYY-MM-DD HH:MM:SS"``), or None.
Timezone-aware timestamps are converted to the local timezone of the
machine running plottr before formatting, so the rendered date and time
always reflect the local (PC) timezone. The UTC offset itself is dropped
from the returned components.

:param ts: timestamp string as returned by ``ds.run_timestamp()``, or None.
:returns: (date_str, time_str) or ('', '') if ts is None or unparsable.
"""
if ts is None:
return '', ''
try:
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
# Render in the local timezone of the machine running plottr.
dt = dt.astimezone()
Comment thread
astafan8 marked this conversation as resolved.
return dt.strftime('%Y-%m-%d'), dt.strftime('%H:%M:%S')
except (ValueError, TypeError):
except (ValueError, TypeError, OSError, OverflowError):
return '', ''


Expand Down
80 changes: 72 additions & 8 deletions test/pytest/test_qcodes_data.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import datetime
import time

import numpy as np
import pytest
from packaging import version
Expand All @@ -13,7 +16,8 @@
get_ds_structure,
get_ds_info,
get_runs_from_db,
ds_to_datadict)
ds_to_datadict,
_split_timestamp)


@pytest.fixture(scope='function')
Expand Down Expand Up @@ -180,6 +184,63 @@ def test_get_ds_structure(experiment):
assert structure == expected_structure


def test_split_timestamp_naive():
# The legacy qcodes timestamp format has no timezone information and is
# returned as-is (split into date and time). Expected values are hard-coded
# so this does not depend on the helper's own logic.
assert _split_timestamp("2026-07-31 10:27:25") == ("2026-07-31", "10:27:25")


def test_split_timestamp_none_and_invalid():
assert _split_timestamp(None) == ("", "")
assert _split_timestamp("") == ("", "")
assert _split_timestamp("not a timestamp") == ("", "")


def test_split_timestamp_timezone_aware_rendered_in_local_time():
# The new qcodes timestamp format includes the UTC offset. Such timestamps
# must be converted to the local timezone of the machine before being split.
# We compute the expected local date/time independently from the helper:
# take a fixed absolute instant, convert it to local wall-clock time via
# ``datetime.fromtimestamp`` (a different code path than the helper), and
# verify the helper agrees for the same instant given in different offsets.
utc_instant = datetime.datetime(2026, 7, 31, 8, 27, 25,
tzinfo=datetime.timezone.utc)
local = datetime.datetime.fromtimestamp(utc_instant.timestamp())
expected = (local.strftime("%Y-%m-%d"), local.strftime("%H:%M:%S"))

# Same instant expressed as UTC (+00:00) and as +02:00.
assert _split_timestamp("2026-07-31 08:27:25+00:00") == expected
assert _split_timestamp("2026-07-31 10:27:25+02:00") == expected
# qcodes renders the offset without a colon (e.g. "+0000"); ensure that
# format is handled too.
assert _split_timestamp("2026-07-31 08:27:25+0000") == expected


def test_split_timestamp_timezone_aware_crosses_date_boundary(monkeypatch):
# Converting to local time can push the timestamp onto a different
# calendar date than the one written in the input string. The local
# timezone is pinned explicitly so the boundary crossing is guaranteed
# regardless of the host machine's own timezone. Expected values are
# again derived independently via ``datetime.fromtimestamp``.
if not hasattr(time, "tzset"):
pytest.skip("time.tzset is not available on this platform")

monkeypatch.setenv("TZ", "Europe/Berlin")
time.tzset()
try:
utc_instant = datetime.datetime(2026, 7, 31, 23, 30, 0,
tzinfo=datetime.timezone.utc)
local = datetime.datetime.fromtimestamp(utc_instant.timestamp())
expected = (local.strftime("%Y-%m-%d"), local.strftime("%H:%M:%S"))

# Same instant, expressed with a +02:00 offset, which shifts the
# calendar date in the input string to the next day.
assert _split_timestamp("2026-08-01 01:30:00+02:00") == expected
finally:
time.tzset()


def test_get_ds_info(experiment):
N = 5

Expand All @@ -201,17 +262,20 @@ def test_get_ds_info(experiment):

# timestamps are difficult to test for, so we will cheat here and
# instead of hard-coding timestamps we will just get them from the dataset
# The same applies to the guid as it contains the timestamp
started_ts = dataset.run_timestamp()
completed_ts = dataset.completed_timestamp()
# The same applies to the guid as it contains the timestamp.
# To avoid testing ``get_ds_info`` against the very helper it uses
# (``_split_timestamp``), we derive the expected local date/time
# independently from the raw unix timestamps exposed by qcodes.
started = datetime.datetime.fromtimestamp(dataset.run_timestamp_raw)
completed = datetime.datetime.fromtimestamp(dataset.completed_timestamp_raw)

expected_ds_info = {
'experiment': '2d_softsweep',
'sample': 'no sample',
'completed_date': completed_ts[:10],
'completed_time': completed_ts[11:],
'started_date': started_ts[:10],
'started_time': started_ts[11:],
'completed_date': completed.strftime('%Y-%m-%d'),
'completed_time': completed.strftime('%H:%M:%S'),
'started_date': started.strftime('%Y-%m-%d'),
'started_time': started.strftime('%H:%M:%S'),
'name': 'results',
'structure': None,
'records': 0,
Expand Down
6 changes: 5 additions & 1 deletion test_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ qcodes
pytest
pytest-qt
hypothesis
mypy==2.3.1
# mypy >=2.2.0 crashes with an INTERNAL ERROR while following imports into
# qcodes' data_set_protocol.py (verified up to 2.3.1, the latest release as
# of writing). Pinned to 2.1.0, the newest version that doesn't crash; bump
# once upstream fixes this (see https://github.com/python/mypy/issues/21741).
mypy==2.1.0
PySide6-stubs
pandas-stubs
watchdog
Loading