From 3b8671968f83259370d9c3b169783ecef27c4c3f Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Thu, 25 Jun 2026 10:16:55 +0200 Subject: [PATCH 1/4] fix: adding tzinfo utc to base_datetime --- cuvis/Measurement.py | 3 +-- cuvis/cuvis_aux.py | 3 +-- tests/test_measurement.py | 12 +++++++++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 0aebefe..32075e6 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -17,8 +17,7 @@ import cuvis.cuvis_types as internal - -base_datetime = datetime.datetime(1970, 1, 1) +base_datetime = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) class Measurement(object): diff --git a/cuvis/cuvis_aux.py b/cuvis/cuvis_aux.py index 6aa1262..7d86abf 100644 --- a/cuvis/cuvis_aux.py +++ b/cuvis/cuvis_aux.py @@ -3,8 +3,7 @@ from ._cuvis_il import cuvis_il import logging import datetime - -base_datetime = datetime.datetime(1970, 1, 1) +base_datetime = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) def _fn_bits(n): diff --git a/tests/test_measurement.py b/tests/test_measurement.py index 72ee40b..e75a550 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -18,9 +18,19 @@ 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_integration_time(test_measurement): From 64de44d37eb090e25b0a8707f3c54526dba426b3 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 19 Aug 2026 14:03:58 +0200 Subject: [PATCH 2/4] chore: run ruff format --- cuvis/Measurement.py | 1 + cuvis/cuvis_aux.py | 1 + tests/test_measurement.py | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 32075e6..6b06149 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -17,6 +17,7 @@ import cuvis.cuvis_types as internal + base_datetime = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) diff --git a/cuvis/cuvis_aux.py b/cuvis/cuvis_aux.py index 7d86abf..e8245c8 100644 --- a/cuvis/cuvis_aux.py +++ b/cuvis/cuvis_aux.py @@ -3,6 +3,7 @@ from ._cuvis_il import cuvis_il import logging import datetime + base_datetime = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) diff --git a/tests/test_measurement.py b/tests/test_measurement.py index e75a550..599fbb1 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -30,7 +30,8 @@ def test_measurement_capture_time(test_measurement): 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) + 2023, 11, 24, 11, 13, 5, 356000, tzinfo=datetime.timezone.utc + ) def test_measurement_integration_time(test_measurement): From 7ac12fb9615d6ab180c14afcf97142026939e428 Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 19 Aug 2026 16:25:34 +0200 Subject: [PATCH 3/4] fixup the utc datetime fix --- CHANGELOG.md | 4 ++++ cuvis/Measurement.py | 19 ++++++++--------- cuvis/cuvis_aux.py | 14 ++++++++----- tests/conftest.py | 11 ++++++++++ tests/test_calibration.py | 26 +++++++++++++++++++++++ tests/test_cuvis_aux.py | 44 +++++++++++++++++++++++++++++++++++++++ tests/test_measurement.py | 20 ++++++++++++++++++ 7 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tests/test_calibration.py create mode 100644 tests/test_cuvis_aux.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0390f..540975c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ 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. +- `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. ### Removed diff --git a/cuvis/Measurement.py b/cuvis/Measurement.py index 6b06149..9bfc876 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, tzinfo=datetime.timezone.utc) - 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,8 @@ def comment(self, comment: str) -> None: pass @property - def factory_calibration(self) -> datetime.datetime: + def factory_calibration(self) -> Optional[datetime.datetime]: + """Timezone-aware UTC instant, or None if the SDK reported a value datetime cannot hold.""" return self._factory_calibration @property diff --git a/cuvis/cuvis_aux.py b/cuvis/cuvis_aux.py index e8245c8..d32894a 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, tzinfo=datetime.timezone.utc) +_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): @@ -76,7 +81,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 +110,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 +132,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..ee1e546 --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,26 @@ +""" +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_value(test_calibration): + """Test the calibration date has the expected exact UTC value.""" + assert test_calibration.info.calibration_date == datetime.datetime( + 2023, 7, 26, 23, 0, tzinfo=datetime.timezone.utc + ) 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 599fbb1..c45efe8 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -34,6 +34,26 @@ def test_measurement_capture_time_value(test_measurement): ) +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): """Test integration time is positive.""" integration_time = test_measurement.integration_time From 97d0c88986cffacabb3fd5ab0a3850d8b41f15cd Mon Sep 17 00:00:00 2001 From: Simon Birkholz Date: Wed, 19 Aug 2026 16:51:38 +0200 Subject: [PATCH 4/4] docs: calibration date only carry meanings for their date components --- CHANGELOG.md | 2 ++ cuvis/Measurement.py | 8 +++++++- cuvis/cuvis_aux.py | 3 +++ tests/test_calibration.py | 17 ++++++++++++----- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540975c..414bfb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,9 @@ Pre-releases (`b*`, `rc*`) are not listed. - `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 9bfc876..a7ff1b3 100644 --- a/cuvis/Measurement.py +++ b/cuvis/Measurement.py @@ -179,7 +179,13 @@ def comment(self, comment: str) -> None: @property def factory_calibration(self) -> Optional[datetime.datetime]: - """Timezone-aware UTC instant, or None if the SDK reported a value datetime cannot hold.""" + """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 d32894a..e303eaf 100644 --- a/cuvis/cuvis_aux.py +++ b/cuvis/cuvis_aux.py @@ -53,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 diff --git a/tests/test_calibration.py b/tests/test_calibration.py index ee1e546..d06f217 100644 --- a/tests/test_calibration.py +++ b/tests/test_calibration.py @@ -19,8 +19,15 @@ def test_calibration_info_calibration_date_is_utc_aware(test_calibration): assert calibration_date.utcoffset() == datetime.timedelta(0) -def test_calibration_info_calibration_date_value(test_calibration): - """Test the calibration date has the expected exact UTC value.""" - assert test_calibration.info.calibration_date == datetime.datetime( - 2023, 7, 26, 23, 0, tzinfo=datetime.timezone.utc - ) +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)