Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ Pre-releases (`b*`, `rc*`) are not listed.
- `cuvis.General.init` - parameter `settings_path` type changed from `str` to `Union[str, Path, SdkSettings]`.
An `SdkSettings` is written to a temporary directory that exists only for the duration of the call, since the SDK reads the settings once during initialisation.
- `cuvis.FileWriteSettings.GeneralExportSettings.__repr__`, `cuvis.FileWriteSettings.ViewerSettings.__repr__` - the docstring that sat below the nested helper, where it was a dead expression rather than a docstring, moved to the top of the method.
- `cuvis.Measurement.capture_time`, `cuvis.Measurement.factory_calibration`, `cuvis.GPSData.time`, `cuvis.SensorInfo.readout_time` - type changed from a naive `datetime.datetime` to one carrying `tzinfo=datetime.timezone.utc`.
The instant is unchanged, only the `+00:00` label is added; comparing or subtracting against a naive `datetime` now raises `TypeError`, so use `datetime.datetime.now(datetime.timezone.utc)` or `.astimezone()` for local time.
- `cuvis.CalibrationInfo.calibration_date` - type changed from `int` to a `datetime.datetime` carrying `tzinfo=datetime.timezone.utc`; the field was annotated as a `datetime` but returned the raw epoch milliseconds unconverted.
The SDK derives this value as midnight on the calibration day in the host's standard local time, so unlike the other timestamps the instant it denotes shifts with the reading machine; treat it as a day, not as an exact moment.
- `cuvis.Measurement.factory_calibration` - type changed from `datetime.datetime` to `Optional[datetime.datetime]`, matching the existing fallback to `None` for SDK values the `datetime` range cannot represent.
Only the day carries meaning: the SDK stores it as midnight in the local time of the machine that wrote the file, so the time component is an artifact of that machine and the day can be off by one when the file is read in another timezone.

### Removed

Expand Down
25 changes: 15 additions & 10 deletions cuvis/Measurement.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Union
from typing import Optional, Union
from .FileWriteSettings import SaveArgs
import datetime
from pathlib import Path
Expand All @@ -11,22 +11,21 @@
MeasurementFlags,
SensorInfo,
GPSData,
_utc_from_epoch_ms,
)
from .cuvis_types import DataFormat, ProcessingMode, ReferenceType
from .cube_utils import ImageData


import cuvis.cuvis_types as internal

base_datetime = datetime.datetime(1970, 1, 1)


class Measurement(object):
capture_time: datetime.datetime # read-only
measurement_flags: MeasurementFlags # read-only
path: str # read-only
comment: str
factory_calibration: datetime.datetime # read-only
factory_calibration: Optional[datetime.datetime] # read-only
assembly: str # read-only
integration_time: int # read-only
averages: int # read-only
Expand Down Expand Up @@ -67,15 +66,13 @@ def _refresh_metadata(self):
):
raise SDKException

self._capture_time = base_datetime + datetime.timedelta(
milliseconds=_metaData.capture_time
)
self._capture_time = _utc_from_epoch_ms(_metaData.capture_time)
self._measurement_flags = MeasurementFlags(_metaData.measurement_flags)
self._path = _metaData.path
self._comment = _metaData.comment
try:
self._factory_calibration = base_datetime + datetime.timedelta(
milliseconds=_metaData.factory_calibration
self._factory_calibration = _utc_from_epoch_ms(
_metaData.factory_calibration
)
except OverflowError:
self._factory_calibration = None
Expand Down Expand Up @@ -156,6 +153,7 @@ def save(self, saveargs: SaveArgs) -> None:

@property
def capture_time(self) -> datetime.datetime:
"""Timezone-aware UTC instant; comparing it against a naive datetime raises TypeError."""
return self._capture_time

@property
Expand All @@ -180,7 +178,14 @@ def comment(self, comment: str) -> None:
pass

@property
def factory_calibration(self) -> datetime.datetime:
def factory_calibration(self) -> Optional[datetime.datetime]:
"""The calibration day, or None if the SDK reported a value datetime cannot hold.

Timezone-aware UTC, but only the day carries meaning. The SDK stores the day as
midnight in the local time of the machine that wrote the file, so the time
component is an artifact of that machine and the day can be off by one when the
file is read in another timezone.
"""
return self._factory_calibration

@property
Expand Down
17 changes: 12 additions & 5 deletions cuvis/cuvis_aux.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
import logging
import datetime

base_datetime = datetime.datetime(1970, 1, 1)
_EPOCH_UTC = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)


def _utc_from_epoch_ms(milliseconds: int) -> datetime.datetime:
"""The SDK reports instants as milliseconds since the Unix epoch in UTC."""
return _EPOCH_UTC + datetime.timedelta(milliseconds=milliseconds)


def _fn_bits(n):
Expand Down Expand Up @@ -48,6 +53,9 @@ def __repr__(self):
class CalibrationInfo(object):
model_name: str
serial_no: str
# Only the day carries meaning. The SDK derives this as midnight in the reading
# machine's standard local time, so the time component is an artifact and the
# value shifts by the local UTC offset from one host to the next.
calibration_date: datetime.datetime
annotation_name: str
unique_id: str
Expand Down Expand Up @@ -76,7 +84,7 @@ def _from_internal(cls, ci: cuvis_il.cuvis_calibration_info_t):
return cls(
ci.model_name,
ci.serial_no,
ci.calibration_date,
_utc_from_epoch_ms(ci.calibration_date),
ci.annotation_name,
ci.unique_id,
ci.file_path,
Expand Down Expand Up @@ -105,7 +113,7 @@ def _from_internal(cls, gps):
longitude=gps.longitude,
latitude=gps.latitude,
altitude=gps.altitude,
time=base_datetime + datetime.timedelta(milliseconds=gps.time),
time=_utc_from_epoch_ms(gps.time),
)


