From ec5fb7f8ff6a3ed6b92151cd52440a2b9c65c4df Mon Sep 17 00:00:00 2001 From: Ian Later Date: Wed, 28 Oct 2026 13:01:20 -0700 Subject: [PATCH 1/4] python(fix): return run bounds as nanosecond timestamps A time axis built from Run.start_time used microsecond integers while channel data uses nanoseconds, so interpolation produced NaN. --- .../+run-timestamp-resolution.fix.md | 1 + .../sift_client/_tests/sift_types/test_run.py | 54 +++++++++++++++++++ python/lib/sift_client/sift_types/run.py | 13 +++-- 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 python/changelog.d/+run-timestamp-resolution.fix.md diff --git a/python/changelog.d/+run-timestamp-resolution.fix.md b/python/changelog.d/+run-timestamp-resolution.fix.md new file mode 100644 index 0000000000..064d53b008 --- /dev/null +++ b/python/changelog.d/+run-timestamp-resolution.fix.md @@ -0,0 +1 @@ +`Run.start_time` and `Run.stop_time` are nanosecond timestamps, matching channel data, so a time axis built from a run lines up with `get_data`. diff --git a/python/lib/sift_client/_tests/sift_types/test_run.py b/python/lib/sift_client/_tests/sift_types/test_run.py index 58695efdf1..3f845a54ae 100644 --- a/python/lib/sift_client/_tests/sift_types/test_run.py +++ b/python/lib/sift_client/_tests/sift_types/test_run.py @@ -3,7 +3,10 @@ from datetime import datetime, timezone from unittest.mock import MagicMock +import pandas as pd import pytest +from google.protobuf.timestamp_pb2 import Timestamp +from sift.runs.v2.runs_pb2 import Run as RunProto from sift_client.sift_types import Run from sift_client.sift_types.run import RunCreate, RunUpdate @@ -134,6 +137,57 @@ def mock_run(mock_client): class TestRun: """Unit tests for Run model - tests properties and methods.""" + def test_bounds_match_channel_timestamp_resolution(self): + """Run bounds are nanosecond timestamps, matching channel data. + + ``stop_time`` is an exact second. That is the case where a + microsecond ``datetime`` and a nanosecond timestamp print the + same and still disagree as integers. + """ + start = Timestamp(seconds=1735689600, nanos=123456789) + stop = Timestamp(seconds=1735689601, nanos=0) + proto = RunProto( + run_id="r", + name="n", + description="", + created_by_user_id="u", + modified_by_user_id="u", + organization_id="o", + ) + proto.created_date.FromDatetime(datetime(2025, 1, 1, tzinfo=timezone.utc)) + proto.modified_date.CopyFrom(proto.created_date) + proto.start_time.CopyFrom(start) + proto.stop_time.CopyFrom(stop) + + run = Run._from_proto(proto) + + assert isinstance(run.start_time, pd.Timestamp) + assert isinstance(run.stop_time, pd.Timestamp) + assert run.start_time.unit == "ns" + assert run.stop_time.unit == "ns" + assert run.start_time.nanosecond == 789 + assert run.stop_time.nanosecond == 0 + start_index = pd.DatetimeIndex([run.start_time]) + assert str(start_index.dtype).startswith("datetime64[ns") + assert int(start_index.asi8[0]) == 1735689600 * 1_000_000_000 + 123456789 + axis = pd.date_range(run.start_time, run.stop_time, periods=2) + assert str(axis.dtype).startswith("datetime64[ns") + assert int(axis.asi8[0]) == int(start_index.asi8[0]) + + unset = RunProto( + run_id="r", + name="n", + description="", + created_by_user_id="u", + modified_by_user_id="u", + organization_id="o", + ) + unset.created_date.CopyFrom(proto.created_date) + unset.modified_date.CopyFrom(proto.modified_date) + unbounded = Run._from_proto(unset) + assert unbounded.start_time is None + assert unbounded.stop_time is None + def test_assets_property_calls_client(self, mock_run, mock_client): """Test that assets property calls client.assets.list_ with correct parameters.""" mock_client.assets.list_.return_value = [] diff --git a/python/lib/sift_client/sift_types/run.py b/python/lib/sift_client/sift_types/run.py index b61968d7b8..342ec21cb1 100644 --- a/python/lib/sift_client/sift_types/run.py +++ b/python/lib/sift_client/sift_types/run.py @@ -7,6 +7,7 @@ from sift.runs.v2.runs_pb2 import CreateRunRequest as CreateRunRequestProto from sift.runs.v2.runs_pb2 import Run as RunProto +from sift_client._internal.time import to_timestamp_nanos from sift_client.sift_types._base import ( BaseType, MappingHelper, @@ -31,7 +32,13 @@ class Run(BaseType[RunProto, "Run"], FileAttachmentsMixin): - """Run model representing a data collection run.""" + """Run model representing a data collection run. + + ``start_time`` and ``stop_time`` are nanosecond ``pandas.Timestamp`` + values, the same resolution as channel data from ``get_data``. A time + axis built from these lines up with that data when both are read as + integers. + """ # Required fields name: str @@ -68,12 +75,12 @@ def _from_proto(cls, proto: RunProto, sift_client: SiftClient | None = None) -> modified_by_user_id=proto.modified_by_user_id, organization_id=proto.organization_id, start_time=( - proto.start_time.ToDatetime(tzinfo=timezone.utc) + to_timestamp_nanos(proto.start_time).as_unit("ns") if proto.HasField("start_time") else None ), stop_time=( - proto.stop_time.ToDatetime(tzinfo=timezone.utc) + to_timestamp_nanos(proto.stop_time).as_unit("ns") if proto.HasField("stop_time") else None ), From 1f6892806fd44a179bb9adb9c731782891995edd Mon Sep 17 00:00:00 2001 From: Ian Later Date: Wed, 28 Oct 2026 13:01:39 -0700 Subject: [PATCH 2/4] python(chore): name the changelog fragment after the pull request --- .../changelog.d/{+run-timestamp-resolution.fix.md => 857.fix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename python/changelog.d/{+run-timestamp-resolution.fix.md => 857.fix.md} (100%) diff --git a/python/changelog.d/+run-timestamp-resolution.fix.md b/python/changelog.d/857.fix.md similarity index 100% rename from python/changelog.d/+run-timestamp-resolution.fix.md rename to python/changelog.d/857.fix.md From b579acfe74722e225f13fe6285d2a16b9c89cb9f Mon Sep 17 00:00:00 2001 From: Ian Later Date: Wed, 28 Oct 2026 14:49:08 -0700 Subject: [PATCH 3/4] python(fix): compare run bounds with the channel timestamp index --- .../sift_client/_tests/sift_types/test_run.py | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/python/lib/sift_client/_tests/sift_types/test_run.py b/python/lib/sift_client/_tests/sift_types/test_run.py index 3f845a54ae..1623cf0b62 100644 --- a/python/lib/sift_client/_tests/sift_types/test_run.py +++ b/python/lib/sift_client/_tests/sift_types/test_run.py @@ -5,9 +5,13 @@ import pandas as pd import pytest +from google.protobuf.any_pb2 import Any from google.protobuf.timestamp_pb2 import Timestamp +from sift.common.type.v1.channel_data_type_pb2 import CHANNEL_DATA_TYPE_DOUBLE +from sift.data.v2.data_pb2 import DoubleValue, DoubleValues, Metadata from sift.runs.v2.runs_pb2 import Run as RunProto +from sift_client._internal.low_level_wrappers.data import DataLowLevelClient from sift_client.sift_types import Run from sift_client.sift_types.run import RunCreate, RunUpdate @@ -134,15 +138,34 @@ def mock_run(mock_client): return run +def _channel_index(*timestamps: Timestamp) -> pd.DatetimeIndex: + """Index ``get_data`` builds for double-channel samples at these times.""" + values = DoubleValues( + metadata=Metadata( + data_type=CHANNEL_DATA_TYPE_DOUBLE, + sampled_ms=0, + channel=Metadata.Channel(name="voltage"), + ), + values=[DoubleValue(timestamp=ts, value=1.0) for ts in timestamps], + ) + packed = Any() + packed.Pack(values) + # ``Pack`` writes ``type.googleapis.com/...``. The data parser reads the + # ``sift.data`` prefix the API sends. + packed.type_url = "sift.data.v2.DoubleValues" + frame = DataLowLevelClient.try_deserialize_channel_data(packed)["voltage"] + return frame.index + + class TestRun: """Unit tests for Run model - tests properties and methods.""" def test_bounds_match_channel_timestamp_resolution(self): - """Run bounds are nanosecond timestamps, matching channel data. + """A time axis from run bounds matches the channel index for the same timestamps. - ``stop_time`` is an exact second. That is the case where a - microsecond ``datetime`` and a nanosecond timestamp print the - same and still disagree as integers. + ``stop_time`` is an exact second. That instant prints the same as a + microsecond ``datetime`` and still disagrees when both are read as + integers. """ start = Timestamp(seconds=1735689600, nanos=123456789) stop = Timestamp(seconds=1735689601, nanos=0) @@ -160,19 +183,12 @@ def test_bounds_match_channel_timestamp_resolution(self): proto.stop_time.CopyFrom(stop) run = Run._from_proto(proto) - - assert isinstance(run.start_time, pd.Timestamp) - assert isinstance(run.stop_time, pd.Timestamp) - assert run.start_time.unit == "ns" - assert run.stop_time.unit == "ns" - assert run.start_time.nanosecond == 789 - assert run.stop_time.nanosecond == 0 - start_index = pd.DatetimeIndex([run.start_time]) - assert str(start_index.dtype).startswith("datetime64[ns") - assert int(start_index.asi8[0]) == 1735689600 * 1_000_000_000 + 123456789 + channel_index = _channel_index(start, stop) axis = pd.date_range(run.start_time, run.stop_time, periods=2) - assert str(axis.dtype).startswith("datetime64[ns") - assert int(axis.asi8[0]) == int(start_index.asi8[0]) + + assert str(channel_index.dtype).startswith("datetime64[ns") + assert axis.dtype == channel_index.dtype + assert [int(value) for value in axis.asi8] == [int(value) for value in channel_index.asi8] unset = RunProto( run_id="r", From b4e251c449a3550c2b2702ebf2649f2a37d2219b Mon Sep 17 00:00:00 2001 From: Ian Later Date: Wed, 28 Oct 2026 14:52:53 -0700 Subject: [PATCH 4/4] python(fix): narrow the channel index to a DatetimeIndex --- python/lib/sift_client/_tests/sift_types/test_run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python/lib/sift_client/_tests/sift_types/test_run.py b/python/lib/sift_client/_tests/sift_types/test_run.py index 1623cf0b62..9950f9145e 100644 --- a/python/lib/sift_client/_tests/sift_types/test_run.py +++ b/python/lib/sift_client/_tests/sift_types/test_run.py @@ -154,7 +154,9 @@ def _channel_index(*timestamps: Timestamp) -> pd.DatetimeIndex: # ``sift.data`` prefix the API sends. packed.type_url = "sift.data.v2.DoubleValues" frame = DataLowLevelClient.try_deserialize_channel_data(packed)["voltage"] - return frame.index + index = frame.index + assert isinstance(index, pd.DatetimeIndex) + return index class TestRun: