Skip to content
Open
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
1 change: 1 addition & 0 deletions python/changelog.d/857.fix.md
Original file line number Diff line number Diff line change
@@ -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`.
72 changes: 72 additions & 0 deletions python/lib/sift_client/_tests/sift_types/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = []
Expand Down
13 changes: 10 additions & 3 deletions python/lib/sift_client/sift_types/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
),
Expand Down
Loading