yostlabs 2025.1.3.4__py3-none-any.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.
- yostlabs/__init__.py +0 -0
- yostlabs/tss3/__init__.py +0 -0
- yostlabs/tss3/api.py +1926 -0
- yostlabs/tss3/consts.py +24 -0
- yostlabs/tss3/eepts.py +228 -0
- yostlabs/tss3/parser.py +258 -0
- yostlabs/tss3/quaternion.py +166 -0
- yostlabs/tss3/utils.py +671 -0
- yostlabs-2025.1.3.4.dist-info/METADATA +18 -0
- yostlabs-2025.1.3.4.dist-info/RECORD +12 -0
- yostlabs-2025.1.3.4.dist-info/WHEEL +4 -0
- yostlabs-2025.1.3.4.dist-info/licenses/LICENSE +21 -0
yostlabs/tss3/utils.py
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
from .api import ThreespaceSensor, ThreespaceCommand, StreamableCommands, ThreespaceCmdResult, threespaceGetHeaderLabels
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import yostlabs.tss3.quaternion as yl_math
|
|
9
|
+
|
|
10
|
+
class ThreespaceStreamingStatus(Enum):
|
|
11
|
+
Data = 0 #Normal data update
|
|
12
|
+
DataEnd = 1 #This is the last packet being sent for this data update. This allows the user to more efficently handle their callback.
|
|
13
|
+
#For example, if you have an expensive computation that needs done over all the data, but only once per frame, it would
|
|
14
|
+
#be preferable to buffer the data received via the callback and only do the computation when DataEnd is received
|
|
15
|
+
Paused = 2 #Streaming has been paused
|
|
16
|
+
Resumed = 3 #Streaming has been resumed
|
|
17
|
+
|
|
18
|
+
#Streaming manager is resetting. It is required that the callback unregisters everything is has registered
|
|
19
|
+
#This option is intended for shutdown purposes or in complex applications where the user needs to completely
|
|
20
|
+
#disable the streaming manager for some reason
|
|
21
|
+
Reset = 4
|
|
22
|
+
|
|
23
|
+
from typing import NamedTuple
|
|
24
|
+
ThreespaceStreamingOption = NamedTuple("ThreespaceStreamingOption", [("cmd", StreamableCommands), ("param", int|None)])
|
|
25
|
+
class ThreespaceStreamingManager:
|
|
26
|
+
"""
|
|
27
|
+
A class that manages multiple clients wanting streamed data. Will update the streaming
|
|
28
|
+
slots and speed dynamically based on given requirements and allow those clients to
|
|
29
|
+
access that data without having to worry about which streaming slot is being used by what data.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Command:
|
|
34
|
+
cmd: StreamableCommands = None
|
|
35
|
+
param: int = None
|
|
36
|
+
|
|
37
|
+
slot: int = None
|
|
38
|
+
registrations: set = field(default_factory=set, init=False)
|
|
39
|
+
|
|
40
|
+
active: bool = False #If not active then it must have been queued for addition, so it will be set active immediately
|
|
41
|
+
|
|
42
|
+
labels: str = None
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Callback:
|
|
46
|
+
func: Callable[[ThreespaceStreamingStatus],None] = None
|
|
47
|
+
hz: int = None
|
|
48
|
+
|
|
49
|
+
only_newest: bool = False
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def interval(self):
|
|
53
|
+
if self.hz is None: return None
|
|
54
|
+
return 1000000 // self.hz
|
|
55
|
+
|
|
56
|
+
def __init__(self, sensor: ThreespaceSensor):
|
|
57
|
+
self.sensor = sensor
|
|
58
|
+
|
|
59
|
+
self.num_slots = len(self.get_slots_from_sensor()) #This is just so if number of available slots ever changes, this changes to match it
|
|
60
|
+
self.registered_commands: dict[tuple, ThreespaceStreamingManager.Command] = {}
|
|
61
|
+
self.slots: list[ThreespaceStreamingManager.Command] = [] #The same as self.registered_commands, but allow indexing based on slot instead
|
|
62
|
+
|
|
63
|
+
self.last_response: ThreespaceCmdResult|None = None
|
|
64
|
+
self.results: dict[tuple,Any] = {}
|
|
65
|
+
|
|
66
|
+
self.callbacks: dict[Callable, ThreespaceStreamingManager.Callback] = {}
|
|
67
|
+
|
|
68
|
+
#Objects currently pausing the streaming
|
|
69
|
+
self.pausers: set[object] = set()
|
|
70
|
+
self.lockers: set[object] = set()
|
|
71
|
+
|
|
72
|
+
#Keeps track of how many packets have been read. Useful for consumers to know if the values have been updated since they last read
|
|
73
|
+
self.sample_count = 0
|
|
74
|
+
|
|
75
|
+
self.enabled = False
|
|
76
|
+
self.is_streaming = False #Store this seperately to attempt to allow using both the regular streaming and streaming manager via pausing and such
|
|
77
|
+
|
|
78
|
+
#Set the initial streaming speed
|
|
79
|
+
self.interval = int(self.sensor.get_settings("stream_interval"))
|
|
80
|
+
|
|
81
|
+
self.dirty = False
|
|
82
|
+
self.validated = False
|
|
83
|
+
|
|
84
|
+
#Control variable to manually control when updating happens here
|
|
85
|
+
self.block_updates = False
|
|
86
|
+
|
|
87
|
+
#Using interval instead of HZ because more precise and the result of ?stream_hz may not be exactly equal to what is set
|
|
88
|
+
#However the functions for interfacing with these are still done in Hz
|
|
89
|
+
self.max_interval = 0xFFFFFFFF
|
|
90
|
+
self.min_interval = 1000000 / 2000
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def paused(self):
|
|
94
|
+
return len(self.pausers) > 0
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def locked(self):
|
|
98
|
+
return len(self.lockers) > 0
|
|
99
|
+
|
|
100
|
+
def pause(self, locker: object):
|
|
101
|
+
if locker in self.pausers: return True
|
|
102
|
+
if self.locked: return False
|
|
103
|
+
self.pausers.add(locker)
|
|
104
|
+
if len(self.pausers) == 1 and self.is_streaming:
|
|
105
|
+
self.__stop_streaming()
|
|
106
|
+
for callback in self.callbacks:
|
|
107
|
+
callback(ThreespaceStreamingStatus.Paused)
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def resume(self, locker: object):
|
|
111
|
+
try:
|
|
112
|
+
self.pausers.remove(locker)
|
|
113
|
+
except KeyError:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
#Attempt to start again
|
|
117
|
+
if len(self.pausers) == 0:
|
|
118
|
+
for callback in self.callbacks:
|
|
119
|
+
callback(ThreespaceStreamingStatus.Resumed)
|
|
120
|
+
self.__apply_streaming_settings_and_update_state()
|
|
121
|
+
|
|
122
|
+
def lock_modifications(self, locker: object):
|
|
123
|
+
"""
|
|
124
|
+
This still allows the streaming manager to operate and register new objects. However, registration
|
|
125
|
+
is limited to commands and speeds that are already operatable. Essentially, after this is called,
|
|
126
|
+
it is not possible to do actions that require updating the sensors onboard settings/state. This gurantees
|
|
127
|
+
streaming will not be stopped/restarted for time sensitive applications.
|
|
128
|
+
Note: This INCLUDES pausing/resuming, enabling/disabling...
|
|
129
|
+
If you need to lock modifications, then pause or resume. The locker should unlock modifications, call the necessary function, and then lock again
|
|
130
|
+
"""
|
|
131
|
+
self.lockers.add(locker)
|
|
132
|
+
|
|
133
|
+
def unlock_modifications(self, locker: object):
|
|
134
|
+
if not locker in self.lockers: return
|
|
135
|
+
self.lockers.remove(locker)
|
|
136
|
+
if not self.locked and self.dirty:
|
|
137
|
+
self.__apply_streaming_settings_and_update_state()
|
|
138
|
+
|
|
139
|
+
def reset(self):
|
|
140
|
+
#Prevent the callbacks unregistrations from instantly taking effect regardless of if they pass immediate_update or not
|
|
141
|
+
self.block_updates = True
|
|
142
|
+
values = list(self.callbacks.values()) #To prevent concurrent dict modification, cache this
|
|
143
|
+
for cb in values:
|
|
144
|
+
cb.func(ThreespaceStreamingStatus.Reset)
|
|
145
|
+
self.block_updates = False
|
|
146
|
+
self.lockers.clear()
|
|
147
|
+
self.pausers.clear()
|
|
148
|
+
self.__apply_streaming_settings_and_update_state()
|
|
149
|
+
if self.num_commands_registered != 0:
|
|
150
|
+
raise RuntimeError(f"Failed to reset streaming manager. {self.num_commands_registered} commands still registered.\n {self.registered_commands}")
|
|
151
|
+
if self.num_callbacks_registered != 0:
|
|
152
|
+
raise RuntimeError(f"Failed to reset streaming manager. {self.num_callbacks_registered} callbacks still registered.\n {self.callbacks}")
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def update(self):
|
|
156
|
+
if self.paused or not self.enabled or not self.sensor.is_streaming: return
|
|
157
|
+
|
|
158
|
+
self.apply_updated_settings()
|
|
159
|
+
self.sensor.updateStreaming()
|
|
160
|
+
result = self.sensor.getOldestStreamingPacket()
|
|
161
|
+
if result is not None:
|
|
162
|
+
while result is not None:
|
|
163
|
+
self.sample_count += 1
|
|
164
|
+
self.last_response = result
|
|
165
|
+
slot_index = 0
|
|
166
|
+
for data in result.data:
|
|
167
|
+
while not self.slots[slot_index].active: slot_index += 1
|
|
168
|
+
cmd = self.slots[slot_index]
|
|
169
|
+
info = (cmd.cmd, cmd.param)
|
|
170
|
+
self.results[info] = data
|
|
171
|
+
slot_index += 1
|
|
172
|
+
|
|
173
|
+
#Let all the callbacks know the data was updated
|
|
174
|
+
for cb in self.callbacks.values():
|
|
175
|
+
if cb.only_newest: continue
|
|
176
|
+
cb.func(ThreespaceStreamingStatus.Data)
|
|
177
|
+
|
|
178
|
+
result = self.sensor.getOldestStreamingPacket()
|
|
179
|
+
|
|
180
|
+
for cb in self.callbacks.values():
|
|
181
|
+
if cb.only_newest:
|
|
182
|
+
cb.func(ThreespaceStreamingStatus.Data)
|
|
183
|
+
cb.func(ThreespaceStreamingStatus.DataEnd)
|
|
184
|
+
|
|
185
|
+
def register_callback(self, callback: Callable[[ThreespaceStreamingStatus],None], hz=None, only_newest=False):
|
|
186
|
+
if callback in self.callbacks: return
|
|
187
|
+
self.callbacks[callback] = ThreespaceStreamingManager.Callback(callback, hz, only_newest)
|
|
188
|
+
self.__update_streaming_speed()
|
|
189
|
+
|
|
190
|
+
def unregister_callback(self, callback: Callable[[ThreespaceStreamingStatus],None]):
|
|
191
|
+
if callback not in self.callbacks: return
|
|
192
|
+
del self.callbacks[callback]
|
|
193
|
+
self.__update_streaming_speed()
|
|
194
|
+
|
|
195
|
+
def register_command(self, owner: object, command: StreamableCommands|ThreespaceStreamingOption, param=None, immediate_update=True):
|
|
196
|
+
"""
|
|
197
|
+
Adds the given command to the streaming slots and starts streaming it
|
|
198
|
+
|
|
199
|
+
Parameters
|
|
200
|
+
----
|
|
201
|
+
owner : A reference to the object registering the command. A command is only unregistered after all its owners release it
|
|
202
|
+
command : The command to register
|
|
203
|
+
param : The parameter (if any) required for the command to be streamed. The command and param together identify a single slot
|
|
204
|
+
immediate_update : If true, the streaming manager will immediately change the streaming slots on the sensor. If doing bulk registers, it
|
|
205
|
+
is useful to set this as False until the last one for performance purposes.
|
|
206
|
+
|
|
207
|
+
Returns
|
|
208
|
+
-------
|
|
209
|
+
True : Successfully registered the command
|
|
210
|
+
False : Failed to register the command. Streaming slots are full
|
|
211
|
+
"""
|
|
212
|
+
if isinstance(command, tuple):
|
|
213
|
+
param = command[1]
|
|
214
|
+
command = command[0]
|
|
215
|
+
info = (command, param)
|
|
216
|
+
if info in self.registered_commands: #Already registered, just add this as an owner
|
|
217
|
+
cmd = self.registered_commands[info]
|
|
218
|
+
if len(cmd.registrations) == 0 and self.num_commands_registered >= self.num_slots: #No room to register
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
cmd.registrations.add(owner)
|
|
222
|
+
if immediate_update and self.dirty:
|
|
223
|
+
return self.__apply_streaming_settings_and_update_state()
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
if self.locked: #Wasn't already registered, so don't allow new registrations
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
#Make sure to only consider a command registered if it has registrations
|
|
230
|
+
num_commands_registered = self.num_commands_registered
|
|
231
|
+
if num_commands_registered >= self.num_slots: #No room to register
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
#Register the command and add it to the streaming
|
|
235
|
+
self.registered_commands[info] = ThreespaceStreamingManager.Command(command, param=param, slot=num_commands_registered)
|
|
236
|
+
self.registered_commands[info].labels = self.sensor.getStreamingLabel(command.value).data
|
|
237
|
+
self.registered_commands[info].registrations.add(owner)
|
|
238
|
+
self.dirty = True
|
|
239
|
+
if immediate_update:
|
|
240
|
+
return self.__apply_streaming_settings_and_update_state()
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
def unregister_command(self, owner: object, command: StreamableCommands|ThreespaceStreamingOption, param=None, immediate_update=True):
|
|
244
|
+
"""
|
|
245
|
+
Removes the given command to the streaming slots and starts streaming it
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
owner : A reference to the object unregistering the command. A command is only unregistered after all its owners release it
|
|
250
|
+
command : The command to unregister
|
|
251
|
+
param : The param (if any) required for the command
|
|
252
|
+
immediate_update : If true, the streaming manager will immediately change the streaming slots on the sensor. If doing bulk unregisters, it
|
|
253
|
+
is useful to set this as False until the last one for performance purposes.
|
|
254
|
+
"""
|
|
255
|
+
if isinstance(command, tuple):
|
|
256
|
+
param = command[1]
|
|
257
|
+
command = command[0]
|
|
258
|
+
info = (command, param)
|
|
259
|
+
if info not in self.registered_commands:
|
|
260
|
+
return
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
self.registered_commands[info].registrations.remove(owner)
|
|
264
|
+
except KeyError:
|
|
265
|
+
return #This owner wasn't registered to begin with, just ignore
|
|
266
|
+
|
|
267
|
+
#Nothing else to do
|
|
268
|
+
if len(self.registered_commands[info].registrations) != 0:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
#Remove the command from streaming since nothing owns it anymore
|
|
272
|
+
self.dirty = True
|
|
273
|
+
if immediate_update:
|
|
274
|
+
self.__apply_streaming_settings_and_update_state()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def __build_stream_slots_string(self):
|
|
278
|
+
cmd_strings = []
|
|
279
|
+
self.slots.clear()
|
|
280
|
+
if self.num_commands_registered == 0: return "255"
|
|
281
|
+
i = 0
|
|
282
|
+
for cmd_key in self.registered_commands:
|
|
283
|
+
cmd = self.registered_commands[cmd_key]
|
|
284
|
+
if not cmd.active: continue #Skip non active registrations
|
|
285
|
+
self.slots.append(cmd)
|
|
286
|
+
cmd.slot = i
|
|
287
|
+
if cmd.param == None:
|
|
288
|
+
cmd_strings.append(str(cmd.cmd.value))
|
|
289
|
+
else:
|
|
290
|
+
cmd_strings.append(f"{cmd.cmd.value}:{cmd.param}")
|
|
291
|
+
i += 1
|
|
292
|
+
return ','.join(cmd_strings)
|
|
293
|
+
|
|
294
|
+
#More user friendly version of __apply_streaming_settings_and_update_state that prevents the user from calling it when not needed.
|
|
295
|
+
def apply_updated_settings(self):
|
|
296
|
+
"""
|
|
297
|
+
This applys the current settings of the streaming manager and updates its state. This is normally done automatically, however
|
|
298
|
+
if the user is registering/unregistering with immediate_update turned off, this can be called to force the update.
|
|
299
|
+
"""
|
|
300
|
+
if not self.dirty: return self.validated
|
|
301
|
+
return self.__apply_streaming_settings_and_update_state()
|
|
302
|
+
|
|
303
|
+
def __apply_streaming_settings_and_update_state(self, ignore_lock=False):
|
|
304
|
+
"""
|
|
305
|
+
Used to apply the current configuration this manager represents to the streaming.
|
|
306
|
+
This involves disabling streaming if currently running
|
|
307
|
+
"""
|
|
308
|
+
if self.block_updates or (self.locked and not ignore_lock):
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
if self.sensor.is_streaming:
|
|
312
|
+
self.__stop_streaming()
|
|
313
|
+
|
|
314
|
+
#Clean up any registrations that need removed and activate any that need activated
|
|
315
|
+
if self.dirty:
|
|
316
|
+
to_remove = []
|
|
317
|
+
for k, v in self.registered_commands.items():
|
|
318
|
+
if len(v.registrations) == 0:
|
|
319
|
+
to_remove.append(k)
|
|
320
|
+
continue
|
|
321
|
+
v.active = True
|
|
322
|
+
for key in to_remove:
|
|
323
|
+
del self.registered_commands[key]
|
|
324
|
+
if key in self.results:
|
|
325
|
+
del self.results[key]
|
|
326
|
+
self.dirty = False
|
|
327
|
+
|
|
328
|
+
if self.num_commands_registered > 0:
|
|
329
|
+
slots_string = self.__build_stream_slots_string()
|
|
330
|
+
err, num_successes = self.sensor.set_settings(stream_slots=slots_string, stream_interval=self.interval)
|
|
331
|
+
if err:
|
|
332
|
+
self.validated = False
|
|
333
|
+
return False
|
|
334
|
+
if not self.paused and self.enabled:
|
|
335
|
+
self.__start_streaming() #Re-enable
|
|
336
|
+
|
|
337
|
+
self.validated = True
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
def __update_streaming_speed(self):
|
|
341
|
+
required_interval = None
|
|
342
|
+
for callback in self.callbacks.values():
|
|
343
|
+
if callback.interval is None: continue
|
|
344
|
+
if required_interval is None or callback.interval < required_interval:
|
|
345
|
+
required_interval = callback.interval
|
|
346
|
+
|
|
347
|
+
if required_interval is None: #Treat required as current to make sure the current interval is still valid
|
|
348
|
+
required_interval = self.interval
|
|
349
|
+
|
|
350
|
+
required_interval = min(self.max_interval, max(self.min_interval, required_interval))
|
|
351
|
+
if required_interval != self.interval:
|
|
352
|
+
print(f"Updating streaming speed from {1000000 / self.interval}hz to {1000000 / required_interval}hz")
|
|
353
|
+
self.interval = int(required_interval)
|
|
354
|
+
self.dirty = True
|
|
355
|
+
self.__apply_streaming_settings_and_update_state()
|
|
356
|
+
|
|
357
|
+
def __start_streaming(self):
|
|
358
|
+
self.sensor.startStreaming()
|
|
359
|
+
self.is_streaming = True
|
|
360
|
+
|
|
361
|
+
def __stop_streaming(self):
|
|
362
|
+
self.sensor.stopStreaming()
|
|
363
|
+
self.is_streaming = False
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def num_commands_registered(self):
|
|
367
|
+
return len([v for v in self.registered_commands.values() if len(v.registrations) != 0])
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def num_callbacks_registered(self):
|
|
371
|
+
return len(self.callbacks)
|
|
372
|
+
|
|
373
|
+
def get_value(self, command: StreamableCommands|ThreespaceStreamingOption, param=None):
|
|
374
|
+
if isinstance(command, tuple):
|
|
375
|
+
param = command[1]
|
|
376
|
+
command = command[0]
|
|
377
|
+
return self.results.get((command, param), None)
|
|
378
|
+
|
|
379
|
+
def get_last_response(self):
|
|
380
|
+
return self.last_response
|
|
381
|
+
|
|
382
|
+
def get_header(self):
|
|
383
|
+
return self.last_response.header
|
|
384
|
+
|
|
385
|
+
def get_cmd_labels(self):
|
|
386
|
+
return ','.join(cmd.labels for cmd in self.registered_commands.values())
|
|
387
|
+
|
|
388
|
+
def get_header_labels(self):
|
|
389
|
+
order = threespaceGetHeaderLabels(self.sensor.header_info)
|
|
390
|
+
return ','.join(order)
|
|
391
|
+
|
|
392
|
+
def get_response_labels(self):
|
|
393
|
+
return ','.join([self.get_header_labels(), self.get_cmd_labels()])
|
|
394
|
+
|
|
395
|
+
def enable(self):
|
|
396
|
+
if self.enabled:
|
|
397
|
+
return
|
|
398
|
+
self.enabled = True
|
|
399
|
+
self.__apply_streaming_settings_and_update_state()
|
|
400
|
+
|
|
401
|
+
def disable(self):
|
|
402
|
+
if not self.enabled:
|
|
403
|
+
return
|
|
404
|
+
if self.is_streaming:
|
|
405
|
+
self.__stop_streaming()
|
|
406
|
+
self.enabled = False
|
|
407
|
+
|
|
408
|
+
def set_max_hz(self, hz: float):
|
|
409
|
+
if hz <= 0 or hz > 2000:
|
|
410
|
+
raise ValueError(f"Invalid streaming Hz {hz}")
|
|
411
|
+
self.min_interval = 1000000 // hz
|
|
412
|
+
self.__update_streaming_speed()
|
|
413
|
+
|
|
414
|
+
def set_min_hz(self, hz: float):
|
|
415
|
+
if hz <= 0 or hz > 2000:
|
|
416
|
+
raise ValueError(f"Invalid streaming Hz {hz}")
|
|
417
|
+
self.max_interval = 1000000 // hz
|
|
418
|
+
self.__update_streaming_speed()
|
|
419
|
+
|
|
420
|
+
def get_slots_from_sensor(self):
|
|
421
|
+
"""
|
|
422
|
+
get a list containing the streaming information from the current sensor
|
|
423
|
+
"""
|
|
424
|
+
slot_setting: str = self.sensor.get_settings("stream_slots")
|
|
425
|
+
slots = slot_setting.split(',')
|
|
426
|
+
slot_info = []
|
|
427
|
+
for slot in slots:
|
|
428
|
+
info = slot.split(':')
|
|
429
|
+
slot = int(info[0]) #Ignore parameters if any
|
|
430
|
+
param = None
|
|
431
|
+
if len(info) > 1:
|
|
432
|
+
param = int(info[1])
|
|
433
|
+
if slot != 255:
|
|
434
|
+
slot_info.append((slot, param))
|
|
435
|
+
else:
|
|
436
|
+
slot_info.append(None)
|
|
437
|
+
|
|
438
|
+
return slot_info
|
|
439
|
+
|
|
440
|
+
class ThreespaceGradientDescentCalibration:
|
|
441
|
+
|
|
442
|
+
@dataclass
|
|
443
|
+
class StageInfo:
|
|
444
|
+
start_vector: int
|
|
445
|
+
end_vector: int
|
|
446
|
+
stage: int
|
|
447
|
+
scale: float
|
|
448
|
+
|
|
449
|
+
count: int = 0
|
|
450
|
+
|
|
451
|
+
MAX_SCALE = 1000000000
|
|
452
|
+
MIN_SCALE = 1
|
|
453
|
+
STAGES = [
|
|
454
|
+
StageInfo(0, 6, 0, MAX_SCALE),
|
|
455
|
+
StageInfo(0, 12, 1, MAX_SCALE),
|
|
456
|
+
StageInfo(0, 24, 2, MAX_SCALE)
|
|
457
|
+
]
|
|
458
|
+
|
|
459
|
+
#Note that each entry has a positive and negative vector included in this list
|
|
460
|
+
CHANGE_VECTORS = [
|
|
461
|
+
np.array([0,0,0,0,0,0,0,0,0,.0001,0,0], dtype=np.float64),
|
|
462
|
+
np.array([0,0,0,0,0,0,0,0,0,-.0001,0,0], dtype=np.float64),
|
|
463
|
+
np.array([0,0,0,0,0,0,0,0,0,0,.0001,0], dtype=np.float64),
|
|
464
|
+
np.array([0,0,0,0,0,0,0,0,0,0,-.0001,0], dtype=np.float64),
|
|
465
|
+
np.array([0,0,0,0,0,0,0,0,0,0,0,.0001], dtype=np.float64),
|
|
466
|
+
np.array([0,0,0,0,0,0,0,0,0,0,0,-.0001], dtype=np.float64), #First 6 only try to change the bias
|
|
467
|
+
np.array([.001,0,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
468
|
+
np.array([-.001,0,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
469
|
+
np.array([0,0,0,0,.001,0,0,0,0,0,0,0], dtype=np.float64),
|
|
470
|
+
np.array([0,0,0,0,-.001,0,0,0,0,0,0,0], dtype=np.float64),
|
|
471
|
+
np.array([0,0,0,0,0,0,0,0,.001,0,0,0], dtype=np.float64),
|
|
472
|
+
np.array([0,0,0,0,0,0,0,0,-.001,0,0,0], dtype=np.float64), #Next 6 only try to change the scale
|
|
473
|
+
np.array([0,.0001,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
474
|
+
np.array([0,-.0001,0,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
475
|
+
np.array([0,0,.0001,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
476
|
+
np.array([0,0,-.0001,0,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
477
|
+
np.array([0,0,0,.0001,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
478
|
+
np.array([0,0,0,-.0001,0,0,0,0,0,0,0,0], dtype=np.float64),
|
|
479
|
+
np.array([0,0,0,0,0,.0001,0,0,0,0,0,0], dtype=np.float64),
|
|
480
|
+
np.array([0,0,0,0,0,-.0001,0,0,0,0,0,0], dtype=np.float64),
|
|
481
|
+
np.array([0,0,0,0,0,0,.0001,0,0,0,0,0], dtype=np.float64),
|
|
482
|
+
np.array([0,0,0,0,0,0,-.0001,0,0,0,0,0], dtype=np.float64),
|
|
483
|
+
np.array([0,0,0,0,0,0,0,.0001,0,0,0,0], dtype=np.float64),
|
|
484
|
+
np.array([0,0,0,0,0,0,0,-.0001,0,0,0,0], dtype=np.float64), #Next 12 only try to change the shear
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
def __init__(self, relative_sensor_orients: list[np.ndarray[float]], no_inverse=False):
|
|
488
|
+
"""
|
|
489
|
+
Params
|
|
490
|
+
------
|
|
491
|
+
relative_sensor_orients : The orientation of the sensor during which each sample is taken if it was tared as if pointing into the screen.
|
|
492
|
+
The inverse of these will be used to calculate where the axes should be located relative to the sensor
|
|
493
|
+
no_inverse : The relative_sensor_orients will be treated as the sample_rotations
|
|
494
|
+
"""
|
|
495
|
+
if no_inverse:
|
|
496
|
+
self.rotation_quats = relative_sensor_orients
|
|
497
|
+
else:
|
|
498
|
+
self.rotation_quats = [np.array(yl_math.quat_inverse(orient)) for orient in relative_sensor_orients]
|
|
499
|
+
|
|
500
|
+
def apply_parameters(self, sample: np.ndarray[float], params: np.ndarray[float]):
|
|
501
|
+
bias = params[9:]
|
|
502
|
+
scale = params[:9]
|
|
503
|
+
scale = scale.reshape((3, 3))
|
|
504
|
+
return scale @ (sample + bias)
|
|
505
|
+
|
|
506
|
+
def rate_parameters(self, params: np.ndarray[float], samples: list[np.ndarray[float]], targets: list[np.ndarray[float]]):
|
|
507
|
+
total_error = 0
|
|
508
|
+
for i in range(len(samples)):
|
|
509
|
+
sample = samples[i]
|
|
510
|
+
target = targets[i]
|
|
511
|
+
|
|
512
|
+
sample = self.apply_parameters(sample, params)
|
|
513
|
+
|
|
514
|
+
error = target - sample
|
|
515
|
+
total_error += yl_math.vec_len(error)
|
|
516
|
+
return total_error
|
|
517
|
+
|
|
518
|
+
def generate_target_list(self, origin: np.ndarray):
|
|
519
|
+
targets = []
|
|
520
|
+
for orient in self.rotation_quats:
|
|
521
|
+
new_vec = np.array(yl_math.quat_rotate_vec(orient, origin), dtype=np.float64)
|
|
522
|
+
targets.append(new_vec)
|
|
523
|
+
return targets
|
|
524
|
+
|
|
525
|
+
def __get_stage(self, stage_number: int):
|
|
526
|
+
if stage_number >= len(self.STAGES):
|
|
527
|
+
return None
|
|
528
|
+
#Always get a shallow copy of the stage so can modify without removing the initial values
|
|
529
|
+
return copy.copy(self.STAGES[stage_number])
|
|
530
|
+
|
|
531
|
+
def calculate(self, samples: list[np.ndarray[float]], origin: np.ndarray[float], verbose=False, max_cycles_per_stage=1000):
|
|
532
|
+
targets = self.generate_target_list(origin)
|
|
533
|
+
initial_params = np.array([1,0,0,0,1,0,0,0,1,0,0,0], dtype=np.float64)
|
|
534
|
+
stage = self.__get_stage(0)
|
|
535
|
+
|
|
536
|
+
best_params = initial_params
|
|
537
|
+
best_rating = self.rate_parameters(best_params, samples, targets)
|
|
538
|
+
count = 0
|
|
539
|
+
while True:
|
|
540
|
+
last_best_rating = best_rating
|
|
541
|
+
params = best_params
|
|
542
|
+
|
|
543
|
+
#Apply all the changes to see if any improve the result
|
|
544
|
+
for change_index in range(stage.start_vector, stage.end_vector):
|
|
545
|
+
change_vector = self.CHANGE_VECTORS[change_index]
|
|
546
|
+
new_params = params + (change_vector * stage.scale)
|
|
547
|
+
rating = self.rate_parameters(new_params, samples, targets)
|
|
548
|
+
|
|
549
|
+
#A better rating, store it
|
|
550
|
+
if rating < best_rating:
|
|
551
|
+
best_params = new_params
|
|
552
|
+
best_rating = rating
|
|
553
|
+
|
|
554
|
+
if verbose and count % 100 == 0:
|
|
555
|
+
print(f"Round {count}: {best_rating=} {stage=}")
|
|
556
|
+
|
|
557
|
+
#Decide if need to go to the next stage or not
|
|
558
|
+
count += 1
|
|
559
|
+
stage.count += 1
|
|
560
|
+
if stage.count >= max_cycles_per_stage:
|
|
561
|
+
stage = self.__get_stage(stage.stage + 1)
|
|
562
|
+
if stage is None:
|
|
563
|
+
if verbose: print("Done from reaching count limit")
|
|
564
|
+
break
|
|
565
|
+
if verbose: print("Going to next stage from count limit")
|
|
566
|
+
|
|
567
|
+
if best_rating == last_best_rating: #The rating did not improve
|
|
568
|
+
if stage.scale == self.MIN_SCALE: #Go to the next stage since can't get any better in this stage!
|
|
569
|
+
stage = self.__get_stage(stage.stage + 1)
|
|
570
|
+
if stage is None:
|
|
571
|
+
if verbose: print("Done from exhaustion")
|
|
572
|
+
break
|
|
573
|
+
if verbose: print("Going to next stage from exhaustion")
|
|
574
|
+
else: #Reduce the size of the changes to hopefully get more accurate tuning
|
|
575
|
+
stage.scale *= 0.1
|
|
576
|
+
if stage.scale < self.MIN_SCALE:
|
|
577
|
+
stage.scale = self.MIN_SCALE
|
|
578
|
+
else: #Rating got better! To help avoid falling in a local minimum, increase the size of the change to see if that could make it better
|
|
579
|
+
stage.scale *= 1.1
|
|
580
|
+
|
|
581
|
+
if verbose:
|
|
582
|
+
print(f"Final Rating: {best_rating}")
|
|
583
|
+
print(f"Final Params: {best_params}")
|
|
584
|
+
|
|
585
|
+
return best_params
|
|
586
|
+
|
|
587
|
+
from xml.dom import minidom, Node
|
|
588
|
+
class ThreespaceFirmwareUploader:
|
|
589
|
+
|
|
590
|
+
def __init__(self, sensor: ThreespaceSensor, file_path: str = None, percentage_callback: Callable[[int],None] = None, verbose: bool = False):
|
|
591
|
+
self.sensor = sensor
|
|
592
|
+
self.set_firmware_path(file_path)
|
|
593
|
+
self.verbose = verbose
|
|
594
|
+
|
|
595
|
+
self.percent_complete = 0
|
|
596
|
+
self.callback = percentage_callback
|
|
597
|
+
|
|
598
|
+
def set_firmware_path(self, file_path: str):
|
|
599
|
+
if file_path is None:
|
|
600
|
+
self.firmware = None
|
|
601
|
+
return
|
|
602
|
+
self.firmware = minidom.parse(file_path)
|
|
603
|
+
|
|
604
|
+
def set_percent_callback(self, callback: Callable[[float],None]):
|
|
605
|
+
self.callback = callback
|
|
606
|
+
|
|
607
|
+
def set_verbose(self, verbose: bool):
|
|
608
|
+
self.verbose = verbose
|
|
609
|
+
|
|
610
|
+
def get_percent_done(self):
|
|
611
|
+
return self.percent_complete
|
|
612
|
+
|
|
613
|
+
def __set_percent_complete(self, percent: float):
|
|
614
|
+
self.percent_complete = percent
|
|
615
|
+
if self.callback:
|
|
616
|
+
self.callback(percent)
|
|
617
|
+
|
|
618
|
+
def log(self, *args):
|
|
619
|
+
if not self.verbose: return
|
|
620
|
+
print(*args)
|
|
621
|
+
|
|
622
|
+
def upload_firmware(self):
|
|
623
|
+
self.percent_complete = 0
|
|
624
|
+
if not self.sensor.in_bootloader:
|
|
625
|
+
self.sensor.enterBootloader()
|
|
626
|
+
self.__set_percent_complete(5)
|
|
627
|
+
|
|
628
|
+
boot_info = self.sensor.bootloader_get_info()
|
|
629
|
+
|
|
630
|
+
root = self.firmware.firstChild
|
|
631
|
+
for c in root.childNodes:
|
|
632
|
+
if c.nodeType == Node.ELEMENT_NODE:
|
|
633
|
+
name = c.nodeName
|
|
634
|
+
if name == "SetAddr":
|
|
635
|
+
self.log("Write S")
|
|
636
|
+
error = self.sensor.bootloader_erase_firmware()
|
|
637
|
+
if error:
|
|
638
|
+
self.log("Failed to erase firmware:", error)
|
|
639
|
+
else:
|
|
640
|
+
self.log("Successfully erased firmware")
|
|
641
|
+
self.__set_percent_complete(20)
|
|
642
|
+
elif name == "MemProgC":
|
|
643
|
+
mem = bytes.fromhex(c.firstChild.nodeValue)
|
|
644
|
+
self.log("Attempting to program", len(mem), "bytes to the chip")
|
|
645
|
+
cpos = 0
|
|
646
|
+
while cpos < len(mem):
|
|
647
|
+
memchunk = mem[cpos : min(len(mem), cpos + boot_info.pagesize)]
|
|
648
|
+
error = self.sensor.bootloader_prog_mem(memchunk)
|
|
649
|
+
if error:
|
|
650
|
+
self.log("Failed upload:", error)
|
|
651
|
+
else:
|
|
652
|
+
self.log("Wrote", len(memchunk), "bytes successfully to offset", cpos)
|
|
653
|
+
cpos += len(memchunk)
|
|
654
|
+
self.__set_percent_complete(20 + cpos / len(mem) * 79)
|
|
655
|
+
elif name == "Run":
|
|
656
|
+
self.log("Resetting with new firmware.")
|
|
657
|
+
self.sensor.bootloader_boot_firmware()
|
|
658
|
+
self.__set_percent_complete(100)
|
|
659
|
+
|
|
660
|
+
if __name__ == "__main__":
|
|
661
|
+
firmware_file = "D:\\svn\\trunk\\3Space\\TSS_3.0_Firmware\\Nuvoton_BASE_Project\\build\\Application.xml"
|
|
662
|
+
sensor = ThreespaceSensor()
|
|
663
|
+
sensor.set_settings(debug_level=0)
|
|
664
|
+
sensor.set_settings(cpu_speed=192000000, power_initial_hold_state=0)
|
|
665
|
+
sensor.commitSettings()
|
|
666
|
+
|
|
667
|
+
firmware_uploader = ThreespaceFirmwareUploader(firmware_file, verbose=True)
|
|
668
|
+
firmware_uploader.upload_firmware(sensor)
|
|
669
|
+
|
|
670
|
+
print(sensor.get_settings("version_firmware"))
|
|
671
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yostlabs
|
|
3
|
+
Version: 2025.1.3.4
|
|
4
|
+
Summary: Python resources and API for 3Space sensors from Yost Labs Inc.
|
|
5
|
+
Project-URL: Homepage, https://yostlabs.com/
|
|
6
|
+
Author-email: "Yost Labs Inc." <techsupport@yostlabs.com>, Andy Riedlinger <techsupport@yostlabs.com>
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: 3space,threespace,yost
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: numpy
|
|
15
|
+
Requires-Dist: pyserial
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
Work In Progress
|