libxrk 0.7.0__cp312-cp312-win_amd64.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/gps.py ADDED
@@ -0,0 +1,649 @@
1
+ # Copyright 2024, Scott Smith. MIT License (see LICENSE).
2
+
3
+ from collections import namedtuple
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+ import pyarrow as pa
8
+
9
+ if TYPE_CHECKING:
10
+ from .base import LogFile
11
+
12
+ # GPS channel names that share a common timebase and may need timing correction
13
+ GPS_CHANNEL_NAMES = ("GPS Speed", "GPS Latitude", "GPS Longitude", "GPS Altitude")
14
+
15
+ # None of the algorithms are slow
16
+ # fastest: Fukushima 2006, but worse accuracy
17
+ # most accurate: Vermeille, also very compact code
18
+ # runner up: Osen
19
+
20
+
21
+ # Also considered:
22
+
23
+ # https://ea4eoz.blogspot.com/2015/11/simple-wgs-84-ecef-conversion-functions.html
24
+ # slower algorithm, not great accuracy
25
+
26
+ # http://wiki.gis.com/wiki/index.php/Geodetic_system
27
+ # poor height accuracy
28
+
29
+ # Olson, D. K., Converting Earth-Centered, Earth-Fixed Coordinates to
30
+ # Geodetic Coordinates, IEEE Transactions on Aerospace and Electronic
31
+ # Systems, 32 (1996) 473-476.
32
+ # difficult to vectorize (poor for numpy/python)
33
+
34
+ GPS = namedtuple("GPS", ["lat", "long", "alt"])
35
+
36
+
37
+ # convert lat/long/zoom to web mercator. lat/long are degrees
38
+ # returns x,y as floats - integer component is which tile to download
39
+ def llz2web(lat, long, zoom=0):
40
+ # wikipedia web mercator projection
41
+ mult = 0.25 * (2 << zoom)
42
+ return (
43
+ mult * (1 + long / 180),
44
+ mult * (1 - np.log(np.tan(np.pi / 4 + np.pi / 360 * lat)) / np.pi),
45
+ )
46
+
47
+
48
+ # returns lat/long as floats in degrees
49
+ def web2ll(x, y, zoom=0):
50
+ mult = 1 / (0.25 * (2 << zoom))
51
+ return (
52
+ np.arctan(np.exp(np.pi - np.multiply(np.pi * mult, y))) * 360 / np.pi - 90,
53
+ np.multiply(180 * mult, x) - 180,
54
+ )
55
+
56
+
57
+ # lat, long = degrees
58
+ # x, y, z, alt = meters
59
+ def lla2ecef(lat, lon, alt):
60
+ a = 6378137
61
+ e = 8.181919084261345e-2
62
+ e_sq = e * e
63
+
64
+ lat = lat * (np.pi / 180)
65
+ lon = lon * (np.pi / 180)
66
+
67
+ clat = np.cos(lat)
68
+ slat = np.sin(lat)
69
+
70
+ N = a / np.sqrt(1 - e_sq * slat * slat)
71
+
72
+ x = (N + alt) * clat * np.cos(lon)
73
+ y = (N + alt) * clat * np.sin(lon)
74
+ z = ((1 - e_sq) * N + alt) * slat
75
+
76
+ return x, y, z
77
+
78
+
79
+ # Karl Osen. Accurate Conversion of Earth-Fixed Earth-Centered Coordinates to Geodetic Coordinates.
80
+ # [Research Report] Norwegian University of Science and Technology. 2017. ￿hal-01704943v2
81
+ # https://hal.science/hal-01704943v2/document
82
+ # pretty accurate, reasonably fast
83
+ def ecef2lla_osen(x, y, z):
84
+ invaa = +2.45817225764733181057e-0014 # 1/(a^2)
85
+ l = +3.34718999507065852867e-0003 # (e^2)/2
86
+ p1mee = +9.93305620009858682943e-0001 # 1-(e^2)
87
+ p1meedaa = +2.44171631847341700642e-0014 # (1-(e^2))/(a^2)
88
+ ll4 = +4.48147234524044602618e-0005 # 4*(l^2) = e^4
89
+ ll = +1.12036808631011150655e-0005 # l^2 = (e^4)/4
90
+ invcbrt2 = +7.93700525984099737380e-0001 # 1/(2^(1/3))
91
+ inv3 = +3.33333333333333333333e-0001 # 1/3
92
+ inv6 = +1.66666666666666666667e-0001 # 1/6
93
+
94
+ w = x * x + y * y
95
+ m = w * invaa
96
+ w = np.sqrt(w)
97
+ n = z * z * p1meedaa
98
+ mpn = m + n
99
+ p = inv6 * (mpn - ll4)
100
+ P = p * p
101
+ G = m * n * ll
102
+ H = 2 * P * p + G
103
+ p = None
104
+ # if H < Hmin: return -1
105
+ C = np.cbrt(H + G + 2 * np.sqrt(H * G)) * invcbrt2
106
+ G = None
107
+ H = None
108
+ i = -ll - 0.5 * mpn
109
+ beta = inv3 * i - C - P / C
110
+ C = None
111
+ P = None
112
+ k = ll * (ll - mpn)
113
+ mpn = None
114
+ # Compute t
115
+ t = np.sqrt(np.sqrt(beta * beta - k) - 0.5 * (beta + i)) + np.sqrt(np.abs(0.5 * (beta - i))) * (
116
+ 2 * (m < n) - 1
117
+ )
118
+ beta = None
119
+ # Use Newton-Raphson's method to compute t correction
120
+ g = 2 * l * (m - n)
121
+ m = None
122
+ n = None
123
+ tt = t * t
124
+ dt = -(tt * (tt + (i + i)) + g * t + k) / (4 * t * (tt + i) + g)
125
+ g = None
126
+ i = None
127
+ tt = None
128
+ # compute latitude (range -PI/2..PI/2)
129
+ u = t + dt + l
130
+ v = t + dt - l
131
+ dt = None
132
+ zu = z * u
133
+ wv = w * v
134
+ # compute altitude
135
+ invuv = 1 / (u * v)
136
+ return GPS(
137
+ np.arctan2(zu, wv) * (180 / np.pi),
138
+ np.arctan2(y, x) * (180 / np.pi),
139
+ np.sqrt(np.square(w - wv * invuv) + np.square(z - zu * p1mee * invuv)) * (1 - 2 * (u < 1)),
140
+ )
141
+
142
+
143
+ # https://www.researchgate.net/publication/227215135_Transformation_from_Cartesian_to_Geodetic_Coordinates_Accelerated_by_Halley's_Method/link/0912f50af90e6de252000000/download
144
+ # "Fukushima 2006"
145
+ # fastest, reasonably accurate but not best
146
+ def ecef2lla_fukushima2006(x, y, z):
147
+ a = 6378137.0
148
+ finv = 298.257222101
149
+ f = 1.0 / finv
150
+ e2 = (2 - f) * f
151
+ ec2 = 1 - e2
152
+ ec = np.sqrt(ec2)
153
+ # b = a * ec
154
+ c = a * e2
155
+ # PIH = 2 * np.arctan(1.)
156
+
157
+ lamb = np.arctan2(y, x)
158
+ s0 = np.abs(z)
159
+ p2 = x * x + y * y
160
+ p = np.sqrt(p2)
161
+ zc = ec * s0
162
+ c0 = ec * p
163
+ c02 = c0 * c0
164
+ s02 = s0 * s0
165
+ a02 = c02 + s02
166
+ a0 = np.sqrt(a02)
167
+ # a03 = a02 * a0
168
+ a03 = a02
169
+ a03 *= a0
170
+ a02 = None
171
+ # s1 = zc * a03 + c * (s02 * s0)
172
+ s02 *= s0
173
+ s02 *= c
174
+ s1 = s02
175
+ s1 += zc * a03
176
+ s02 = None
177
+ # c1 = p * a03 - c * (c02 * c0)
178
+ c02 *= c0
179
+ c02 *= c
180
+ c1 = p * a03 - c02
181
+ c02 = None
182
+ cs0c0 = c * c0 * s0
183
+ # b0 = 1.5 * cs0c0 * ((p*s0 - zc*c0) * a0 - cs0c0)
184
+ zc *= c0
185
+ b0 = cs0c0
186
+ b0 *= 1.5 * ((p * s0 - zc) * a0 - cs0c0)
187
+ a0 = None
188
+ zc = None
189
+ cs0c0 = None
190
+ s1 = s1 * a03 - b0 * s0
191
+ # cc = ec * (c1 * a03 - b0 * c0)
192
+ c1 *= a03
193
+ b0 *= c0
194
+ c1 -= b0
195
+ cc = c1
196
+ cc *= ec
197
+ c1 = None
198
+ a03 = None
199
+ c0 = None
200
+ b0 = None
201
+ phi = np.arctan2(s1, cc)
202
+ s12 = s1 * s1
203
+ cc2 = cc * cc
204
+ h = (p * cc + s0 * s1 - a * np.sqrt(ec2 * s12 + cc2)) / np.sqrt(s12 + cc2)
205
+ s1 = None
206
+ cc = None
207
+ s12 = None
208
+ cc2 = None
209
+ phi = np.copysign(phi, z)
210
+
211
+ return GPS(phi * (180 / np.pi), lamb * (180 / np.pi), h)
212
+
213
+
214
+ # Computing geodetic coordinates from geocentric coordinates
215
+ # H. Vermeille, 2003/2004
216
+ # http://users.auth.gr/kvek/78_Vermeille.pdf
217
+ def ecef2lla_vermeille2003(x, y, z):
218
+ a = 6378137.0
219
+ e = 8.181919084261345e-2
220
+
221
+ p = (x * x + y * y) * (1 / (a * a))
222
+ q = ((1 - e * e) / (a * a)) * z * z
223
+ r = (p + q - e**4) * (1 / 6)
224
+ s = (e**4 / 4) * p * q / (r**3)
225
+ p = None
226
+ t = np.cbrt(1 + s + np.sqrt(s * (2 + s)))
227
+ s = None
228
+ u = r * (1 + t + 1 / t)
229
+ r = None
230
+ t = None
231
+ v = np.sqrt(u * u + e**4 * q)
232
+ u += v # precalc
233
+ w = (e**2 / 2) * (u - q) / v
234
+ q = None
235
+ k = np.sqrt(u + w * w) - w
236
+ D = k * np.sqrt(x * x + y * y) / (k + e**2)
237
+ rtDDzz = np.sqrt(D * D + z * z)
238
+ return GPS(
239
+ (180 / np.pi) * 2 * np.arctan2(z, D + rtDDzz),
240
+ (180 / np.pi) * np.arctan2(y, x),
241
+ (k + e**2 - 1) / k * rtDDzz,
242
+ )
243
+
244
+
245
+ def find_crossing_idx(
246
+ XYZ: np.ndarray, marker: np.ndarray # coordinates to look up in (X, Y, Z), meters
247
+ ): # (lat, long), degrees
248
+
249
+ if isinstance(marker, tuple):
250
+ marker = np.array(marker)
251
+ if len(marker.shape) == 1:
252
+ # force it to be a 2d shape to make the rest of the code simpler
253
+ return find_crossing_idx(XYZ, marker.reshape((1, len(marker))))[0]
254
+
255
+ # very similar to gps lap insert, but we can assume XYZ is a
256
+ # reference (as opposed to GPS lap insert where we aren't sure if
257
+ # the trajectory of the GPS is correct or not - think pit stops,
258
+ # going off track, etc). As a result, only one pass is needed.
259
+ # Also we do not need to filter based on minspeed, so no timecodes
260
+ # are used in this function.
261
+
262
+ lat = marker[:, 0].reshape((len(marker), 1))
263
+ lon = marker[:, 1].reshape((len(marker), 1))
264
+ SO = np.stack(lla2ecef(lat, lon, 0), axis=2)
265
+ SD = np.stack(lla2ecef(lat, lon, 1000), axis=2) - SO
266
+
267
+ O = XYZ[:, :3]
268
+ D = O[1:] - O[:-1]
269
+ O = O[:-1] - SO
270
+
271
+ SN = np.sum(SD * SD, axis=2, keepdims=True) * D - np.sum(SD * D, axis=2, keepdims=True) * SD
272
+ t = np.clip(
273
+ -np.sum(SN * O, axis=2, keepdims=True) / np.sum(SN * D, axis=2, keepdims=True),
274
+ 0,
275
+ 1,
276
+ )
277
+
278
+ # XXX This won't work with rally stages (anything not a circuit)
279
+ distsq = np.sum(np.square(O + t * D), axis=2)
280
+ minidx = np.argmin(distsq, axis=1)
281
+ colrange = np.arange(t.shape[0])
282
+ return np.column_stack([minidx + t[colrange, minidx, 0], np.sqrt(distsq[colrange, minidx])])
283
+
284
+
285
+ def find_crossing_dist(
286
+ XYZD: np.ndarray, # coordinates to look up in (X, Y, Z, Distance), meters
287
+ marker: tuple[float, float],
288
+ ): # (lat, long) tuple, degrees
289
+ idx, _ = find_crossing_idx(XYZD, np.array(marker))
290
+ if idx + 1 >= len(XYZD):
291
+ return XYZD[int(idx), 3]
292
+ scale, idx = np.modf(idx)
293
+ return XYZD[int(idx), 3] + scale * (XYZD[int(idx) + 1, 3] - XYZD[int(idx), 3])
294
+
295
+
296
+ def find_laps(
297
+ XYZ: np.ndarray, # coordinates to look up in (X, Y, Z), meters
298
+ timecodes: np.ndarray, # time for above coordinates, ms
299
+ marker: tuple[float, float],
300
+ ): # (lat, long) tuple, degrees
301
+ # gps lap insert. We assume the start finish "line" is a
302
+ # plane containing the vector that goes through the GPS
303
+ # coordinates sf lat/long from altitude 0 to 1000. The normal
304
+ # of the plane is generally in line with the direction of
305
+ # travel, given the above constraint.
306
+
307
+ # O, D = vehicle vector (O=origin, D=direction, [0]=O, [1]=O+D)
308
+ # SO, SD = start finish origin, direction (plane must contain SO and SO+SD poitns)
309
+ # SN = start finish plane normal
310
+
311
+ # D = a*SD + SN
312
+ # 0 = SD . SN
313
+ # combine to get: 0 = SD . (D - a*SD)
314
+ # a * (SD . SD) = SD . D
315
+ # plug back into first eq:
316
+ # SN = D - (SD . D) / (SD . SD) * SD
317
+ # or to avoid division, and because length doesn't matter:
318
+ # SN = (SD . SD) * D - (SD. D) * SD
319
+
320
+ # now determine intersection with plane SO,SN from vector O,O+D:
321
+ # SN . (O + tD - SO) = 0
322
+ # t * (D . SN) + SN . (O - SO) = 0
323
+ # t = -SN.(O-SO) / D.SN
324
+
325
+ SO = np.array(lla2ecef(*marker, 0.0)).reshape((1, 3))
326
+ SD = np.array(lla2ecef(*marker, 1000)).reshape((1, 3)) - SO
327
+
328
+ O = XYZ - SO
329
+
330
+ D = O[1:] - O[:-1]
331
+ O = O[:-1]
332
+
333
+ # VBox seems to need 30, maybe my friend is using an old map description
334
+ marker_size = 30 # meters, how far you can be from the marker to count as a lap
335
+
336
+ # Precalculate in which time periods we were traveling at least 4 m/s (~10mph)
337
+ minspeed = np.sum(D * D, axis=1) > np.square((timecodes[1:] - timecodes[:-1]) * (4 / 1000))
338
+
339
+ SN = (
340
+ np.sum(SD * SD, axis=1).reshape((len(SD), 1)) * D
341
+ - np.sum(SD * D, axis=1).reshape((len(D), 1)) * SD
342
+ )
343
+ t = np.maximum(-np.sum(SN * O, axis=1) / np.sum(SN * D, axis=1), 0)
344
+ # This only works because the track is considered at altitude 0
345
+ dist = np.sum(np.square(O + t.reshape((len(t), 1)) * D), axis=1)
346
+ pick = (t[1:] <= 1) & (t[:-1] > 1) & (dist[1:] < marker_size**2)
347
+
348
+ # Now that we have a decent candidate selection of lap
349
+ # crossings, generate a single normal vector for the
350
+ # start/finish line to use for all lap crossings, to make the
351
+ # lap times more accurate/consistent. Weight the crossings by
352
+ # velocity and add them together. As it happens, SN is
353
+ # already weighted by velocity...
354
+ SN = np.sum(SN[1:][pick & minspeed[1:]], axis=0).reshape((1, 3))
355
+ # recompute t, dist, pick
356
+ t = np.maximum(-np.sum(SN * O, axis=1) / np.sum(SN * D, axis=1), 0)
357
+ dist = np.sum(np.square(O + t.reshape((len(t), 1)) * D), axis=1)
358
+ pick = (t[1:] <= 1) & (t[:-1] > 1) & (dist[1:] < marker_size**2)
359
+
360
+ lap_markers = [0]
361
+ for idx in np.nonzero(pick)[0] + 1:
362
+ if timecodes[idx] <= lap_markers[-1]:
363
+ continue
364
+ if not minspeed[idx]:
365
+ idx = np.argmax(minspeed[idx:]) + idx
366
+ lap_markers.append(timecodes[idx] + t[idx] * (timecodes[idx + 1] - timecodes[idx]))
367
+ return lap_markers[1:]
368
+
369
+
370
+ ecef2lla = ecef2lla_vermeille2003
371
+
372
+ if __name__ == "__main__":
373
+
374
+ def perf_test():
375
+ import time
376
+
377
+ samples = 10000000
378
+ lat = -90.0 + 180.0 * np.random.rand(samples, 1)
379
+ long = -180.0 + 360.0 * np.random.rand(samples, 1)
380
+ alt = -11e3 + (20e3) * np.random.rand(
381
+ samples, 1
382
+ ) # From approximately the bottom of the Mariana trench, to the top of the Everest
383
+
384
+ print("generating x,y,z")
385
+ x, y, z = lla2ecef(lat, long, alt)
386
+ algos = [
387
+ ("osen", ecef2lla_osen),
388
+ ("fukushima2006", ecef2lla_fukushima2006),
389
+ ("vermeille2003", ecef2lla_vermeille2003),
390
+ ]
391
+ stats: dict[str, list[float]] = {name: [] for name, algo in algos}
392
+ for _ in range(5):
393
+ for name, algo in algos:
394
+ start = time.time()
395
+ ilat, ilong, ialt = algo(x, y, z)
396
+ duration = time.time() - start
397
+ stats[name].append(duration)
398
+ print("algorithm %s took %.3f" % (name, duration))
399
+ print(
400
+ " avg",
401
+ np.sqrt(np.sum((ilat - lat) ** 2)) / len(ilat),
402
+ np.sqrt(np.sum((ilong - long) ** 2)) / len(ilong),
403
+ np.sqrt(np.sum((ialt - alt) ** 2)) / len(ialt),
404
+ )
405
+ print(
406
+ " max",
407
+ np.max(np.abs(ilat - lat)),
408
+ np.max(np.abs(ilong - long)),
409
+ np.max(np.abs(ialt - alt)),
410
+ )
411
+ for name, stat in stats.items():
412
+ print(name, ", ".join(["%.3f" % s for s in stat]))
413
+
414
+ perf_test()
415
+
416
+
417
+ def detect_gps_timing_offset_from_gnfi(
418
+ gps_timecodes: np.ndarray,
419
+ gnfi_timecodes: np.ndarray,
420
+ expected_dt_ms: float = 40.0,
421
+ ) -> list[tuple[int, int]]:
422
+ """Detect GPS timing offset using GNFI as reference clock.
423
+
424
+ GNFI messages run on the logger's internal clock (NOT the buggy GPS timecode
425
+ stream). They are continuous with no gaps and end at the true session end time.
426
+ By comparing the GPS end time to GNFI end time, we can detect if the GPS
427
+ firmware bug added ~65533ms to GPS timecodes.
428
+
429
+ Args:
430
+ gps_timecodes: GPS channel timecodes array
431
+ gnfi_timecodes: GNFI timecodes array (logger internal clock)
432
+ expected_dt_ms: Expected time delta between GPS samples (default 40ms = 25Hz)
433
+
434
+ Returns:
435
+ List of (gap_time, correction) tuples, or empty list if no bug detected
436
+ """
437
+ if gnfi_timecodes is None or len(gnfi_timecodes) < 2:
438
+ return []
439
+
440
+ if len(gps_timecodes) < 2:
441
+ return []
442
+
443
+ OVERFLOW_BUG_MS = 65533
444
+ TOLERANCE = 5000 # Allow 5 second tolerance for end-time comparison
445
+
446
+ gps_end = int(gps_timecodes[-1])
447
+ gnfi_end = int(gnfi_timecodes[-1])
448
+ offset = gps_end - gnfi_end
449
+
450
+ # Check if GPS extends ~65533ms beyond GNFI (the bug signature)
451
+ if not (OVERFLOW_BUG_MS - TOLERANCE <= offset <= OVERFLOW_BUG_MS + TOLERANCE):
452
+ return []
453
+
454
+ # Bug detected! Find the gap where it likely occurred
455
+ # Look for the largest gap in GPS timecodes
456
+ dt = np.diff(gps_timecodes)
457
+ gap_threshold = expected_dt_ms * 10 # 400ms default
458
+
459
+ gap_indices = np.where(dt > gap_threshold)[0]
460
+ if len(gap_indices) == 0:
461
+ return []
462
+
463
+ # Find the largest gap
464
+ largest_gap_idx = gap_indices[np.argmax(dt[gap_indices])]
465
+ gap_time = int(gps_timecodes[largest_gap_idx])
466
+ gap_size = dt[largest_gap_idx]
467
+
468
+ # For direct gaps (60000-70000ms), use gap_size - expected_dt as correction
469
+ # For hidden bugs (detected via GNFI), use OVERFLOW_BUG_MS as correction
470
+ # because the gap_size might be smaller due to signal loss masking the bug
471
+ if OVERFLOW_BUG_MS - TOLERANCE <= gap_size <= OVERFLOW_BUG_MS + TOLERANCE:
472
+ # Direct gap - correction is the excess time
473
+ correction = gap_size - expected_dt_ms
474
+ else:
475
+ # Hidden bug - correction is the full overflow amount
476
+ correction = OVERFLOW_BUG_MS
477
+ return [(gap_time, int(correction))]
478
+
479
+
480
+ def fix_gps_timing_gaps(
481
+ log: "LogFile",
482
+ expected_dt_ms: float = 40.0,
483
+ gnfi_timecodes: np.ndarray | None = None,
484
+ ) -> "LogFile":
485
+ """Detect and correct 16-bit overflow timing gaps in GPS channels and lap boundaries.
486
+
487
+ Some AIM data loggers produce GPS data with spurious timestamp jumps
488
+ (e.g., 65533ms gaps that should be ~40ms). This is caused by a 16-bit
489
+ overflow bug in the logger firmware where the upper 16 bits of the
490
+ timecode are corrupted, resulting in a gap of approximately 65533ms
491
+ (0xFFED, or 2^16 - 3).
492
+
493
+ This function detects the firmware bug in three ways (in order of preference):
494
+ 1. GNFI-based detection: If GNFI timecodes are available, compare GPS end time
495
+ to GNFI end time (GNFI runs on logger's internal clock, provides ground truth)
496
+ 2. Direct detection: gaps between 60000ms and 70000ms
497
+ 3. Indirect detection: GPS ends ~65533ms after other channels, indicating
498
+ the bug occurred during a GPS signal loss (hidden within a smaller gap)
499
+
500
+ The fix is applied in-place to the LogFile's channels dict and laps table.
501
+
502
+ Parameters
503
+ ----------
504
+ log : LogFile
505
+ The loaded log file with channels dict.
506
+ expected_dt_ms : float, default=40.0
507
+ Expected time delta between GPS samples in milliseconds.
508
+ Default is 40ms (25 Hz GPS).
509
+ gnfi_timecodes : np.ndarray or None, default=None
510
+ Optional GNFI timecodes from logger's internal clock. If provided,
511
+ used for more robust detection of the GPS timing bug.
512
+
513
+ Returns
514
+ -------
515
+ LogFile
516
+ The same LogFile object with corrected GPS timecodes and lap boundaries.
517
+ """
518
+ # The firmware bug causes a gap of approximately 65533ms (0xFFED).
519
+ OVERFLOW_BUG_MS = 65533
520
+ OVERFLOW_GAP_MIN = 60000 # 60 seconds minimum
521
+ OVERFLOW_GAP_MAX = 70000 # 70 seconds maximum
522
+
523
+ # Find the first GPS channel that exists
524
+ gps_channel_name = None
525
+ for name in GPS_CHANNEL_NAMES:
526
+ if name in log.channels:
527
+ gps_channel_name = name
528
+ break
529
+
530
+ if gps_channel_name is None:
531
+ return log
532
+
533
+ # Get the GPS timecodes
534
+ gps_table = log.channels[gps_channel_name]
535
+ gps_time = gps_table.column("timecodes").to_numpy()
536
+
537
+ if len(gps_time) < 2:
538
+ return log
539
+
540
+ # Detect gaps
541
+ dt = np.diff(gps_time)
542
+ gap_threshold = expected_dt_ms * 10 # 400ms default
543
+
544
+ # Find indices where gaps are too large
545
+ gap_indices = np.where(dt > gap_threshold)[0]
546
+
547
+ if len(gap_indices) == 0:
548
+ return log
549
+
550
+ # Build list of (gap_time, correction) pairs - only for firmware bug gaps
551
+ gap_corrections = []
552
+
553
+ # Method 1: GNFI-based detection (most reliable, if available)
554
+ if gnfi_timecodes is not None:
555
+ gap_corrections = detect_gps_timing_offset_from_gnfi(
556
+ gps_time, gnfi_timecodes, expected_dt_ms
557
+ )
558
+
559
+ # Method 2: Direct detection - gaps between 60000ms and 70000ms
560
+ if len(gap_corrections) == 0:
561
+ for gap_idx in gap_indices:
562
+ gap_time = gps_time[gap_idx]
563
+ gap_size = dt[gap_idx]
564
+
565
+ # Only fix gaps that match the firmware bug signature (around 65533ms)
566
+ if not (OVERFLOW_GAP_MIN <= gap_size <= OVERFLOW_GAP_MAX):
567
+ continue # Skip - this is a legitimate gap, not the firmware bug
568
+
569
+ correction = gap_size - expected_dt_ms
570
+ gap_corrections.append((gap_time, correction))
571
+
572
+ # Method 3: Indirect detection - GPS extends ~65533ms beyond other channels
573
+ # This happens when the bug occurs during GPS signal loss
574
+ if len(gap_corrections) == 0 and len(gap_indices) > 0:
575
+ # Find end time of non-GPS channels
576
+ non_gps_end_times = []
577
+ for ch_name, ch_table in log.channels.items():
578
+ if ch_name not in GPS_CHANNEL_NAMES:
579
+ ch_time = ch_table.column("timecodes").to_numpy()
580
+ if len(ch_time) > 0:
581
+ non_gps_end_times.append(ch_time[-1])
582
+
583
+ if non_gps_end_times:
584
+ max_non_gps_end = max(non_gps_end_times)
585
+ gps_end = gps_time[-1]
586
+ end_offset = gps_end - max_non_gps_end
587
+
588
+ # If GPS extends ~65533ms beyond other channels, the bug is hidden
589
+ if OVERFLOW_GAP_MIN <= end_offset <= OVERFLOW_GAP_MAX:
590
+ # Find the gap where the bug likely occurred (largest gap)
591
+ largest_gap_idx = gap_indices[np.argmax(dt[gap_indices])]
592
+ gap_time = gps_time[largest_gap_idx]
593
+
594
+ # Apply correction of ~65533ms (the overflow amount)
595
+ correction = OVERFLOW_BUG_MS
596
+ gap_corrections.append((gap_time, correction))
597
+
598
+ # Fix GPS channel timecodes
599
+ gps_time_fixed = gps_time.astype(np.float64)
600
+ for gap_time, correction in gap_corrections:
601
+ gps_time_fixed[gps_time > gap_time] -= correction
602
+
603
+ gps_time_fixed = gps_time_fixed.astype(np.int64)
604
+
605
+ # Update all GPS channels with corrected timecodes
606
+ for name in GPS_CHANNEL_NAMES:
607
+ if name not in log.channels:
608
+ continue
609
+
610
+ table = log.channels[name]
611
+ value_column = table.column(name)
612
+ field = table.schema.field(name)
613
+ metadata = field.metadata
614
+
615
+ new_table = pa.table(
616
+ {
617
+ "timecodes": pa.array(gps_time_fixed, type=pa.int64()),
618
+ name: value_column,
619
+ }
620
+ )
621
+
622
+ if metadata:
623
+ new_field = new_table.schema.field(name).with_metadata(metadata)
624
+ new_schema = pa.schema([new_table.schema.field("timecodes"), new_field])
625
+ new_table = new_table.cast(new_schema)
626
+
627
+ log.channels[name] = new_table
628
+
629
+ # Fix lap boundaries (start_time, end_time)
630
+ if log.laps is not None and len(log.laps) > 0:
631
+ start_times = log.laps.column("start_time").to_numpy().astype(np.float64)
632
+ end_times = log.laps.column("end_time").to_numpy().astype(np.float64)
633
+
634
+ for gap_time, correction in gap_corrections:
635
+ start_times[start_times > gap_time] -= correction
636
+ end_times[end_times > gap_time] -= correction
637
+
638
+ new_laps_data = {}
639
+ for col_name in log.laps.column_names:
640
+ if col_name == "start_time":
641
+ new_laps_data[col_name] = pa.array(start_times.astype(np.int64), type=pa.int64())
642
+ elif col_name == "end_time":
643
+ new_laps_data[col_name] = pa.array(end_times.astype(np.int64), type=pa.int64())
644
+ else:
645
+ new_laps_data[col_name] = log.laps.column(col_name)
646
+
647
+ log.laps = pa.table(new_laps_data)
648
+
649
+ return log
libxrk/py.typed ADDED
File without changes