Skip to content

Commit 59b2e43

Browse files
authored
Fix/asc writer timestamps format (#2032)
* 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 * docs: add changelog fragment for #2022 * style: apply black formatting to logformats_test.py * 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. * fix(asc): address PR review feedback on timestamps_format 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) * 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. * 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.
1 parent 74201b9 commit 59b2e43

3 files changed

Lines changed: 130 additions & 8 deletions

File tree

can/io/asc.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import re
1111
from collections.abc import Generator
1212
from datetime import datetime, timezone, tzinfo
13-
from typing import Any, Final, TextIO
13+
from typing import Any, Final, Literal, TextIO
1414

1515
from ..message import Message
1616
from ..typechecking import StringPathLike
@@ -75,7 +75,7 @@ def __init__(
7575
self.relative_timestamp = relative_timestamp
7676
self.date: str | None = None
7777
self.start_time = 0.0
78-
# TODO - what is this used for? The ASC Writer only prints `absolute`
78+
self._last_timestamp = 0.0
7979
self.timestamps_format: str | None = None
8080
self.internal_events_logged = False
8181

@@ -294,6 +294,7 @@ def __iter__(self) -> Generator[Message, None, None]:
294294
if self.relative_timestamp
295295
else self._datetime_to_timestamp(datetime_str, self._timezone)
296296
)
297+
self._last_timestamp = self.start_time
297298
continue
298299

299300
# Handle the "Start of measurement" line
@@ -309,7 +310,11 @@ def __iter__(self) -> Generator[Message, None, None]:
309310
msg_kwargs: dict[str, float | bool | int] = {}
310311
try:
311312
_timestamp, channel, rest_of_message = line.split(None, 2)
312-
timestamp = float(_timestamp) + self.start_time
313+
if self.timestamps_format == "relative" and not self.relative_timestamp:
314+
self._last_timestamp += float(_timestamp)
315+
timestamp = self._last_timestamp
316+
else:
317+
timestamp = float(_timestamp) + self.start_time
313318
msg_kwargs["timestamp"] = timestamp
314319
if channel == "CANFD":
315320
msg_kwargs["is_fd"] = True
@@ -372,6 +377,7 @@ def __init__(
372377
file: StringPathLike | TextIO,
373378
channel: int = 1,
374379
tz: tzinfo | None = _LOCAL_TZ,
380+
timestamps_format: Literal["absolute", "relative"] = "absolute",
375381
**kwargs: Any,
376382
) -> None:
377383
"""
@@ -384,7 +390,22 @@ def __init__(
384390
have a channel set. Default is 1.
385391
:param tz:
386392
Timezone for timestamps in the log file. Defaults to local timezone.
393+
:param timestamps_format:
394+
the format of timestamps in the header.
395+
Use ``"absolute"`` (default) so that readers can recover
396+
the original wall-clock timestamps by combining the
397+
per-message offset with the trigger-block start time.
398+
Use ``"relative"`` when only the elapsed time from the
399+
start of the recording matters and no absolute time
400+
recovery is needed.
401+
:raises ValueError: if *timestamps_format* is not ``"absolute"`` or
402+
``"relative"``
387403
"""
404+
if timestamps_format not in ("absolute", "relative"):
405+
raise ValueError(
406+
f"timestamps_format must be 'absolute' or 'relative', "
407+
f"got {timestamps_format!r}"
408+
)
388409
if kwargs.get("append", False):
389410
raise ValueError(
390411
f"{self.__class__.__name__} is currently not equipped to "
@@ -394,11 +415,12 @@ def __init__(
394415

395416
self._timezone = tz
396417
self.channel = channel
418+
self.timestamps_format = timestamps_format
397419

398420
# write start of file header
399421
start_time = self._format_header_datetime(datetime.now(tz=self._timezone))
400422
self.file.write(f"date {start_time}\n")
401-
self.file.write("base hex timestamps absolute\n")
423+
self.file.write(f"base hex timestamps {self.timestamps_format}\n")
402424
self.file.write("internal events logged\n")
403425

404426
# the last part is written with the timestamp of the first message
@@ -445,10 +467,17 @@ def log_event(self, message: str, timestamp: float | None = None) -> None:
445467
# Use last known timestamp if unknown
446468
if timestamp is None:
447469
timestamp = self.last_timestamp
448-
# turn into relative timestamps if necessary
449-
if timestamp >= self.started:
450-
timestamp -= self.started
451-
line = self.FORMAT_EVENT.format(timestamp=timestamp, message=message)
470+
timestamp = max(timestamp, self.last_timestamp)
471+
# Compute written timestamp based on configured format
472+
if self.timestamps_format == "absolute":
473+
# offsets from the start of measurement
474+
written_timestamp = timestamp - self.started
475+
else:
476+
# deltas from the preceding event
477+
written_timestamp = timestamp - self.last_timestamp
478+
# Track last timestamp so the next event can compute its delta
479+
self.last_timestamp = timestamp
480+
line = self.FORMAT_EVENT.format(timestamp=written_timestamp, message=message)
452481
self.file.write(line)
453482

454483
def on_message_received(self, msg: Message) -> None:

doc/changelog.d/2022.added.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added `timestamps_format` parameter to `ASCWriter` to support configurable timestamp
2+
format: `"absolute"` (default, timestamps are offsets from the start of measurement)
3+
or `"relative"` (each timestamp is the delta from the preceding event), matching the
4+
semantics described in the ASC format specification.

test/logformats_test.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
TODO: correctly set preserves_channel and adds_default_channel
1313
"""
14+
1415
import locale
1516
import logging
1617
import os
@@ -687,6 +688,94 @@ def test_write(self):
687688

688689
self.assertEqual(expected_file.read_text(), actual_file.read_text())
689690

691+
def test_write_timestamps_format_default_is_absolute(self):
692+
"""ASCWriter should write 'timestamps absolute' in the header by default."""
693+
with can.ASCWriter(self.test_file_name) as writer:
694+
pass
695+
696+
content = Path(self.test_file_name).read_text()
697+
self.assertIn("timestamps absolute", content)
698+
699+
def test_write_timestamps_format_relative(self):
700+
"""ASCWriter should write 'timestamps relative' when requested."""
701+
with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer:
702+
pass
703+
704+
content = Path(self.test_file_name).read_text()
705+
self.assertIn("timestamps relative", content)
706+
self.assertNotIn("timestamps absolute", content)
707+
708+
def test_write_timestamps_format_invalid(self):
709+
"""ASCWriter should raise ValueError for an unsupported timestamps_format."""
710+
with self.assertRaises(ValueError):
711+
can.ASCWriter(self.test_file_name, timestamps_format="unix")
712+
713+
def test_write_relative_timestamp_roundtrip(self):
714+
"""Messages written with relative format round-trip to their original timestamps."""
715+
msgs = [
716+
can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"),
717+
can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"),
718+
can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"),
719+
]
720+
721+
with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer:
722+
for m in msgs:
723+
writer.on_message_received(m)
724+
725+
with can.ASCReader(self.test_file_name, relative_timestamp=False) as reader:
726+
result = list(reader)
727+
728+
self.assertEqual(len(result), len(msgs))
729+
self.assertAlmostEqual(result[0].timestamp, 100.0, places=3)
730+
self.assertAlmostEqual(result[1].timestamp, 100.3, places=3)
731+
self.assertAlmostEqual(result[2].timestamp, 101.0, places=3)
732+
733+
def test_write_relative_timestamps_are_per_event_deltas(self):
734+
"""With timestamps_format='relative', each written timestamp is a delta from the
735+
preceding event (not an offset from measurement start)."""
736+
msgs = [
737+
can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"),
738+
can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"),
739+
can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"),
740+
]
741+
742+
with can.ASCWriter(self.test_file_name, timestamps_format="relative") as writer:
743+
for m in msgs:
744+
writer.on_message_received(m)
745+
746+
with can.ASCReader(self.test_file_name, relative_timestamp=True) as reader:
747+
result = list(reader)
748+
749+
self.assertEqual(len(result), len(msgs))
750+
# msg1: 0.0 (delta from "Start of measurement" at same time)
751+
# msg2: 0.3 (delta from msg1)
752+
# msg3: 0.7 (delta from msg2 — NOT 1.0, which would be absolute offset)
753+
self.assertAlmostEqual(result[0].timestamp, 0.0, places=5)
754+
self.assertAlmostEqual(result[1].timestamp, 0.3, places=5)
755+
self.assertAlmostEqual(result[2].timestamp, 0.7, places=5)
756+
757+
def test_write_absolute_timestamps_are_offsets_from_start(self):
758+
"""With timestamps_format='absolute' (default), messages round-trip to their
759+
original timestamps when read back with relative_timestamp=False."""
760+
msgs = [
761+
can.Message(timestamp=100.0, arbitration_id=0x1, data=b"\x01"),
762+
can.Message(timestamp=100.3, arbitration_id=0x2, data=b"\x02"),
763+
can.Message(timestamp=101.0, arbitration_id=0x3, data=b"\x03"),
764+
]
765+
766+
with can.ASCWriter(self.test_file_name, timestamps_format="absolute") as writer:
767+
for m in msgs:
768+
writer.on_message_received(m)
769+
770+
with can.ASCReader(self.test_file_name, relative_timestamp=False) as reader:
771+
result = list(reader)
772+
773+
self.assertEqual(len(result), len(msgs))
774+
# Timestamps are recovered from the triggerblock start time + file offset:
775+
self.assertAlmostEqual(result[0].timestamp, 100.0, places=3)
776+
self.assertAlmostEqual(result[1].timestamp, 100.3, places=3)
777+
self.assertAlmostEqual(result[2].timestamp, 101.0, places=3)
778+
690779
@parameterized.expand(
691780
[
692781
(

0 commit comments

Comments
 (0)