diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0390f..414bfb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 0aebefe..a7ff1b3 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from .FileWriteSettings import SaveArgs import datetime from pathlib import Path @@ -11,6 +11,7 @@ MeasurementFlags, SensorInfo, GPSData, + _utc_from_epoch_ms, ) from .cuvis_types import DataFormat, ProcessingMode, ReferenceType from .cube_utils import ImageData @@ -18,15 +19,13 @@ 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 @@ -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 @@ -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 @@ -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 diff --git a/cuvis/cuvis_aux.py b/cuvis/cuvis_aux.py index 6aa1262..e303eaf 100644 --- a/cuvis/cuvis_aux.py +++ b/cuvis/cuvis_aux.py @@ -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): @@ -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 @@ -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, @@ -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), ) @@ -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, diff --git a/tests/conftest.py b/tests/conftest.py index 480beb1..2a2179a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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): """ diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100644 index 0000000..d06f217 --- /dev/null +++ b/tests/test_calibration.py @@ -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) diff --git a/tests/test_cuvis_aux.py b/tests/test_cuvis_aux.py new file mode 100644 index 0000000..5862c07 --- /dev/null +++ b/tests/test_cuvis_aux.py @@ -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 diff --git a/tests/test_measurement.py b/tests/test_measurement.py index 72ee40b..c45efe8 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -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):