diff --git a/plottr/data/qcodes_dataset.py b/plottr/data/qcodes_dataset.py index 551aa160..b05a0ae3 100644 --- a/plottr/data/qcodes_dataset.py +++ b/plottr/data/qcodes_dataset.py @@ -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() return dt.strftime('%Y-%m-%d'), dt.strftime('%H:%M:%S') - except (ValueError, TypeError): + except (ValueError, TypeError, OSError, OverflowError): return '', '' diff --git a/test/pytest/test_qcodes_data.py b/test/pytest/test_qcodes_data.py index a188c0b6..d66c3654 100644 --- a/test/pytest/test_qcodes_data.py +++ b/test/pytest/test_qcodes_data.py @@ -1,3 +1,6 @@ +import datetime +import time + import numpy as np import pytest from packaging import version @@ -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') @@ -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 @@ -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, diff --git a/test_requirements.txt b/test_requirements.txt index 4b58a050..0a59f9e4 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -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 \ No newline at end of file