pycan-plot 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ .pytest_cache/
5
+ docs/superpowers/
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.5
2
+ Name: pycan-plot
3
+ Version: 0.0.1
4
+ Summary: Plot CAN signals from a candump log using a KCD database
5
+ Author-email: Alexander Becker <nabla@radiation.systems>
6
+ License-Expression: GPL-3.0-or-later
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: cantools
9
+ Requires-Dist: click
10
+ Requires-Dist: matplotlib
11
+ Requires-Dist: rich
12
+ Description-Content-Type: text/markdown
13
+
14
+ # pycan-plot
15
+
16
+ Graph CAN signals from a recorded `candump` log, decoded through a KCD
17
+ database.
18
+
19
+ ## Installation
20
+
21
+ ```console
22
+ pip install -e .
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```console
28
+ pycan-plot capture.log -d database.kcd -s Status.Voltage -s Status.Mode -o plot.png
29
+ ```
30
+
31
+ Signals are named `Message.Signal`, exactly as they appear in the KCD.
32
+ With no `-o`, an interactive window opens instead.
33
+
34
+ | Option | Meaning |
35
+ |---|---|
36
+ | `-d`, `--kcd` | KCD database (required) |
37
+ | `-s`, `--signal` | `Message.Signal` to plot; repeatable |
38
+ | `-p`, `--prefix` | Hex stack-position prefix ORed onto each message ID (default `0x0`) |
39
+ | `-t`, `--time-window` | `START..END`; each end is relative seconds (`12.5`) or a clock time (`08:25:40`), and either may be omitted |
40
+ | `-o`, `--output` | Output file; format taken from the extension (`.png`, `.svg`, `.pdf`) |
41
+ | `--show` | Open an interactive window; implied when `-o` is absent |
42
+ | `--split` | One subplot per signal instead of overlaying them |
43
+ | `--line` | Interpolate between samples instead of holding each value |
44
+
45
+ Signals are drawn as steps by default, because a CAN signal holds its last
46
+ received value until the next frame arrives. `--line` overrides that.
47
+
48
+ Examples:
49
+
50
+ ```console
51
+ # One module in stack position 2, clipped to twenty seconds of the capture
52
+ pycan-plot capture.log -d db.kcd -s Status.Voltage -p 0x02000000 -t 10..30 -o v.png
53
+
54
+ # Two signals on separate stacked axes, clipped by wall-clock time
55
+ pycan-plot capture.log -d db.kcd -s Status.Voltage -s Status.Mode \
56
+ --split -t 08:25:40..08:26:10 -o both.svg
57
+ ```
58
+
59
+ ## Exit codes
60
+
61
+ | Code | Meaning |
62
+ |---|---|
63
+ | 0 | success |
64
+ | 1 | log, KCD, or output file could not be read or written |
65
+ | 2 | CLI usage error (unknown message or signal, bad `-t`, bad `-p`) |
66
+ | 3 | no samples found for any requested signal |
67
+
68
+ Exit code 3 reports which stack-position prefixes *were* present for the
69
+ requested messages — usually the fastest way to spot a wrong `-p`.
70
+
71
+ ## Fish tab completion
72
+
73
+ An editable install writes the completion script automatically. To do it by
74
+ hand:
75
+
76
+ ```fish
77
+ _PYCAN_PLOT_COMPLETE=fish_source pycan-plot | source # current shell
78
+ _PYCAN_PLOT_COMPLETE=fish_source pycan-plot > ~/.config/fish/completions/pycan-plot.fish # persistent
79
+ ```
80
+
81
+ Completion covers log files, KCD files, message and signal names pulled live
82
+ from the KCD on the command line, and — once the log file is given — the
83
+ stack-position prefixes actually present in that log.
84
+
85
+ ## License
86
+
87
+ `pycan-plot` is distributed under the terms of the GPL-3.0-or-later license.
@@ -0,0 +1,74 @@
1
+ # pycan-plot
2
+
3
+ Graph CAN signals from a recorded `candump` log, decoded through a KCD
4
+ database.
5
+
6
+ ## Installation
7
+
8
+ ```console
9
+ pip install -e .
10
+ ```
11
+
12
+ ## Usage
13
+
14
+ ```console
15
+ pycan-plot capture.log -d database.kcd -s Status.Voltage -s Status.Mode -o plot.png
16
+ ```
17
+
18
+ Signals are named `Message.Signal`, exactly as they appear in the KCD.
19
+ With no `-o`, an interactive window opens instead.
20
+
21
+ | Option | Meaning |
22
+ |---|---|
23
+ | `-d`, `--kcd` | KCD database (required) |
24
+ | `-s`, `--signal` | `Message.Signal` to plot; repeatable |
25
+ | `-p`, `--prefix` | Hex stack-position prefix ORed onto each message ID (default `0x0`) |
26
+ | `-t`, `--time-window` | `START..END`; each end is relative seconds (`12.5`) or a clock time (`08:25:40`), and either may be omitted |
27
+ | `-o`, `--output` | Output file; format taken from the extension (`.png`, `.svg`, `.pdf`) |
28
+ | `--show` | Open an interactive window; implied when `-o` is absent |
29
+ | `--split` | One subplot per signal instead of overlaying them |
30
+ | `--line` | Interpolate between samples instead of holding each value |
31
+
32
+ Signals are drawn as steps by default, because a CAN signal holds its last
33
+ received value until the next frame arrives. `--line` overrides that.
34
+
35
+ Examples:
36
+
37
+ ```console
38
+ # One module in stack position 2, clipped to twenty seconds of the capture
39
+ pycan-plot capture.log -d db.kcd -s Status.Voltage -p 0x02000000 -t 10..30 -o v.png
40
+
41
+ # Two signals on separate stacked axes, clipped by wall-clock time
42
+ pycan-plot capture.log -d db.kcd -s Status.Voltage -s Status.Mode \
43
+ --split -t 08:25:40..08:26:10 -o both.svg
44
+ ```
45
+
46
+ ## Exit codes
47
+
48
+ | Code | Meaning |
49
+ |---|---|
50
+ | 0 | success |
51
+ | 1 | log, KCD, or output file could not be read or written |
52
+ | 2 | CLI usage error (unknown message or signal, bad `-t`, bad `-p`) |
53
+ | 3 | no samples found for any requested signal |
54
+
55
+ Exit code 3 reports which stack-position prefixes *were* present for the
56
+ requested messages — usually the fastest way to spot a wrong `-p`.
57
+
58
+ ## Fish tab completion
59
+
60
+ An editable install writes the completion script automatically. To do it by
61
+ hand:
62
+
63
+ ```fish
64
+ _PYCAN_PLOT_COMPLETE=fish_source pycan-plot | source # current shell
65
+ _PYCAN_PLOT_COMPLETE=fish_source pycan-plot > ~/.config/fish/completions/pycan-plot.fish # persistent
66
+ ```
67
+
68
+ Completion covers log files, KCD files, message and signal names pulled live
69
+ from the KCD on the command line, and — once the log file is given — the
70
+ stack-position prefixes actually present in that log.
71
+
72
+ ## License
73
+
74
+ `pycan-plot` is distributed under the terms of the GPL-3.0-or-later license.
@@ -0,0 +1,23 @@
1
+ """Hatchling build hook — installs the fish completion on editable installs.
2
+
3
+ The hook runs inside an isolated build environment where neither this package
4
+ nor its dependencies are importable, so it reaches into `src/` for the
5
+ dependency-free `_fish` module rather than importing `pycan_plot` proper.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
11
+
12
+
13
+ class CustomBuildHook(BuildHookInterface):
14
+ def initialize(self, version: str, build_data: dict) -> None:
15
+ if version != "editable":
16
+ return
17
+ try:
18
+ sys.path.insert(0, os.path.join(self.root, "src"))
19
+ from pycan_plot._fish import install_fish_script
20
+
21
+ install_fish_script(os.path.expanduser("~"))
22
+ except Exception:
23
+ pass # never block an install over completions
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pycan-plot"
7
+ dynamic = ["version"]
8
+ description = "Plot CAN signals from a candump log using a KCD database"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "GPL-3.0-or-later"
12
+ authors = [
13
+ { name = "Alexander Becker", email = "nabla@radiation.systems" },
14
+ ]
15
+ dependencies = [
16
+ "click",
17
+ "cantools",
18
+ "matplotlib",
19
+ "rich",
20
+ ]
21
+
22
+ [project.scripts]
23
+ pycan-plot = "pycan_plot.main:main"
24
+
25
+ [tool.hatch.version]
26
+ path = "src/pycan_plot/__about__.py"
27
+
28
+ [tool.hatch.build.hooks.custom]
29
+ path = "hatch_build.py"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/pycan_plot"]
33
+
34
+ [tool.hatch.envs.test]
35
+ dependencies = [
36
+ "pytest",
37
+ ]
38
+ [tool.hatch.envs.test.scripts]
39
+ run = "pytest {args:tests}"
40
+
41
+ [tool.hatch.envs.types]
42
+ extra-dependencies = [
43
+ "mypy>=1.0.0",
44
+ ]
45
+ [tool.hatch.envs.types.scripts]
46
+ check = "mypy --install-types --non-interactive {args:src/pycan_plot tests}"
47
+
48
+ [tool.pytest.ini_options]
49
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
File without changes
@@ -0,0 +1,168 @@
1
+ """Click shell-completion callbacks.
2
+
3
+ Completions are computed live from the KCD named on the command line, so
4
+ there is no generated table to keep in sync with the database. Every
5
+ callback swallows exceptions: a malformed KCD must never break the user's
6
+ shell.
7
+ """
8
+ import re
9
+ from pathlib import Path
10
+ from typing import List, Optional
11
+
12
+ import cantools
13
+ from click.shell_completion import CompletionItem
14
+
15
+ _ID_LINE = re.compile(r"^\(\d+(?:\.\d+)?\)\s+\S+\s+([0-9A-Fa-f]+)#")
16
+ _PREFIX_MASK = 0x1F000000
17
+ #: How many lines of the log to sample when completing -p.
18
+ _PREFIX_SCAN_LINES = 50_000
19
+
20
+
21
+ def _complete_paths(incomplete: str, suffix: str) -> List[CompletionItem]:
22
+ """Offer directories plus files ending in `suffix`, keeping the token's
23
+ user-visible form (`~/foo`, `./foo`, `foo`) rather than absolutising it."""
24
+ raw = incomplete or ""
25
+ expanded = Path(raw).expanduser() if raw else Path(".")
26
+ if raw.endswith("/") and expanded.is_dir():
27
+ directory, prefix = expanded, ""
28
+ else:
29
+ directory, prefix = expanded.parent, expanded.name
30
+ if not directory.exists():
31
+ return []
32
+
33
+ if raw.startswith("~"):
34
+ home = Path.home()
35
+
36
+ def display(p: Path) -> str:
37
+ try:
38
+ return "~/" + str(p.relative_to(home))
39
+ except ValueError:
40
+ return str(p)
41
+ else:
42
+ def display(p: Path) -> str:
43
+ return str(p)
44
+
45
+ items = []
46
+ for entry in sorted(directory.iterdir()):
47
+ if not entry.name.startswith(prefix):
48
+ continue
49
+ shown = display(entry)
50
+ if entry.is_dir():
51
+ items.append(CompletionItem(shown + "/", type="plain"))
52
+ elif entry.suffix == suffix:
53
+ items.append(CompletionItem(shown))
54
+ return items
55
+
56
+
57
+ def _kcd_from_ctx(ctx) -> Optional[str]:
58
+ """During completion the -d value may still be sitting in ctx.args."""
59
+ kcd = ctx.params.get("kcd_file")
60
+ if kcd:
61
+ return kcd
62
+ for arg in ctx.args:
63
+ if str(arg).endswith(".kcd"):
64
+ return str(arg)
65
+ return None
66
+
67
+
68
+ def _logfile_from_ctx(ctx) -> Optional[str]:
69
+ logfile = ctx.params.get("logfile")
70
+ if logfile:
71
+ return logfile
72
+ for arg in ctx.args:
73
+ if str(arg).endswith(".log"):
74
+ return str(arg)
75
+ return None
76
+
77
+
78
+ def _load_db(ctx):
79
+ kcd = _kcd_from_ctx(ctx)
80
+ if not kcd:
81
+ return None
82
+ try:
83
+ return cantools.database.load_file(str(Path(kcd).expanduser()))
84
+ except Exception:
85
+ return None
86
+
87
+
88
+ def complete_kcd(ctx, param, incomplete) -> List[CompletionItem]:
89
+ try:
90
+ return _complete_paths(incomplete, ".kcd")
91
+ except Exception:
92
+ return []
93
+
94
+
95
+ def complete_logfile(ctx, param, incomplete) -> List[CompletionItem]:
96
+ try:
97
+ return _complete_paths(incomplete, ".log")
98
+ except Exception:
99
+ return []
100
+
101
+
102
+ def complete_signal(ctx, param, incomplete) -> List[CompletionItem]:
103
+ """`Message.Signal` is a single token, so complete it in two phases:
104
+ message names (with a trailing dot) until the dot is typed, then that
105
+ message's signals as full `Message.Signal` strings."""
106
+ try:
107
+ db = _load_db(ctx)
108
+ if db is None:
109
+ return []
110
+ if "." not in incomplete:
111
+ return [
112
+ CompletionItem(m.name + ".")
113
+ for m in db.messages
114
+ if m.name.startswith(incomplete)
115
+ ]
116
+ message_name, partial = incomplete.split(".", 1)
117
+ try:
118
+ message = db.get_message_by_name(message_name)
119
+ except KeyError:
120
+ return []
121
+ return [
122
+ CompletionItem(f"{message_name}.{s.name}")
123
+ for s in message.signals
124
+ if s.name.startswith(partial)
125
+ ]
126
+ except Exception:
127
+ return []
128
+
129
+
130
+ def complete_prefix(ctx, param, incomplete) -> List[CompletionItem]:
131
+ """Offer the stack-position prefixes actually present in the log."""
132
+ try:
133
+ logfile = _logfile_from_ctx(ctx)
134
+ if not logfile:
135
+ return []
136
+ seen = set()
137
+ with open(Path(logfile).expanduser(), "r") as fh:
138
+ for count, line in enumerate(fh):
139
+ if count >= _PREFIX_SCAN_LINES:
140
+ break
141
+ match = _ID_LINE.match(line)
142
+ if match:
143
+ seen.add(int(match.group(1), 16) & _PREFIX_MASK)
144
+ return [
145
+ CompletionItem(f"0x{p:08X}")
146
+ for p in sorted(seen)
147
+ if f"0x{p:08X}".lower().startswith(incomplete.lower())
148
+ ]
149
+ except Exception:
150
+ return []
151
+
152
+
153
+ def fish_source() -> str:
154
+ """The fish completion script, generated by Click itself."""
155
+ from click.shell_completion import FishComplete
156
+
157
+ from pycan_plot.main import main
158
+
159
+ return FishComplete(main, {}, "pycan-plot", "_PYCAN_PLOT_COMPLETE").source()
160
+
161
+
162
+ def install_fish(quiet: bool = False) -> None:
163
+ """Write the fish completion script into the user's fish config."""
164
+ from pycan_plot._fish import install_fish_script
165
+
166
+ dest = install_fish_script(str(Path.home()))
167
+ if not quiet:
168
+ print(f"Installed → {dest}")
@@ -0,0 +1,45 @@
1
+ """The fish completion script text, with no third-party imports.
2
+
3
+ This module is deliberately dependency-free: the hatchling build hook imports
4
+ it from an isolated build environment where neither `pycan_plot`'s
5
+ dependencies nor the package itself are installed yet.
6
+
7
+ The script is generic boilerplate — it delegates every completion back to the
8
+ running command via `_PYCAN_PLOT_COMPLETE=fish_complete`, and embeds no option
9
+ or signal names. That is what makes hard-coding it safe. `test_completion.py`
10
+ asserts it still matches what the installed Click generates, so a change in
11
+ Click's template is caught rather than silently shipped stale.
12
+ """
13
+
14
+ FISH_SCRIPT = '''function _pycan_plot_completion;
15
+ set -l response (env _PYCAN_PLOT_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) pycan-plot);
16
+
17
+ for completion in $response;
18
+ set -l metadata (string split "," $completion);
19
+
20
+ if test $metadata[1] = "dir";
21
+ __fish_complete_directories $metadata[2];
22
+ else if test $metadata[1] = "file";
23
+ __fish_complete_path $metadata[2];
24
+ else if test $metadata[1] = "plain";
25
+ echo $metadata[2];
26
+ end;
27
+ end;
28
+ end;
29
+
30
+ complete --no-files --command pycan-plot --arguments "(_pycan_plot_completion)";
31
+ '''
32
+
33
+ #: Where the script belongs in a user's fish configuration.
34
+ FISH_RELATIVE_DEST = ".config/fish/completions/pycan-plot.fish"
35
+
36
+
37
+ def install_fish_script(home: str) -> str:
38
+ """Write FISH_SCRIPT under `home` and return the path written."""
39
+ import os
40
+
41
+ dest = os.path.join(home, FISH_RELATIVE_DEST)
42
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
43
+ with open(dest, "w") as fh:
44
+ fh.write(FISH_SCRIPT)
45
+ return dest
@@ -0,0 +1,62 @@
1
+ """Parsing of `candump`-format text logs.
2
+
3
+ A line looks like:
4
+
5
+ (1786364740.740423) can0 0107800C#00000000
6
+
7
+ That is: a parenthesised epoch timestamp, the interface name, then the
8
+ arbitration ID and payload separated by `#`. IDs of 3 hex digits are
9
+ standard, 8 hex digits extended — we do not distinguish them, since a KCD
10
+ frame ID comparison covers both. Lines using `##` (CAN FD) or `#R`
11
+ (remote frames) carry nothing we can plot and are counted as malformed.
12
+ """
13
+ import os
14
+ import re
15
+ from typing import Iterator, NamedTuple, Union
16
+
17
+ _LINE = re.compile(
18
+ r"^\((?P<ts>\d+(?:\.\d+)?)\)\s+" # (timestamp)
19
+ r"\S+\s+" # interface
20
+ r"(?P<id>[0-9A-Fa-f]+)" # arbitration ID
21
+ r"#(?P<data>[0-9A-Fa-f]*)\s*$" # payload (possibly empty)
22
+ )
23
+
24
+
25
+ class Frame(NamedTuple):
26
+ timestamp: float
27
+ arb_id: int
28
+ data: bytes
29
+
30
+
31
+ class CandumpReader:
32
+ """Iterable over the frames in a candump log.
33
+
34
+ Malformed lines are skipped rather than raised on; after iteration
35
+ finishes, `malformed` holds how many were skipped. Blank lines are
36
+ ignored entirely and do not count as malformed. Re-iterating restarts
37
+ the count from zero.
38
+ """
39
+
40
+ def __init__(self, path: Union[str, os.PathLike]) -> None:
41
+ self.path = path
42
+ self.malformed = 0
43
+
44
+ def __iter__(self) -> Iterator[Frame]:
45
+ self.malformed = 0
46
+ with open(self.path, "r") as fh:
47
+ for line in fh:
48
+ if not line.strip():
49
+ continue
50
+ match = _LINE.match(line)
51
+ if match is None:
52
+ self.malformed += 1
53
+ continue
54
+ payload = match.group("data")
55
+ if len(payload) % 2:
56
+ self.malformed += 1
57
+ continue
58
+ yield Frame(
59
+ float(match.group("ts")),
60
+ int(match.group("id"), 16),
61
+ bytes.fromhex(payload),
62
+ )
@@ -0,0 +1,25 @@
1
+ """Exceptions that carry the tool's documented exit codes."""
2
+
3
+
4
+ class PlotError(Exception):
5
+ """Base for errors that map onto a process exit code."""
6
+
7
+ exit_code = 1
8
+
9
+
10
+ class InputError(PlotError):
11
+ """A file could not be read, parsed, or written."""
12
+
13
+ exit_code = 1
14
+
15
+
16
+ class UsageError(PlotError):
17
+ """The command line asked for something that cannot be satisfied."""
18
+
19
+ exit_code = 2
20
+
21
+
22
+ class NoDataError(PlotError):
23
+ """Not one requested signal produced a single sample."""
24
+
25
+ exit_code = 3
@@ -0,0 +1,104 @@
1
+ """The single streaming pass over a candump log.
2
+
3
+ Only frames whose arbitration ID matches a requested message (with the
4
+ stack-position prefix already ORed in) are decoded; everything else costs
5
+ one dict lookup. That is what keeps a multi-million-line log tractable.
6
+ """
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
9
+
10
+ from pycan_plot.selection import SignalRef
11
+ from pycan_plot.timespec import TimeWindow
12
+
13
+ #: Stack position lives in bits [28:24] of a v1 extended ID.
14
+ PREFIX_MASK = 0x1F000000
15
+
16
+
17
+ @dataclass
18
+ class Series:
19
+ """All samples collected for one requested signal."""
20
+
21
+ ref: SignalRef
22
+ times: List[float] = field(default_factory=list)
23
+ values: List[float] = field(default_factory=list)
24
+
25
+ @property
26
+ def label(self) -> str:
27
+ return self.ref.label
28
+
29
+ @property
30
+ def unit(self) -> Optional[str]:
31
+ return self.ref.signal.unit
32
+
33
+ @property
34
+ def choices(self) -> Optional[Dict[int, Any]]:
35
+ return self.ref.signal.choices
36
+
37
+
38
+ @dataclass
39
+ class ExtractResult:
40
+ series: Dict[str, Series]
41
+ malformed: int = 0
42
+ observed_prefixes: Dict[int, Set[int]] = field(default_factory=dict)
43
+ frames_scanned: int = 0
44
+
45
+
46
+ def extract(
47
+ reader: Iterable,
48
+ refs: Sequence[SignalRef],
49
+ prefix: int,
50
+ window: TimeWindow,
51
+ ) -> ExtractResult:
52
+ """Stream `reader` once and collect samples for every ref in `refs`."""
53
+ series = {ref.label: Series(ref) for ref in refs}
54
+
55
+ # Frames we must decode, keyed by the fully-prefixed ID.
56
+ wanted: Dict[int, List[SignalRef]] = {}
57
+ # Base IDs we watch for the wrong-prefix hint, whatever their prefix.
58
+ watched_bases: Set[int] = set()
59
+ for ref in refs:
60
+ wanted.setdefault(ref.base_frame_id | prefix, []).append(ref)
61
+ watched_bases.add(ref.base_frame_id)
62
+
63
+ result = ExtractResult(series=series)
64
+ result.observed_prefixes = {base: set() for base in watched_bases}
65
+
66
+ start: Optional[float] = None
67
+ end: Optional[float] = None
68
+ bounds_resolved = False
69
+
70
+ for frame in reader:
71
+ result.frames_scanned += 1
72
+ if not bounds_resolved:
73
+ # Bounds are relative to the first frame in the log, not the
74
+ # first matching one, so resolve them as soon as we see any frame.
75
+ start, end = window.resolve(frame.timestamp)
76
+ bounds_resolved = True
77
+
78
+ base = frame.arb_id & ~PREFIX_MASK
79
+ if base in result.observed_prefixes:
80
+ result.observed_prefixes[base].add(frame.arb_id & PREFIX_MASK)
81
+
82
+ refs_here = wanted.get(frame.arb_id)
83
+ if refs_here is None:
84
+ continue
85
+ if start is not None and frame.timestamp < start:
86
+ continue
87
+ if end is not None and frame.timestamp > end:
88
+ continue
89
+
90
+ try:
91
+ decoded = refs_here[0].message.decode(frame.data, decode_choices=False)
92
+ except Exception:
93
+ # Short, over-long or otherwise undecodable payload: count and move on.
94
+ result.malformed += 1
95
+ continue
96
+
97
+ for ref in refs_here:
98
+ if ref.signal.name not in decoded:
99
+ continue # a muxed signal that this frame does not carry
100
+ series[ref.label].times.append(frame.timestamp)
101
+ series[ref.label].values.append(float(decoded[ref.signal.name]))
102
+
103
+ result.malformed += getattr(reader, "malformed", 0)
104
+ return result