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/CLAUDE.md +133 -0
- libxrk/__init__.py +29 -0
- libxrk/aim_xrk.cp312-win_amd64.pyd +0 -0
- libxrk/aim_xrk.pyi +38 -0
- libxrk/aim_xrk.pyx +982 -0
- libxrk/base.py +332 -0
- libxrk/gps.py +649 -0
- libxrk/py.typed +0 -0
- libxrk-0.7.0.dist-info/METADATA +236 -0
- libxrk-0.7.0.dist-info/RECORD +12 -0
- libxrk-0.7.0.dist-info/WHEEL +4 -0
- libxrk-0.7.0.dist-info/licenses/LICENSE +25 -0
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
|