Expand All @@ -127,8 +135,7 @@ def _from_internal(cls, info):
averages=info.averages,
temperature=info.temperature,
gain=info.gain,
readout_time=base_datetime
+ datetime.timedelta(milliseconds=info.readout_time),
readout_time=_utc_from_epoch_ms(info.readout_time),
width=info.width,
height=info.height,
raw_frame_id=info.raw_frame_id,
Expand Down
11 changes: 11 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ def test_measurement(test_session_file):
return test_session_file.get_measurement(0)


@pytest.fixture(scope="session")
def test_calibration(test_session_file):
"""
Load the Calibration of the Test session once per session.
"""
calibration = cuvis.Calibration(test_session_file)
yield calibration
del calibration
gc.collect()


@pytest.fixture
def processing_context_from_session(test_session_file):
"""
Expand Down
33 changes: 33 additions & 0 deletions tests/test_calibration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Tests for cuvis.Calibration module.

Covers the CalibrationInfo fields read from the bundled test session.
"""

import datetime


def test_calibration_info_calibration_date_is_datetime(test_calibration):
"""Test the calibration date is converted instead of returned as raw epoch milliseconds."""
assert isinstance(test_calibration.info.calibration_date, datetime.datetime)


def test_calibration_info_calibration_date_is_utc_aware(test_calibration):
"""Test the calibration date is a timezone-aware UTC datetime (see cuvis.pyil#29)."""
calibration_date = test_calibration.info.calibration_date
assert calibration_date.tzinfo is not None
assert calibration_date.utcoffset() == datetime.timedelta(0)


def test_calibration_info_calibration_date_day(test_calibration):
"""Test the calibration date lands on the expected day.

The SDK derives this value as midnight on the calibration day in the host's
standard local time, so the instant shifts with the machine running the test
and only the day can be pinned portably. Standard offsets span UTC-12 to
UTC+14, which bounds the deviation at 14 hours.
"""
assert abs(
test_calibration.info.calibration_date
- datetime.datetime(2023, 7, 27, tzinfo=datetime.timezone.utc)
) <= datetime.timedelta(hours=14)
44 changes: 44 additions & 0 deletions tests/test_cuvis_aux.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Tests for cuvis.cuvis_aux helpers.

Covers the shared epoch conversion and the GPSData timestamp, which the bundled
test session carries no record for.
"""

import datetime
from types import SimpleNamespace

import pytest

from cuvis.cuvis_aux import GPSData, _utc_from_epoch_ms

CAPTURE_TIME_MS = 1700824385356
CAPTURE_TIME = datetime.datetime(
2023, 11, 24, 11, 13, 5, 356000, tzinfo=datetime.timezone.utc
)


@pytest.mark.parametrize(
"milliseconds, expected",
[
(0, datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)),
(CAPTURE_TIME_MS, CAPTURE_TIME),
],
ids=["epoch", "capture_time"],
)
def test_utc_from_epoch_ms(milliseconds, expected):
"""Test epoch milliseconds convert to the expected timezone-aware UTC datetime."""
assert _utc_from_epoch_ms(milliseconds) == expected
assert _utc_from_epoch_ms(milliseconds).utcoffset() == datetime.timedelta(0)


def test_gps_data_time_is_utc_aware():
"""Test the GPS timestamp is a timezone-aware UTC datetime (see cuvis.pyil#29)."""
gps = GPSData._from_internal(
SimpleNamespace(
longitude=9.9937, latitude=48.4011, altitude=478.0, time=CAPTURE_TIME_MS
)
)
assert gps.time.tzinfo is not None
assert gps.time.utcoffset() == datetime.timedelta(0)
assert gps.time == CAPTURE_TIME
33 changes: 32 additions & 1 deletion tests/test_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,40 @@ def test_measurement_metadata_attributes(test_measurement):


def test_measurement_capture_time(test_measurement):
"""Test capture time is a datetime object."""
"""Test capture time is a timezone-aware UTC datetime object."""
capture_time = test_measurement.capture_time
assert isinstance(capture_time, datetime.datetime)
# capture_time originates from a UTC epoch timestamp and must be
# explicitly marked as UTC (see issue cuvis.pyil#29).
assert capture_time.tzinfo is not None
assert capture_time.utcoffset() == datetime.timedelta(0)


def test_measurement_capture_time_value(test_measurement):
"""Test capture time has the expected exact UTC value (see cuvis.pyil#29)."""
assert test_measurement.capture_time == datetime.datetime(
2023, 11, 24, 11, 13, 5, 356000, tzinfo=datetime.timezone.utc
)


def test_measurement_factory_calibration_is_utc_aware(test_measurement):
"""Test factory calibration is a timezone-aware UTC datetime with the expected value."""
factory_calibration = test_measurement.factory_calibration
assert isinstance(factory_calibration, datetime.datetime)
assert factory_calibration.tzinfo is not None
assert factory_calibration.utcoffset() == datetime.timedelta(0)
assert factory_calibration == datetime.datetime(
2023, 7, 26, 23, 0, tzinfo=datetime.timezone.utc
)


def test_measurement_sensor_info_readout_time_is_utc_aware(test_measurement):
"""Test the sensor readout time is timezone-aware UTC and matches the capture time."""
readout_time = test_measurement.data["IMAGE_info"].readout_time
assert isinstance(readout_time, datetime.datetime)
assert readout_time.tzinfo is not None
assert readout_time.utcoffset() == datetime.timedelta(0)
assert readout_time == test_measurement.capture_time


def test_measurement_integration_time(test_measurement):
Expand Down