diff --git a/python/changelog.d/857.fix.md b/python/changelog.d/857.fix.md new file mode 100644 index 000000000..064d53b00 --- /dev/null +++ b/python/changelog.d/857.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 58695efdf..9950f9145 100644 --- a/python/lib/sift_client/_tests/sift_types/test_run.py +++ b/python/lib/sift_client/_tests/sift_types/test_run.py @@ -3,8 +3,15 @@ from datetime import datetime, timezone from unittest.mock import MagicMock +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 @@ -131,9 +138,74 @@ 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"] + index = frame.index + assert isinstance(index, pd.DatetimeIndex) + return index + + class TestRun: """Unit tests for Run model - tests properties and methods.""" + def test_bounds_match_channel_timestamp_resolution(self): + """A time axis from run bounds matches the channel index for the same timestamps. + + ``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) + 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) + channel_index = _channel_index(start, stop) + axis = pd.date_range(run.start_time, run.stop_time, periods=2) + + 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", + 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 b61968d7b..342ec21cb 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 ),