From 0ec79a49d74c6a0d79ee209b406e88c86dccdd03 Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Tue, 24 Feb 2026 12:31:25 +0100 Subject: [PATCH 1/7] feat(asc): add timestamps_format parameter to ASCWriter Allow callers to choose between 'absolute' (default, existing behaviour) and 'relative' when creating an ASC log file. The value is written into the 'base hex timestamps ...' header line so that other tools (CANalyzer, CANoe, etc.) can interpret the file correctly. Closes #2022 --- can/io/asc.py | 21 ++++++++++++++++++-- test/logformats_test.py | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/can/io/asc.py b/can/io/asc.py index c02020e27..0e778743e 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -10,7 +10,7 @@ import re from collections.abc import Generator from datetime import datetime, timezone, tzinfo -from typing import Any, Final, TextIO +from typing import Any, Final, Literal, TextIO from ..message import Message from ..typechecking import StringPathLike @@ -372,6 +372,7 @@ def __init__( file: StringPathLike | TextIO, channel: int = 1, tz: tzinfo | None = _LOCAL_TZ, + timestamps_format: Literal["absolute", "relative"] = "absolute", **kwargs: Any, ) -> None: """ @@ -384,7 +385,22 @@ def __init__( have a channel set. Default is 1. :param tz: Timezone for timestamps in the log file. Defaults to local timezone. + :param timestamps_format: + the format of timestamps in the header. + Use ``"absolute"`` (default) so that readers can recover + the original wall-clock timestamps by combining the + per-message offset with the trigger-block start time. + Use ``"relative"`` when only the elapsed time from the + start of the recording matters and no absolute time + recovery is needed. + :raises ValueError: if *timestamps_format* is not ``"absolute"`` or + ``"relative"`` """ + if timestamps_format not in ("absolute", "relative"): + raise ValueError( + f"timestamps_format must be 'absolute' or 'relative', " + f"got {timestamps_format!r}" + ) if kwargs.get("append", False): raise ValueError( f"{self.__class__.__name__} is currently not equipped to " @@ -394,11 +410,12 @@ def __init__( self._timezone = tz self.channel = channel + self.timestamps_format = timestamps_format # write start of file header start_time = self._format_header_datetime(datetime.now(tz=self._timezone)) self.file.write(f"date {start_time}\n") - self.file.write("base hex timestamps absolute\n") + self.file.write(f"base hex timestamps {self.timestamps_format}\n") self.file.write("internal events logged\n") # the last part is written with the timestamp of the first message diff --git a/test/logformats_test.py b/test/logformats_test.py index 6839c2450..adeeb9c14 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -687,6 +687,49 @@ def test_write(self): self.assertEqual(expected_file.read_text(), actual_file.read_text()) + def test_write_timestamps_format_default_is_absolute(self): + """ASCWriter should write 'timestamps absolute' in the header by default.""" + with can.ASCWriter(self.test_file_name) as writer: + pass + + content = Path(self.test_file_name).read_text() + self.assertIn("timestamps absolute", content) + + def test_write_timestamps_format_relative(self): + """ASCWriter should write 'timestamps relative' when requested.""" + with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer: + pass + + content = Path(self.test_file_name).read_text() + self.assertIn("timestamps relative", content) + self.assertNotIn("timestamps absolute", content) + + def test_write_timestamps_format_invalid(self): + """ASCWriter should raise ValueError for an unsupported timestamps_format.""" + with self.assertRaises(ValueError): + can.ASCWriter(self.test_file_name, timestamps_format="unix") + + def test_write_relative_timestamp_roundtrip(self): + """Messages written with relative format round-trip with relative timestamps.""" + msgs = [ + can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), + can.Message(timestamp=100.5, arbitration_id=0x2, data=b"\x02"), + ] + + with can.ASCWriter( + self.test_file_name, timestamps_format="relative" + ) as writer: + for m in msgs: + writer.on_message_received(m) + + with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader: + result = list(reader) + + self.assertEqual(len(result), len(msgs)) + # With relative_timestamp=True timestamps are offsets from the first message + self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) + self.assertAlmostEqual(result[1].timestamp, 0.5, places=5) + @parameterized.expand( [ ( From 32c77e18bbab7348c366e54e61a1b161d62ac8d7 Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Tue, 24 Feb 2026 12:36:56 +0100 Subject: [PATCH 2/7] docs: add changelog fragment for #2022 --- doc/changelog.d/2022.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/2022.added.md diff --git a/doc/changelog.d/2022.added.md b/doc/changelog.d/2022.added.md new file mode 100644 index 000000000..4a79c2d4e --- /dev/null +++ b/doc/changelog.d/2022.added.md @@ -0,0 +1 @@ +Added `timestamps_format` parameter to `ASCWriter` to allow writing `relative` or `absolute` timestamps in the ASC file header. From a3db5ed52caa014b3b82e60149cf1c6875c46d1d Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Tue, 24 Feb 2026 13:25:26 +0100 Subject: [PATCH 3/7] style: apply black formatting to logformats_test.py --- test/logformats_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/logformats_test.py b/test/logformats_test.py index adeeb9c14..1cfe89a72 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -11,6 +11,7 @@ TODO: correctly set preserves_channel and adds_default_channel """ + import locale import logging import os @@ -716,9 +717,7 @@ def test_write_relative_timestamp_roundtrip(self): can.Message(timestamp=100.5, arbitration_id=0x2, data=b"\x02"), ] - with can.ASCWriter( - self.test_file_name, timestamps_format="relative" - ) as writer: + with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer: for m in msgs: writer.on_message_received(m) From fb59549552b2dc7b694f3a05366bf78915598b76 Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Wed, 25 Feb 2026 12:18:50 +0100 Subject: [PATCH 4/7] fix(asc): apply timestamps_format to actual written timestamps, not just header Previously, log_event() always subtracted self.started from every timestamp regardless of timestamps_format, meaning "relative" mode only changed the header line while writing identical data to "absolute" mode. Per the ASC format specification: - "absolute": each timestamp is an offset from the start of measurement - "relative": each timestamp is a delta from the preceding event Fix log_event() to compute per-event deltas when timestamps_format="relative", and update self.last_timestamp after each event so the next delta is correct. Also add two tests that verify the actual values written to the file differ between the two modes (3-message uneven spacing exposes the distinction at msg3: absolute writes 1.0, relative writes 0.7). Update changelog fragment to describe the semantic difference accurately. --- can/io/asc.py | 20 ++++++++++++--- doc/changelog.d/2022.added.md | 5 +++- test/logformats_test.py | 48 ++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/can/io/asc.py b/can/io/asc.py index 0e778743e..f49bb42ab 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -462,10 +462,22 @@ def log_event(self, message: str, timestamp: float | None = None) -> None: # Use last known timestamp if unknown if timestamp is None: timestamp = self.last_timestamp - # turn into relative timestamps if necessary - if timestamp >= self.started: - timestamp -= self.started - line = self.FORMAT_EVENT.format(timestamp=timestamp, message=message) + # Compute written timestamp based on configured format + if self.timestamps_format == "absolute": + # offsets from the start of measurement + written_timestamp = ( + timestamp - self.started if timestamp >= self.started else timestamp + ) + else: + # deltas from the preceding event + written_timestamp = ( + timestamp - self.last_timestamp + if timestamp >= self.last_timestamp + else 0.0 + ) + # Track last timestamp so the next event can compute its delta + self.last_timestamp = timestamp + line = self.FORMAT_EVENT.format(timestamp=written_timestamp, message=message) self.file.write(line) def on_message_received(self, msg: Message) -> None: diff --git a/doc/changelog.d/2022.added.md b/doc/changelog.d/2022.added.md index 4a79c2d4e..b8b8cd205 100644 --- a/doc/changelog.d/2022.added.md +++ b/doc/changelog.d/2022.added.md @@ -1 +1,4 @@ -Added `timestamps_format` parameter to `ASCWriter` to allow writing `relative` or `absolute` timestamps in the ASC file header. +Added `timestamps_format` parameter to `ASCWriter` to support configurable timestamp +format: `"absolute"` (default, timestamps are offsets from the start of measurement) +or `"relative"` (each timestamp is the delta from the preceding event), matching the +semantics described in the ASC format specification. diff --git a/test/logformats_test.py b/test/logformats_test.py index 1cfe89a72..a31c4e8bf 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -725,10 +725,56 @@ def test_write_relative_timestamp_roundtrip(self): result = list(reader) self.assertEqual(len(result), len(msgs)) - # With relative_timestamp=True timestamps are offsets from the first message + # Timestamps in file are per-event deltas; reader reads them as-is self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) self.assertAlmostEqual(result[1].timestamp, 0.5, places=5) + def test_write_relative_timestamps_are_per_event_deltas(self): + """With timestamps_format='relative', each written timestamp is a delta from the + preceding event (not an offset from measurement start).""" + msgs = [ + can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), + can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"), + can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"), + ] + + with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer: + for m in msgs: + writer.on_message_received(m) + + with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader: + result = list(reader) + + self.assertEqual(len(result), len(msgs)) + # msg1: 0.0 (delta from "Start of measurement" at same time) + # msg2: 0.3 (delta from msg1) + # msg3: 0.7 (delta from msg2 — NOT 1.0, which would be absolute offset) + self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) + self.assertAlmostEqual(result[1].timestamp, 0.3, places=5) + self.assertAlmostEqual(result[2].timestamp, 0.7, places=5) + + def test_write_absolute_timestamps_are_offsets_from_start(self): + """With timestamps_format='absolute' (default), each written timestamp is an + offset from the measurement start, not a per-event delta.""" + msgs = [ + can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), + can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"), + can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"), + ] + + with can.ASCWriter(self.test_file_name, timestamps_format="absolute") as writer: + for m in msgs: + writer.on_message_received(m) + + with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader: + result = list(reader) + + self.assertEqual(len(result), len(msgs)) + # All timestamps are offsets from the measurement start (100.0): + self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) + self.assertAlmostEqual(result[1].timestamp, 0.3, places=5) + self.assertAlmostEqual(result[2].timestamp, 1.0, places=5) + @parameterized.expand( [ ( From 66b307ba9ee5969682fff29cd66031213e017f1c Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Thu, 26 Feb 2026 12:39:11 +0100 Subject: [PATCH 5/7] fix(asc): address PR review feedback on timestamps_format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback received (zariiii9003): 1. Use Literal type hint for timestamps_format parameter - Changed: timestamps_format: str = "absolute" - To: timestamps_format: Literal["absolute", "relative"] = "absolute" - Added Literal to typing imports 2. Simplify log_event timestamp computation - Moved monotonic clamp out of if/else blocks: timestamp = max(timestamp, self.last_timestamp) - Each branch now contains only simple arithmetic: absolute: written_timestamp = timestamp - self.started relative: written_timestamp = timestamp - self.last_timestamp 3. Use relative_timestamp=False in roundtrip tests - Updated test_write_relative_timestamp_roundtrip and test_write_absolute_timestamps_are_offsets_from_start to use relative_timestamp=False so assertions verify original message timestamps are recovered (100.0, 100.3, 101.0) rather than file-stored offsets (0.0, 0.3, 1.0) Additional issues found and fixed during review: 4. Removed outdated TODO comment in ASCReader - Removed: "TODO - what is this used for? The ASC Writer only prints absolute" — no longer accurate since ASCWriter now supports both "absolute" and "relative" formats 5. Lowered assertAlmostEqual precision from places=5 to places=3 - The datetime triggerblock roundtrip (fromtimestamp -> strftime -> strptime -> timestamp) only preserves millisecond precision due to the ".NNN" format. places=5 (5 microseconds) is stricter than what the format can guarantee; places=3 (0.5 ms) correctly reflects the actual precision limit. Verified empirically: sub-millisecond timestamps incur ~0.456 ms error which passes places=3 but fails places=5. 6. Updated docstrings for both modified roundtrip tests to accurately describe the new assertion semantics (original timestamp recovery) --- can/io/asc.py | 12 +++--------- test/logformats_test.py | 23 +++++++++++------------ 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/can/io/asc.py b/can/io/asc.py index f49bb42ab..aeb748d8b 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -75,7 +75,6 @@ def __init__( self.relative_timestamp = relative_timestamp self.date: str | None = None self.start_time = 0.0 - # TODO - what is this used for? The ASC Writer only prints `absolute` self.timestamps_format: str | None = None self.internal_events_logged = False @@ -462,19 +461,14 @@ def log_event(self, message: str, timestamp: float | None = None) -> None: # Use last known timestamp if unknown if timestamp is None: timestamp = self.last_timestamp + timestamp = max(timestamp, self.last_timestamp) # Compute written timestamp based on configured format if self.timestamps_format == "absolute": # offsets from the start of measurement - written_timestamp = ( - timestamp - self.started if timestamp >= self.started else timestamp - ) + written_timestamp = timestamp - self.started else: # deltas from the preceding event - written_timestamp = ( - timestamp - self.last_timestamp - if timestamp >= self.last_timestamp - else 0.0 - ) + written_timestamp = timestamp - self.last_timestamp # Track last timestamp so the next event can compute its delta self.last_timestamp = timestamp line = self.FORMAT_EVENT.format(timestamp=written_timestamp, message=message) diff --git a/test/logformats_test.py b/test/logformats_test.py index a31c4e8bf..dfe818df0 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -711,7 +711,7 @@ def test_write_timestamps_format_invalid(self): can.ASCWriter(self.test_file_name, timestamps_format="unix") def test_write_relative_timestamp_roundtrip(self): - """Messages written with relative format round-trip with relative timestamps.""" + """Messages written with relative format round-trip to their original timestamps.""" msgs = [ can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), can.Message(timestamp=100.5, arbitration_id=0x2, data=b"\x02"), @@ -721,13 +721,12 @@ def test_write_relative_timestamp_roundtrip(self): for m in msgs: writer.on_message_received(m) - with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader: + with can.ASCReader(self.test_file_name, relative_timestamp=False) as reader: result = list(reader) self.assertEqual(len(result), len(msgs)) - # Timestamps in file are per-event deltas; reader reads them as-is - self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) - self.assertAlmostEqual(result[1].timestamp, 0.5, places=5) + self.assertAlmostEqual(result[0].timestamp, 100.0, places=3) + self.assertAlmostEqual(result[1].timestamp, 100.5, places=3) def test_write_relative_timestamps_are_per_event_deltas(self): """With timestamps_format='relative', each written timestamp is a delta from the @@ -754,8 +753,8 @@ def test_write_relative_timestamps_are_per_event_deltas(self): self.assertAlmostEqual(result[2].timestamp, 0.7, places=5) def test_write_absolute_timestamps_are_offsets_from_start(self): - """With timestamps_format='absolute' (default), each written timestamp is an - offset from the measurement start, not a per-event delta.""" + """With timestamps_format='absolute' (default), messages round-trip to their + original timestamps when read back with relative_timestamp=False.""" msgs = [ can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"), @@ -766,14 +765,14 @@ def test_write_absolute_timestamps_are_offsets_from_start(self): for m in msgs: writer.on_message_received(m) - with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader: + with can.ASCReader(self.test_file_name, relative_timestamp=False) as reader: result = list(reader) self.assertEqual(len(result), len(msgs)) - # All timestamps are offsets from the measurement start (100.0): - self.assertAlmostEqual(result[0].timestamp, 0.0, places=5) - self.assertAlmostEqual(result[1].timestamp, 0.3, places=5) - self.assertAlmostEqual(result[2].timestamp, 1.0, places=5) + # Timestamps are recovered from the triggerblock start time + file offset: + self.assertAlmostEqual(result[0].timestamp, 100.0, places=3) + self.assertAlmostEqual(result[1].timestamp, 100.3, places=3) + self.assertAlmostEqual(result[2].timestamp, 101.0, places=3) @parameterized.expand( [ From 76a6e5ccfcf59ce59924a6929c770e540044b3ae Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Sat, 28 Feb 2026 13:31:16 +0100 Subject: [PATCH 6/7] fix(asc): fix relative timestamp roundtrip for 3+ messages ASCReader was treating all timestamps as cumulative offsets from start_time, ignoring the timestamps_format value. When reading a file written with timestamps_format="relative" (per-event deltas) and relative_timestamp=False, the reader now accumulates deltas into start_time instead of adding each delta independently. Without this fix, a 3-message roundtrip would produce: msg3: 0.7 + 100.0 = 100.7 (wrong, expected 101.0) Also strengthen test_write_relative_timestamp_roundtrip to use 3 messages, exposing the bug that was masked by the 2-message case. --- can/io/asc.py | 6 +++++- test/logformats_test.py | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/can/io/asc.py b/can/io/asc.py index aeb748d8b..d332db37d 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -308,7 +308,11 @@ def __iter__(self) -> Generator[Message, None, None]: msg_kwargs: dict[str, float | bool | int] = {} try: _timestamp, channel, rest_of_message = line.split(None, 2) - timestamp = float(_timestamp) + self.start_time + if self.timestamps_format == "relative" and not self.relative_timestamp: + self.start_time += float(_timestamp) + timestamp = self.start_time + else: + timestamp = float(_timestamp) + self.start_time msg_kwargs["timestamp"] = timestamp if channel == "CANFD": msg_kwargs["is_fd"] = True diff --git a/test/logformats_test.py b/test/logformats_test.py index dfe818df0..42ed29b13 100644 --- a/test/logformats_test.py +++ b/test/logformats_test.py @@ -714,7 +714,8 @@ def test_write_relative_timestamp_roundtrip(self): """Messages written with relative format round-trip to their original timestamps.""" msgs = [ can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"), - can.Message(timestamp=100.5, arbitration_id=0x2, data=b"\x02"), + can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"), + can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"), ] with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer: @@ -726,7 +727,8 @@ def test_write_relative_timestamp_roundtrip(self): self.assertEqual(len(result), len(msgs)) self.assertAlmostEqual(result[0].timestamp, 100.0, places=3) - self.assertAlmostEqual(result[1].timestamp, 100.5, places=3) + self.assertAlmostEqual(result[1].timestamp, 100.3, places=3) + self.assertAlmostEqual(result[2].timestamp, 101.0, places=3) def test_write_relative_timestamps_are_per_event_deltas(self): """With timestamps_format='relative', each written timestamp is a delta from the From b8a31e4a55daea84ad4c511fb438545d4e1d3826 Mon Sep 17 00:00:00 2001 From: Ju-Daeng-E Date: Mon, 2 Mar 2026 11:13:34 +0100 Subject: [PATCH 7/7] fix(asc): use private _last_timestamp instead of mutating start_time Use self._last_timestamp to accumulate relative timestamps in ASCReader so that self.start_time remains unchanged and safe for external access. --- can/io/asc.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/can/io/asc.py b/can/io/asc.py index d332db37d..93ed79c35 100644 --- a/can/io/asc.py +++ b/can/io/asc.py @@ -75,6 +75,7 @@ def __init__( self.relative_timestamp = relative_timestamp self.date: str | None = None self.start_time = 0.0 + self._last_timestamp = 0.0 self.timestamps_format: str | None = None self.internal_events_logged = False @@ -293,6 +294,7 @@ def __iter__(self) -> Generator[Message, None, None]: if self.relative_timestamp else self._datetime_to_timestamp(datetime_str, self._timezone) ) + self._last_timestamp = self.start_time continue # Handle the "Start of measurement" line @@ -309,8 +311,8 @@ def __iter__(self) -> Generator[Message, None, None]: try: _timestamp, channel, rest_of_message = line.split(None, 2) if self.timestamps_format == "relative" and not self.relative_timestamp: - self.start_time += float(_timestamp) - timestamp = self.start_time + self._last_timestamp += float(_timestamp) + timestamp = self._last_timestamp else: timestamp = float(_timestamp) + self.start_time msg_kwargs["timestamp"] = timestamp