libxrk 0.6.0__cp310-cp310-macosx_10_9_x86_64.whl

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.
libxrk/aim_xrk.pyx ADDED
@@ -0,0 +1,892 @@
1
+
2
+ # Copyright 2024, Scott Smith. MIT License (see LICENSE).
3
+
4
+ from array import array
5
+ import concurrent.futures
6
+ import ctypes
7
+ from dataclasses import dataclass, field
8
+ import math
9
+ import mmap
10
+ import numpy as np
11
+ import os
12
+ from pprint import pprint # pylint: disable=unused-import
13
+ import struct
14
+ import sys
15
+ import time
16
+ import traceback # pylint: disable=unused-import
17
+ from typing import Dict, List, Optional
18
+ import zlib
19
+
20
+ import cython
21
+ from cython.operator cimport dereference
22
+ from libcpp.vector cimport vector
23
+
24
+ import pyarrow as pa
25
+
26
+ from . import gps
27
+ from .gps import fix_gps_timing_gaps
28
+ from . import base
29
+
30
+ # 1,2,5,10,20,25,50 Hz
31
+ # units
32
+ # dec ptr
33
+
34
+ dc_slots = {'slots': True} if sys.version_info.minor >= 10 else {}
35
+
36
+ @dataclass(**dc_slots)
37
+ class Group:
38
+ index: int
39
+ channels: List[int]
40
+ samples: array = field(default_factory=lambda: array('I'), repr=False)
41
+ # used during building:
42
+ timecodes: Optional[array] = field(default=None, repr=False)
43
+
44
+ @dataclass(**dc_slots)
45
+ class GroupRef:
46
+ group: Group
47
+ offset: int
48
+
49
+ @dataclass(**dc_slots)
50
+ class Channel:
51
+ index: int = -1
52
+ short_name: str = ""
53
+ long_name: str = ""
54
+ size: int = 0
55
+ units: str = ""
56
+ dec_pts: int = 0
57
+ interpolate: bool = False
58
+ unknown: bytes = b""
59
+ group: Optional[GroupRef] = None
60
+ timecodes: object = field(default=None, repr=False)
61
+ sampledata: object = field(default=None, repr=False)
62
+
63
+ @dataclass(**dc_slots)
64
+ class Message:
65
+ token: bytes
66
+ num: int
67
+ content: bytes
68
+
69
+ @dataclass(**dc_slots)
70
+ class DataStream:
71
+ channels: Dict[str, Channel]
72
+ messages: Dict[str, List[Message]]
73
+ laps: pa.Table
74
+ time_offset: int
75
+
76
+ @dataclass(**dc_slots)
77
+ class Decoder:
78
+ stype: str
79
+ interpolate: bool = False
80
+ fixup: object = None
81
+
82
+ def _nullterm_string(s):
83
+ zero = s.find(0)
84
+ if zero >= 0: s = s[:zero]
85
+ return s.decode('ascii')
86
+
87
+ _manual_decoders = {
88
+ 'Calculated_Gear': Decoder('Q', fixup=lambda a: array('I', [0 if int(x) & 0x80000 else
89
+ (int(x) >> 16) & 7 for x in a])),
90
+ 'PreCalcGear': Decoder('Q', fixup=lambda a: array('I', [0 if int(x) & 0x80000 else
91
+ (int(x) >> 16) & 7 for x in a])),
92
+ }
93
+
94
+ _gear_table = np.arange(65536, dtype=np.uint16)
95
+ _gear_table[ord('N')] = 0
96
+ _gear_table[ord('1')] = 1
97
+ _gear_table[ord('2')] = 2
98
+ _gear_table[ord('3')] = 3
99
+ _gear_table[ord('4')] = 4
100
+ _gear_table[ord('5')] = 5
101
+ _gear_table[ord('6')] = 6
102
+
103
+ _decoders = {
104
+ 0: Decoder('i'), # Master Clock on M4GT4?
105
+ 1: Decoder('H', interpolate=True,
106
+ fixup=lambda a: np.ndarray(buffer=a, shape=(len(a),),
107
+ dtype=np.float16).astype(np.float32).data),
108
+ 3: Decoder('i'), # Master Clock on ScottE46?
109
+ 4: Decoder('h'),
110
+ 6: Decoder('f', interpolate=True),
111
+ 11: Decoder('h'),
112
+ 12: Decoder('i'), # Predictive Time?
113
+ 13: Decoder('B'), # status field?
114
+ 15: Decoder('H', fixup=lambda a: _gear_table[a]), # ?? NdscSwitch on M4GT4. Also actual size is 8 bytes
115
+ 20: Decoder('H', interpolate=True,
116
+ fixup=lambda a: np.ndarray(buffer=a, shape=(len(a),),
117
+ dtype=np.float16).astype(np.float32).data),
118
+ 24: Decoder('i'), # Best Run Diff?
119
+ }
120
+
121
+ _unit_map = {
122
+ 1: ('%', 2),
123
+ 3: ('G', 2),
124
+ 4: ('deg', 1),
125
+ 5: ('deg/s', 1),
126
+ 6: ('', 0), # number
127
+ 9: ('Hz', 0),
128
+ 11: ('', 0), # number
129
+ 12: ('mm', 0),
130
+ 14: ('bar', 2),
131
+ 15: ('rpm', 0),
132
+ 16: ('km/h', 0),
133
+ 17: ('C', 1),
134
+ 18: ('ms', 0),
135
+ 19: ('Nm', 0),
136
+ 20: ('km/h', 0),
137
+ 21: ('V', 1), # mv?
138
+ 22: ('l', 1),
139
+ 24: ('l/s', 0), # ? rs3 displayed 1l/h
140
+ 26: ('time?', 0),
141
+ 27: ('A', 0),
142
+ 30: ('lambda', 2),
143
+ 31: ('gear', 0),
144
+ 33: ('%', 2),
145
+ 43: ('kg', 3),
146
+ }
147
+
148
+ def _ndarray_from_mv(mv):
149
+ mv = memoryview(mv) # force it
150
+ return np.ndarray(buffer=mv, shape=(len(mv),), dtype=np.dtype(mv.format))
151
+
152
+ def _sliding_ndarray(buf, typ):
153
+ return np.ndarray(buffer=buf, dtype=typ,
154
+ shape=(len(buf) - array(typ).itemsize + 1,), strides=(1,))
155
+
156
+ def _tokdec(s):
157
+ if s: return ord(s[0]) + 256 * _tokdec(s[1:])
158
+ return 0
159
+
160
+ def _tokenc(i):
161
+ s = ''
162
+ while i:
163
+ s += chr(i & 255)
164
+ i >>= 8
165
+ return s
166
+
167
+ accum = cython.struct(
168
+ last_timecode=cython.int,
169
+ add_helper=cython.ushort,
170
+ Mms=cython.ushort,
171
+ data=vector[cython.uchar],
172
+ timecodes=vector[cython.int])
173
+
174
+ cdef packed struct smsg_hdr: # covers G, S, and M messages
175
+ cython.ushort op
176
+ cython.int timecode
177
+ cython.ushort index
178
+ cython.ushort count # for M messages only
179
+ # data field(s) follow(s), size depends on type/group
180
+
181
+ cdef packed struct cmsg_hdr: # covers c messages
182
+ cython.ushort op
183
+ cython.uchar unk1 # always 0?
184
+ cython.ushort channel # bottom 3 bits always 4?
185
+ cython.uchar unk3 # always 0x84?
186
+ cython.uchar unk4 # always 6?
187
+ cython.int timecode
188
+ # data field follows, size depends on type
189
+
190
+ cdef packed struct hmsg_hdr:
191
+ cython.ushort op
192
+ cython.uint tok
193
+ cython.int hlen
194
+ cython.uchar ver
195
+ cython.uchar cl
196
+
197
+ cdef packed struct hmsg_ftr:
198
+ cython.uchar op
199
+ cython.uint tok
200
+ cython.ushort bytesum
201
+ cython.uchar cl
202
+
203
+ cdef union msg_hdr:
204
+ smsg_hdr s
205
+ cmsg_hdr c
206
+ hmsg_hdr h
207
+
208
+ ctypedef const cython.uchar* byte_ptr
209
+ ctypedef vector[accum] vaccum
210
+
211
+ cdef extern from '<numeric>' namespace 'std' nogil:
212
+ T accumulate[InputIt, T](InputIt first, InputIt last, T init)
213
+
214
+ cdef _resize_vaccum(vaccum & v, size_t idx):
215
+ if idx >= v.size():
216
+ old_len = v.size()
217
+ v.resize(idx + 1)
218
+ for i in range(old_len, v.size()):
219
+ v[i].last_timecode = -1
220
+ v[i].add_helper = 1
221
+ v[i].Mms = 0
222
+
223
+ cdef _Mms_lookup(int k):
224
+ # Not sure how to represent 500 Hz
225
+ if k == 8: return 5 # 200 Hz
226
+ if k == 16: return 10 # 100 Hz
227
+ if k == 32: return 20 # 50 Hz
228
+ if k == 64: return 40 # 25 Hz
229
+ if k == 80: return 50 # 20 Hz
230
+ # I guess 10Hz, 5Hz, 2Hz, and 1Hz don't use M messages
231
+ return 0
232
+
233
+ @cython.wraparound(False)
234
+ def _decode_sequence(s, progress=None):
235
+ cdef const cython.uchar[::1] sv = s
236
+ groups = []
237
+ channels = []
238
+ messages = {}
239
+ tok_GPS: cython.uint = _tokdec('GPS')
240
+ tok_GPS1: cython.uint = _tokdec('GPS1')
241
+ progress_interval: cython.Py_ssize_t = 8_000_000
242
+ next_progress: cython.Py_ssize_t = progress_interval
243
+ pos: cython.Py_ssize_t = 0
244
+ oldpos: cython.Py_ssize_t = pos
245
+ badbytes: cython.Py_ssize_t = 0
246
+ badpos: cython.Py_ssize_t = 0
247
+ ord_op: cython.int = ord('(')
248
+ ord_cp: cython.int = ord(')')
249
+ ord_op_G : cython.int = ord_op + 256 * ord('G')
250
+ ord_op_S : cython.int = ord_op + 256 * ord('S')
251
+ ord_op_M : cython.int = ord_op + 256 * ord('M')
252
+ ord_op_c : cython.int = ord_op + 256 * ord('c')
253
+ ord_lt: cython.int = ord('<')
254
+ ord_lt_h : cython.int = ord_lt + 256 * ord('h')
255
+ ord_gt: cython.int = ord('>')
256
+ len_s: cython.Py_ssize_t = len(s)
257
+ cdef vaccum[4] gc_data # [0]: G messages (groups) [1]: S messages (samples?) [2]: c messages (channels from expansion) [3]: M messages
258
+ time_offset = None
259
+ last_time = None
260
+ t1 = time.perf_counter()
261
+ cdef vaccum * data_cat
262
+ cdef accum * data_p
263
+ gpsmsg: vector[cython.uchar]
264
+ show_all: cython.int = 0
265
+ show_bad: cython.int = 0
266
+ while pos < len_s:
267
+ try:
268
+ while True:
269
+ oldpos = pos
270
+ if pos + 10 >= len_s: # smallest message is 3 (frame) + 4 (tc) + 2 (idx) + 1 (data)
271
+ raise IndexError
272
+ msg = <msg_hdr *>&sv[pos]
273
+ typ: cython.int = msg.s.op
274
+ if abs(typ - (ord_op_G + ord_op_S) // 2) == (ord_op_S - ord_op_G) // 2:
275
+ data_cat = &gc_data[typ == ord_op_S]
276
+ data_p = &dereference(data_cat)[msg.s.index]
277
+ if data_p >= &dereference(data_cat.end()):
278
+ raise IndexError
279
+ pos += data_p.add_helper
280
+ last = &sv[pos-1]
281
+ if last[0] != ord_cp:
282
+ raise ValueError("%s at %x" % (chr(s[pos-1]), pos-1))
283
+ if show_all:
284
+ print('tc=%d %s idx=%d' % (msg.s.timecode, chr(msg.s.op >> 8), msg.s.index))
285
+ if msg.s.timecode > data_p.last_timecode:
286
+ data_p.last_timecode = msg.s.timecode
287
+ data_p.data.insert(data_p.data.end(),
288
+ <const cython.uchar *>&msg.s.timecode, last)
289
+ elif typ == ord_op_M:
290
+ data_p = &gc_data[3][msg.s.index]
291
+ if data_p >= &dereference(gc_data[3].end()):
292
+ raise IndexError
293
+ if data_p.Mms == 0:
294
+ raise ValueError('No ms understood for channel %s' %
295
+ channels[msg.s.index].long_name)
296
+ pos += data_p.add_helper * msg.s.count + 10
297
+ if sv[pos] != ord_cp:
298
+ raise ValueError("%s at %x" % (chr(s[pos]), pos))
299
+ if show_all:
300
+ print('tc=%d M idx=%d cnt=%d ms=%d' %
301
+ (msg.s.timecode, msg.s.index, msg.s.count, data_p.Mms))
302
+ if msg.s.timecode > data_p.last_timecode:
303
+ data_p.last_timecode = msg.s.timecode + (msg.s.count-1) * data_p.Mms
304
+ m_tc : cython.int
305
+ for m_tc in range(msg.s.count):
306
+ data_p.timecodes.push_back(msg.s.timecode + m_tc * data_p.Mms)
307
+ data_p.data.insert(data_p.data.end(),
308
+ &sv[oldpos+10], &sv[pos])
309
+ pos += 1
310
+ elif typ == ord_op_c:
311
+ assert msg.c.unk1 == 0, '%x' % msg.c.unk1
312
+ assert (msg.c.channel & 7) == 4, '%x' % (msg.c.channel & 7)
313
+ assert msg.c.unk3 == 0x84, '%x' % msg.c.unk3
314
+ assert msg.c.unk4 == 6, '%x' % msg.c.unk4
315
+ data_cat = &gc_data[2]
316
+ data_p = &dereference(data_cat)[msg.c.channel >> 3]
317
+ if data_p >= &dereference(data_cat.end()):
318
+ raise IndexError
319
+ pos += data_p.add_helper
320
+ last = &sv[pos-1]
321
+ if last[0] != ord_cp:
322
+ raise ValueError("%s at %x" % (chr(s[pos-1]), pos-1))
323
+ if show_all:
324
+ print('tc=%d c idx=%d' % (msg.c.timecode, msg.c.channel >> 3))
325
+ if msg.c.timecode > data_p.last_timecode:
326
+ data_p.last_timecode = msg.c.timecode
327
+ data_p.data.insert(data_p.data.end(),
328
+ <const cython.uchar *>&msg.c.timecode, last)
329
+ elif typ == ord_lt_h:
330
+ if pos > next_progress:
331
+ next_progress += progress_interval
332
+ if progress:
333
+ progress(pos, len(s))
334
+ tok: cython.uint = msg.h.tok
335
+ hlen: cython.Py_ssize_t = msg.h.hlen
336
+ if hlen >= len_s:
337
+ raise IndexError
338
+ ver = msg.h.ver
339
+ assert msg.h.cl == ord_gt, "%s at %x" % (chr(msg.h.cl), pos+11)
340
+ pos += 12
341
+
342
+ # get some "free" range checking here before we go walking data[]
343
+ assert sv[pos+hlen] == ord_lt, "%s at %x" % (s[pos+hlen], pos+hlen)
344
+
345
+ bytesum: cython.ushort = accumulate[byte_ptr, cython.int](
346
+ &sv[pos], &sv[pos+hlen], 0)
347
+ pos += hlen
348
+
349
+ msgf = <hmsg_ftr *>&sv[pos]
350
+
351
+ assert msgf.tok == tok, "%x vs %x at %x" % (msgf.tok, tok, pos+1)
352
+ assert msgf.bytesum == bytesum, '%x vs %x at %x' % (msgf.bytesum, bytesum, pos+5)
353
+ assert msgf.cl == ord_gt, "%s at %x" % (chr(msgf.cl), pos+7)
354
+ pos += 8
355
+
356
+ if (tok >> 24) == 32:
357
+ tok -= 32 << 24 # rstrip(' ')
358
+
359
+ if tok == tok_GPS or tok == tok_GPS1:
360
+ # fast path common case
361
+ gpsmsg.insert(gpsmsg.end(), &sv[oldpos+12], &sv[pos-8])
362
+ else:
363
+ data = s[oldpos + 12 : pos - 8]
364
+ if tok == _tokdec('CNF'):
365
+ data = _decode_sequence(data).messages
366
+ #channels = {} # Replays don't necessarily contain all the original channels
367
+ for m in data[_tokdec('CHS')]:
368
+ channels += [None] * (m.content.index - len(channels) + 1)
369
+ if not channels[m.content.index]:
370
+ channels[m.content.index] = m.content
371
+ _resize_vaccum(gc_data[1], m.content.index)
372
+ gc_data[1][m.content.index].add_helper = m.content.size + 9
373
+ _resize_vaccum(gc_data[2], m.content.index)
374
+ gc_data[2][m.content.index].add_helper = m.content.size + 12
375
+ _resize_vaccum(gc_data[3], m.content.index)
376
+ gc_data[3][m.content.index].add_helper = m.content.size
377
+ gc_data[3][m.content.index].Mms = _Mms_lookup(
378
+ m.content.unknown[64] & 127)
379
+ else:
380
+ assert channels[m.content.index].short_name == m.content.short_name, "%s vs %s" % (channels[m.content.index].short_name, m.content.short_name)
381
+ assert channels[m.content.index].long_name == m.content.long_name
382
+ for m in data.get(_tokdec('GRP'), []):
383
+ groups += [None] * (m.content.index - len(groups) + 1)
384
+ groups[m.content.index] = m.content
385
+ idx = 6
386
+ for ch in m.content.channels:
387
+ channels[ch].group = GroupRef(m.content, idx)
388
+ idx += channels[ch].size
389
+ if show_all:
390
+ print('GROUP', m.content.index,
391
+ [(ch, channels[ch].long_name, channels[ch].size)
392
+ for ch in m.content.channels])
393
+
394
+ _resize_vaccum(gc_data[0], m.content.index)
395
+ gc_data[0][m.content.index].add_helper = 9 + sum(
396
+ channels[ch].size for ch in m.content.channels)
397
+ elif tok == _tokdec('GRP'):
398
+ data = memoryview(data).cast('H')
399
+ assert data[1] == len(data[2:])
400
+ data = Group(index = data[0], channels = data[2:])
401
+ elif tok == _tokdec('CDE'):
402
+ data = ['%02x' % x for x in data]
403
+ elif tok == _tokdec('CHS'):
404
+ dcopy = bytearray(data) # copy
405
+ data = Channel()
406
+ (data.index,
407
+ data.short_name,
408
+ data.long_name,
409
+ data.size) = struct.unpack('<H22x8s24s16xB39x', dcopy)
410
+ try:
411
+ data.units, data.dec_pts = _unit_map[dcopy[12] & 127]
412
+ except KeyError:
413
+ print('Unknown units[%d] for %s' %
414
+ (dcopy[12] & 127, data.long_name))
415
+ data.units = ''
416
+ data.dec_pts = 0
417
+
418
+ # [12] maybe type (lower bits) combined with scale or ??
419
+ # [13] decoder of some type?
420
+ # [20] possibly how to decode bytes
421
+ # [64] data rate. 32=50Hz, 64=25Hz, 80=20Hz, 160=10Hz. What about 5Hz, 2Hz, 1Hz?
422
+ # [84] decoder of some type?
423
+ dcopy[0:2] = [0] * 2 # reset index
424
+ dcopy[24:32] = [0] * 8 # short name
425
+ dcopy[32:56] = [0] * 24 # long name
426
+ data.unknown = bytes(dcopy)
427
+ data.short_name = _nullterm_string(data.short_name)
428
+ data.long_name = _nullterm_string(data.long_name)
429
+ data.timecodes = array('i')
430
+ data.sampledata = bytearray()
431
+ elif tok == _tokdec('LAP'):
432
+ # cache first time offset for use later
433
+ duration, end_time = struct.unpack('4xI8xI', data)
434
+ if time_offset is None:
435
+ time_offset = end_time - duration
436
+ last_time = end_time
437
+ elif tok in (_tokdec('RCR'), _tokdec('VEH'), _tokdec('CMP'), _tokdec('VTY'), _tokdec('NDV'), _tokdec('TMD'), _tokdec('TMT'),
438
+ _tokdec('DBUN'), _tokdec('DBUT'), _tokdec('DVER'), _tokdec('MANL'), _tokdec('MODL'), _tokdec('MANI'),
439
+ _tokdec('MODI'), _tokdec('HWNF'), _tokdec('PDLT'), _tokdec('NTE')):
440
+ data = _nullterm_string(data)
441
+ elif tok == _tokdec('ENF'):
442
+ data = _decode_sequence(data).messages
443
+ elif tok == _tokdec('TRK'):
444
+ data = {'name': _nullterm_string(data[:32]),
445
+ 'sf_lat': memoryview(data).cast('i')[9] / 1e7,
446
+ 'sf_long': memoryview(data).cast('i')[10] / 1e7}
447
+ elif tok == _tokdec('ODO'):
448
+ # not sure how to map fuel.
449
+ # Fuel Used channel claims 8.56l used (2046.0-2037.4)
450
+ # Fuel Used odo says 70689.
451
+ data = {_nullterm_string(data[i:i+16]):
452
+ {'time': memoryview(data[i+16:i+24]).cast('I')[0], # seconds
453
+ 'dist': memoryview(data[i+16:i+24]).cast('I')[1]} # meters
454
+ for i in range(0, len(data), 64)
455
+ # not sure how to parse fuel, doesn't match any expected units
456
+ if not _nullterm_string(data[i:i+16]).startswith('Fuel')}
457
+
458
+ try:
459
+ messages[tok].append(Message(tok, ver, data))
460
+ except KeyError:
461
+ messages[tok] = [Message(tok, ver, data)]
462
+ else:
463
+ assert False, "%02x%02x at %x" % (s[pos], s[pos+1], pos)
464
+ except Exception as _err: # pylint: disable=broad-exception-caught
465
+ if oldpos != badpos + badbytes and badbytes:
466
+ if show_bad:
467
+ print('Bad bytes(%d at %x):' % (badbytes, badpos),
468
+ ', '.join('%02x' % c for c in s[badpos:badpos + badbytes])
469
+ )
470
+ badbytes = 0
471
+ if not badbytes:
472
+ if show_bad:
473
+ sys.stdout.flush()
474
+ traceback.print_exc()
475
+ badpos = oldpos # pylint: disable=unused-variable
476
+ if oldpos < len_s:
477
+ badbytes += 1
478
+ pos = oldpos + 1
479
+ t2 = time.perf_counter()
480
+ if badbytes:
481
+ if show_bad:
482
+ print('Bad bytes(%d at %x):' % (badbytes, badpos),
483
+ ', '.join('%02x' % c for c in s[badpos:badpos + badbytes])
484
+ )
485
+ badbytes = 0
486
+ assert pos == len(s)
487
+ # quick scan through all the groups/channels for the first used timecode
488
+ if channels:
489
+ # int(min(time_offset, time_offset,
490
+ time_offset = int(min(
491
+ ([time_offset] if time_offset is not None else [])
492
+ #XXX*[s2mv[l[0]] for l in g_indices if l.size()],
493
+ #XXX*[s2mv[l[0]] for l in ch_indices if l.size()],
494
+ + [c.timecodes[0] for c in channels if c and len(c.timecodes)],
495
+ default=0))
496
+ last_time = int(max(
497
+ ([last_time] if last_time is not None else [])
498
+ #XXX*[s2mv[l[l.size()-1]] for l in g_indices if l.size()],
499
+ #XXX*[s2mv[l[l.size()-1]] for l in ch_indices if l.size()],
500
+ + [c.timecodes[len(c.timecodes)-1] for c in channels if c and len(c.timecodes)],
501
+ default=0))
502
+ def process_group(g):
503
+ g.samples = np.array([], dtype=np.int32)
504
+ g.timecodes = g.samples.data
505
+ if g.index < gc_data[0].size():
506
+ data_p = &gc_data[0][g.index]
507
+ if data_p.data.size():
508
+ g.samples = np.asarray(<cython.uchar[:data_p.data.size()]> &data_p.data[0])
509
+ rows = len(g.samples) // (data_p.add_helper - 3)
510
+ g.timecodes = np.ndarray(buffer=g.samples, dtype=np.int32,
511
+ shape=(rows,),
512
+ strides=(data_p.add_helper-3,)) - time_offset
513
+ for ch in g.channels:
514
+ process_channel(channels[ch])
515
+
516
+ def process_channel(c):
517
+ if c.long_name in _manual_decoders:
518
+ d = _manual_decoders[c.long_name]
519
+ elif c.unknown[20] in _decoders:
520
+ d = _decoders[c.unknown[20]]
521
+ else:
522
+ return
523
+
524
+ c.interpolate = d.interpolate
525
+ if c.group:
526
+ grp = c.group.group
527
+ c.timecodes = grp.timecodes
528
+ c.sampledata = np.ndarray(buffer=grp.samples[c.group.offset:], dtype=d.stype,
529
+ shape=grp.timecodes.shape,
530
+ strides=(gc_data[0][grp.index].add_helper-3,)).copy()
531
+ else:
532
+ # check for S messages
533
+ view_offset = 6
534
+ stride_offset = 3
535
+ data_p = &gc_data[1][c.index]
536
+ if not data_p.data.size():
537
+ # No? maybe c messages
538
+ view_offset = 4
539
+ stride_offset = 8
540
+ data_p = &gc_data[2][c.index]
541
+ if data_p.data.size():
542
+ assert len(c.timecodes) == 0, "Can't have both S/c and M records for channel %s (index=%d, %d vs %d)" % (c.long_name, c.index, len(c.timecodes), data_p.data.size())
543
+
544
+ # TREAD LIGHTLY - raw pointers here
545
+ view = np.asarray(<cython.uchar[:data_p.data.size()]> &data_p.data[0])
546
+ rows = len(view) // (data_p.add_helper - stride_offset)
547
+
548
+ tc = np.ndarray(buffer=view, dtype=np.int32,
549
+ shape=(rows,), strides=(data_p.add_helper-stride_offset,)).copy()
550
+ samp = np.ndarray(buffer=view[view_offset:], dtype=d.stype,
551
+ shape=(rows,), strides=(data_p.add_helper-stride_offset,)).copy()
552
+ else:
553
+ data_p = &gc_data[3][c.index] # M messages
554
+ if data_p.timecodes.size():
555
+ tc = np.asarray(<cython.int[:data_p.timecodes.size()]>
556
+ &data_p.timecodes[0]).copy()
557
+ samp = np.ndarray(buffer=np.asarray(<cython.uchar[:data_p.data.size()]>
558
+ &data_p.data[0]),
559
+ dtype=d.stype, shape=tc.shape).copy()
560
+ else:
561
+ tc = _ndarray_from_mv(c.timecodes)
562
+ samp = _ndarray_from_mv(memoryview(c.sampledata).cast(d.stype))
563
+ c.timecodes = (tc - time_offset).data
564
+ c.sampledata = samp.data
565
+
566
+ if d.fixup:
567
+ c.sampledata = memoryview(d.fixup(c.sampledata))
568
+ if c.units == 'V': # most are really encoded as mV, but one or two aren't....
569
+ c.sampledata = np.divide(c.sampledata, 1000).data
570
+
571
+ laps = None
572
+ if not channels:
573
+ t4 = time.perf_counter()
574
+ pass # nothing to do
575
+ elif progress:
576
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(2, os.cpu_count())) as worker:
577
+ bg_work = worker.submit(_bg_gps_laps, <cython.uchar[:gpsmsg.size()]> &gpsmsg[0],
578
+ messages, time_offset, last_time)
579
+ group_work = worker.map(process_group, [x for x in groups if x])
580
+ channel_work = worker.map(process_channel,
581
+ [x for x in channels if x and not x.group])
582
+ gps_ch, laps = bg_work.result()
583
+ t4 = time.perf_counter()
584
+ for i in group_work:
585
+ pass
586
+ for i in channel_work:
587
+ pass
588
+ channels.extend(gps_ch)
589
+ else:
590
+ for g in groups:
591
+ if g: process_group(g)
592
+ for c in channels:
593
+ if c and not c.group: process_channel(c)
594
+ t4 = time.perf_counter()
595
+ gps_ch, laps = _bg_gps_laps(<cython.uchar[:gpsmsg.size()]> &gpsmsg[0],
596
+ messages, time_offset, last_time)
597
+ channels.extend(gps_ch)
598
+
599
+ t3 = time.perf_counter()
600
+ if t3-t1 > 0.1:
601
+ print('division: scan=%f, gps=%f, group/ch=%f more' % (t2-t1, t4-t2, t3-t4))
602
+
603
+ return DataStream(
604
+ channels={ch.long_name: ch for ch in channels
605
+ if ch and len(ch.sampledata)
606
+ and ch.long_name not in ('StrtRec', 'Master Clk')},
607
+ messages=messages,
608
+ laps=laps,
609
+ time_offset=time_offset)
610
+
611
+ def _get_metadata(msg_by_type):
612
+ ret = {}
613
+ for msg, name in [(_tokdec('RCR'), 'Driver'),
614
+ (_tokdec('VEH'), 'Vehicle'),
615
+ (_tokdec('TMD'), 'Log Date'),
616
+ (_tokdec('TMT'), 'Log Time'),
617
+ (_tokdec('VTY'), 'Session'),
618
+ (_tokdec('CMP'), 'Series'),
619
+ (_tokdec('NTE'), 'Long Comment'),
620
+ ]:
621
+ if msg in msg_by_type:
622
+ ret[name] = msg_by_type[msg][-1].content
623
+ if _tokdec('TRK') in msg_by_type:
624
+ ret['Venue'] = msg_by_type[_tokdec('TRK')][-1].content['name']
625
+ # ignore the start/finish line?
626
+ if _tokdec('ODO') in msg_by_type:
627
+ for name, stats in msg_by_type[_tokdec('ODO')][-1].content.items():
628
+ ret['Odo/%s Distance (km)' % name] = stats['dist'] / 1000
629
+ ret['Odo/%s Time' % name] = '%d:%02d:%02d' % (stats['time'] // 3600,
630
+ stats['time'] // 60 % 60,
631
+ stats['time'] % 60)
632
+ return ret
633
+
634
+ def _bg_gps_laps(gpsmsg, msg_by_type, time_offset, last_time):
635
+ channels = _decode_gps(gpsmsg, time_offset)
636
+ lat_ch = None
637
+ lon_ch = None
638
+ for ch in channels:
639
+ if ch.long_name == 'GPS Latitude': lat_ch = ch
640
+ if ch.long_name == 'GPS Longitude': lon_ch = ch
641
+ laps = _get_laps(lat_ch, lon_ch, msg_by_type, time_offset, last_time)
642
+ return channels, laps
643
+
644
+ def _decode_gps(gpsmsg, time_offset):
645
+ if not gpsmsg: return []
646
+ alldata = memoryview(gpsmsg)
647
+ assert len(alldata) % 56 == 0
648
+ timecodes = np.asarray(alldata[0:].cast('i')[::56//4])
649
+ # certain old MXP firmware (and maybe others) would periodically
650
+ # butcher the upper 16-bits of the timecode field. If necessary,
651
+ # reconstruct it using only the bottom 16-bits and assuming time
652
+ # never skips ahead too far.
653
+ if np.any(timecodes[1:] < timecodes[:-1]):
654
+ timecodes = (timecodes & 65535) + (timecodes[0] - (timecodes[0] & 65535))
655
+ timecodes += 65536 * np.cumsum(np.concatenate(([0], timecodes[1:] < timecodes[:-1])))
656
+ #itow_ms = alldata[4:].cast('I')[::56//4]
657
+ #weekN = alldata[12:].cast('H')[::56//2]
658
+ ecefX_cm = alldata[16:].cast('i')[::56//4]
659
+ ecefY_cm = alldata[20:].cast('i')[::56//4]
660
+ ecefZ_cm = alldata[24:].cast('i')[::56//4]
661
+ #posacc_cm = alldata[28:].cast('i')[::56//4]
662
+ ecefdX_cms = alldata[32:].cast('i')[::56//4]
663
+ ecefdY_cms = alldata[36:].cast('i')[::56//4]
664
+ ecefdZ_cms = alldata[40:].cast('i')[::56//4]
665
+ #velacc_cms = alldata[44:].cast('i')[::56//4]
666
+ #nsat = alldata[51::56]
667
+
668
+ timecodes = memoryview(timecodes - time_offset)
669
+
670
+ gpsconv = gps.ecef2lla(np.divide(ecefX_cm, 100),
671
+ np.divide(ecefY_cm, 100),
672
+ np.divide(ecefZ_cm, 100))
673
+
674
+ return [Channel(
675
+ long_name='GPS Speed',
676
+ units='m/s',
677
+ dec_pts=1,
678
+ interpolate=True,
679
+ timecodes=timecodes,
680
+ sampledata=memoryview(np.sqrt(np.square(ecefdX_cms) +
681
+ np.square(ecefdY_cms) +
682
+ np.square(ecefdZ_cms)) / 100.)),
683
+ Channel(long_name='GPS Latitude', units='deg', dec_pts=4, interpolate=True,
684
+ timecodes=timecodes, sampledata=memoryview(gpsconv.lat)),
685
+ Channel(long_name='GPS Longitude', units='deg', dec_pts=4, interpolate=True,
686
+ timecodes=timecodes, sampledata=memoryview(gpsconv.long)),
687
+ Channel(long_name='GPS Altitude', units='m', dec_pts=1, interpolate=True,
688
+ timecodes=timecodes, sampledata=memoryview(gpsconv.alt))]
689
+
690
+ def _get_laps(lat_ch, lon_ch, msg_by_type, time_offset, last_time):
691
+ lap_nums = []
692
+ start_times = []
693
+ end_times = []
694
+
695
+ if lat_ch and lon_ch:
696
+ # If we have GPS, do gps lap insert.
697
+
698
+ track = msg_by_type[_tokdec('TRK')][-1].content
699
+ XYZ = np.column_stack(gps.lla2ecef(np.array(lat_ch.sampledata),
700
+ np.array(lon_ch.sampledata), 0))
701
+ lap_markers = gps.find_laps(XYZ,
702
+ np.array(lat_ch.timecodes),
703
+ (track['sf_lat'], track['sf_long']))
704
+
705
+ lap_markers = [0] + lap_markers + [last_time - time_offset]
706
+
707
+ for lap, (start_time, end_time) in enumerate(zip(lap_markers[:-1], lap_markers[1:])):
708
+ lap_nums.append(lap)
709
+ start_times.append(start_time)
710
+ end_times.append(end_time)
711
+ else:
712
+ # otherwise, use the lap data provided.
713
+ if _tokdec('LAP') in msg_by_type:
714
+ for m in msg_by_type[_tokdec('LAP')]:
715
+ # 2nd byte is segment #, see M4GT4
716
+ segment, lap, duration, end_time = struct.unpack('xBHIxxxxxxxxI', m.content)
717
+ end_time -= time_offset
718
+ if segment:
719
+ continue
720
+ elif not lap_nums:
721
+ pass
722
+ elif lap_nums[-1] == lap:
723
+ continue
724
+ elif lap_nums[-1] + 1 == lap:
725
+ pass
726
+ elif lap_nums[-1] + 2 == lap:
727
+ # emit inferred lap
728
+ lap_nums.append(lap - 1)
729
+ start_times.append(end_times[-1])
730
+ end_times.append(end_time - duration)
731
+ else:
732
+ assert False, 'Lap gap from %d to %d' % (lap_nums[-1], lap)
733
+ lap_nums.append(lap)
734
+ start_times.append(end_time - duration)
735
+ end_times.append(end_time)
736
+
737
+ # Create PyArrow table
738
+ return pa.table({
739
+ 'num': pa.array(lap_nums, type=pa.int32()),
740
+ 'start_time': pa.array(start_times, type=pa.int64()),
741
+ 'end_time': pa.array(end_times, type=pa.int64())
742
+ })
743
+
744
+
745
+ def _channel_to_table(ch):
746
+ """Convert a Channel object to a PyArrow table with metadata."""
747
+ # Create metadata dict for the channel data field (without name, as it's the column name)
748
+ metadata = {
749
+ b'units': (ch.units if ch.size != 1 else '').encode('utf-8'),
750
+ b'dec_pts': str(ch.dec_pts).encode('utf-8'),
751
+ b'interpolate': str(ch.interpolate).encode('utf-8')
752
+ }
753
+
754
+ # Determine the appropriate type for values based on the data
755
+ if isinstance(ch.sampledata, memoryview):
756
+ values_array = np.array(ch.sampledata)
757
+ else:
758
+ values_array = ch.sampledata
759
+
760
+ # Create the schema with metadata on the channel data field
761
+ # Use the actual channel name as the column name
762
+ channel_field = pa.field(ch.long_name, pa.from_numpy_dtype(values_array.dtype), metadata=metadata)
763
+ schema = pa.schema([
764
+ pa.field('timecodes', pa.int64()),
765
+ channel_field
766
+ ])
767
+
768
+ # Create the table with the channel name as the column name
769
+ return pa.table({
770
+ 'timecodes': pa.array(ch.timecodes, type=pa.int64()),
771
+ ch.long_name: pa.array(values_array)
772
+ }, schema=schema)
773
+
774
+
775
+ def _decompress_if_zlib(data):
776
+ """Decompress zlib-compressed data if detected, otherwise return as-is.
777
+
778
+ XRZ files are XRK files compressed with zlib. They start with zlib magic
779
+ bytes (0x78 followed by 0x01, 0x9C, or 0xDA).
780
+ """
781
+ if len(data) < 2:
782
+ return data
783
+
784
+ # Check for zlib magic bytes
785
+ first_byte = data[0] if isinstance(data[0], int) else ord(data[0])
786
+ second_byte = data[1] if isinstance(data[1], int) else ord(data[1])
787
+
788
+ if first_byte == 0x78 and second_byte in (0x01, 0x9C, 0xDA):
789
+ return zlib.decompress(bytes(data))
790
+
791
+ return data
792
+
793
+
794
+ class _open_xrk:
795
+ """Context manager that opens an XRK/XRZ file, using mmap if available, falling back to read().
796
+
797
+ This handles environments like JupyterLite where mmap may not be supported.
798
+ Also accepts bytes or file-like objects directly.
799
+ XRZ files (zlib-compressed XRK) are automatically decompressed.
800
+ """
801
+ def __init__(self, source):
802
+ self._source = source
803
+ self._file = None
804
+ self._mmap = None
805
+ self._data = None
806
+
807
+ def __enter__(self):
808
+ # Handle bytes input directly
809
+ if isinstance(self._source, (bytes, bytearray)):
810
+ self._data = _decompress_if_zlib(self._source)
811
+ return self._data
812
+
813
+ # Handle memoryview - convert to bytes for consistent handling
814
+ if isinstance(self._source, memoryview):
815
+ self._data = _decompress_if_zlib(bytes(self._source))
816
+ return self._data
817
+
818
+ # Handle file-like objects (BytesIO, etc.)
819
+ if hasattr(self._source, 'read'):
820
+ self._source.seek(0)
821
+ self._data = _decompress_if_zlib(self._source.read())
822
+ return self._data
823
+
824
+ # Handle file path - try mmap first, fall back to read()
825
+ self._file = open(self._source, 'rb')
826
+ try:
827
+ self._mmap = mmap.mmap(self._file.fileno(), 0, access=mmap.ACCESS_READ)
828
+ # Check if zlib compressed - if so, decompress and use bytes instead of mmap
829
+ if len(self._mmap) >= 2 and self._mmap[0] == 0x78 and self._mmap[1] in (0x01, 0x9C, 0xDA):
830
+ self._data = zlib.decompress(self._mmap[:])
831
+ self._mmap.close()
832
+ self._mmap = None
833
+ return self._data
834
+ return self._mmap
835
+ except (OSError, ValueError):
836
+ # mmap failed (e.g., JupyterLite/IDBFS) - fall back to read()
837
+ self._file.seek(0)
838
+ self._data = _decompress_if_zlib(self._file.read())
839
+ return self._data
840
+
841
+ def __exit__(self, exc_type, exc_val, exc_tb):
842
+ if self._mmap is not None:
843
+ self._mmap.close()
844
+ if self._file is not None:
845
+ self._file.close()
846
+ return False
847
+
848
+
849
+ def aim_xrk(fname, progress=None):
850
+ """Load an AIM XRK or XRZ file.
851
+
852
+ Args:
853
+ fname: Path to the XRK/XRZ file, or bytes/BytesIO containing file data
854
+ progress: Optional progress callback
855
+
856
+ Returns:
857
+ LogFile object with channels, laps, and metadata
858
+ """
859
+ with _open_xrk(fname) as m:
860
+ data = _decode_sequence(m, progress)
861
+
862
+ log = base.LogFile(
863
+ {ch.long_name: _channel_to_table(ch) for ch in data.channels.values()},
864
+ data.laps,
865
+ _get_metadata(data.messages),
866
+ fname if not isinstance(fname, (bytes, bytearray, memoryview)) and not hasattr(fname, 'read') else "<bytes>")
867
+
868
+ # Fix GPS timing gaps (spurious timestamp jumps in some AIM loggers)
869
+ fix_gps_timing_gaps(log)
870
+
871
+ return log
872
+
873
+
874
+ def aim_track_dbg(fname):
875
+ """Debug function to extract track data from an AIM XRK file."""
876
+ with _open_xrk(fname) as m:
877
+ data = _decode_sequence(m, None)
878
+ return {_tokenc(k): v for k, v in data.messages.items()}
879
+
880
+ #def _help_decode_channels(self, chmap):
881
+ # pprint(chmap)
882
+ # for i in range(len(self.data.channels[0].unknown)):
883
+ # d = sorted([(v.unknown[i], chmap.get(v.long_name, ''), v.long_name)
884
+ # for v in self.data.channels
885
+ # if len(v.unknown) > i])
886
+ # if len(set([x[0] for x in d])) == 1:
887
+ # continue
888
+ # pprint((i, d))
889
+ # d = sorted([(len(v.sampledata), chmap.get(v.long_name, ''), v.long_name)
890
+ # for v in self.data.channels])
891
+ # if len(set([x[0] for x in d])) != 1:
892
+ # pprint(('len', d))