qnbot-sdk-core 0.2.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.
- qnbot_sdk/core/__init__.py +201 -0
- qnbot_sdk/core/buffer.py +172 -0
- qnbot_sdk/core/calibration.py +296 -0
- qnbot_sdk/core/compat.py +14 -0
- qnbot_sdk/core/config.py +288 -0
- qnbot_sdk/core/config_normalization.py +200 -0
- qnbot_sdk/core/device.py +23 -0
- qnbot_sdk/core/driver.py +73 -0
- qnbot_sdk/core/keys.py +79 -0
- qnbot_sdk/core/models/__init__.py +30 -0
- qnbot_sdk/core/models/commands.py +71 -0
- qnbot_sdk/core/models/diagnostics.py +54 -0
- qnbot_sdk/core/models/safety.py +25 -0
- qnbot_sdk/core/models/serialization.py +29 -0
- qnbot_sdk/core/models/status.py +32 -0
- qnbot_sdk/core/models/telemetry.py +43 -0
- qnbot_sdk/core/registry.py +155 -0
- qnbot_sdk/core/retargeting.py +1783 -0
- qnbot_sdk/core/sdk.py +80 -0
- qnbot_sdk/core/system.py +2671 -0
- qnbot_sdk_core-0.2.4.dist-info/METADATA +6 -0
- qnbot_sdk_core-0.2.4.dist-info/RECORD +23 -0
- qnbot_sdk_core-0.2.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Core contracts and runtime for QnBot SDK."""
|
|
2
|
+
|
|
3
|
+
from qnbot_sdk.core.buffer import BufferRecord, TimeSeriesBuffer
|
|
4
|
+
from qnbot_sdk.core.calibration import CalibrationRecord, CalibrationStore
|
|
5
|
+
from qnbot_sdk.core.config import (
|
|
6
|
+
AlgorithmConfig,
|
|
7
|
+
AlgorithmConfigInput,
|
|
8
|
+
AlgorithmGroup,
|
|
9
|
+
CalibrationConfig,
|
|
10
|
+
CalibrationInteractionKind,
|
|
11
|
+
ConnectionKind,
|
|
12
|
+
ConnectionSpec,
|
|
13
|
+
DebugConfig,
|
|
14
|
+
DeviceKind,
|
|
15
|
+
DeviceSpec,
|
|
16
|
+
ExternalConnection,
|
|
17
|
+
QnBotConfig,
|
|
18
|
+
SerialConnection,
|
|
19
|
+
Side,
|
|
20
|
+
Source,
|
|
21
|
+
SourceKind,
|
|
22
|
+
Target,
|
|
23
|
+
TargetKind,
|
|
24
|
+
)
|
|
25
|
+
from qnbot_sdk.core.device import Device
|
|
26
|
+
from qnbot_sdk.core.driver import (
|
|
27
|
+
DeviceCommandWriter,
|
|
28
|
+
DeviceDriver,
|
|
29
|
+
SerialDiscoveryHook,
|
|
30
|
+
SerialEndpoint,
|
|
31
|
+
SerialFactory,
|
|
32
|
+
serial_connection_label,
|
|
33
|
+
)
|
|
34
|
+
from qnbot_sdk.core.keys import DataKeys
|
|
35
|
+
from qnbot_sdk.core.models import (
|
|
36
|
+
ArmJointCommand,
|
|
37
|
+
DeviceStatus,
|
|
38
|
+
DiagnosticEvent,
|
|
39
|
+
DiagnosticLevel,
|
|
40
|
+
DiagnosticSample,
|
|
41
|
+
GloveHapticsCommand,
|
|
42
|
+
HandJointCommand,
|
|
43
|
+
HapticsCommand,
|
|
44
|
+
SafetyState,
|
|
45
|
+
Telemetry,
|
|
46
|
+
TelemetryMeta,
|
|
47
|
+
)
|
|
48
|
+
from qnbot_sdk.core.registry import (
|
|
49
|
+
BuilderContext,
|
|
50
|
+
DeviceFactory,
|
|
51
|
+
DriverFactory,
|
|
52
|
+
DriverFactoryKey,
|
|
53
|
+
ModuleRegistry,
|
|
54
|
+
QnBotModule,
|
|
55
|
+
)
|
|
56
|
+
from qnbot_sdk.core.retargeting import (
|
|
57
|
+
AlgorithmKind,
|
|
58
|
+
AlgorithmInfo,
|
|
59
|
+
AlgorithmMap,
|
|
60
|
+
Algorithm,
|
|
61
|
+
CalibrationAlgorithmEntry,
|
|
62
|
+
CalibrationMode,
|
|
63
|
+
CalibrationRequirement,
|
|
64
|
+
CalibrationResult,
|
|
65
|
+
CalibrationStage,
|
|
66
|
+
CalibrationStageSample,
|
|
67
|
+
Calibrator,
|
|
68
|
+
Retargeter,
|
|
69
|
+
RetargeterRegistry,
|
|
70
|
+
SourcePattern,
|
|
71
|
+
TargetPattern,
|
|
72
|
+
RetargetingAlgorithmEntry,
|
|
73
|
+
RetargetingBinding,
|
|
74
|
+
RetargetingBindingResolver,
|
|
75
|
+
RetargetingContext,
|
|
76
|
+
RetargetingPipeline,
|
|
77
|
+
RetargetingPlan,
|
|
78
|
+
RetargetingPlanResult,
|
|
79
|
+
RetargetingPreparation,
|
|
80
|
+
RetargetingResult,
|
|
81
|
+
ResolvedRetargetingBinding,
|
|
82
|
+
load_python_algorithm,
|
|
83
|
+
load_wheel_algorithm,
|
|
84
|
+
normalize_algorithm_manifest,
|
|
85
|
+
prepare_retargeting,
|
|
86
|
+
)
|
|
87
|
+
from qnbot_sdk.core.sdk import QnBotSDK
|
|
88
|
+
from qnbot_sdk.core.system import (
|
|
89
|
+
CommandGate,
|
|
90
|
+
CalibrationController,
|
|
91
|
+
CalibrationInteraction,
|
|
92
|
+
ControlGate,
|
|
93
|
+
DeviceCommandController,
|
|
94
|
+
DiagnosticsEmitter,
|
|
95
|
+
HapticsController,
|
|
96
|
+
InputManager,
|
|
97
|
+
MissingRequiredCalibration,
|
|
98
|
+
OutputManager,
|
|
99
|
+
RecordingController,
|
|
100
|
+
RobotSystem,
|
|
101
|
+
RuntimeLoop,
|
|
102
|
+
SafetyController,
|
|
103
|
+
SystemBuilder,
|
|
104
|
+
WorkflowFacade,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"ArmJointCommand",
|
|
109
|
+
"AlgorithmInfo",
|
|
110
|
+
"AlgorithmKind",
|
|
111
|
+
"AlgorithmMap",
|
|
112
|
+
"Algorithm",
|
|
113
|
+
"AlgorithmConfig",
|
|
114
|
+
"AlgorithmConfigInput",
|
|
115
|
+
"AlgorithmGroup",
|
|
116
|
+
"CalibrationAlgorithmEntry",
|
|
117
|
+
"CalibrationConfig",
|
|
118
|
+
"CalibrationInteractionKind",
|
|
119
|
+
"CalibrationMode",
|
|
120
|
+
"CalibrationRecord",
|
|
121
|
+
"CalibrationRequirement",
|
|
122
|
+
"CalibrationResult",
|
|
123
|
+
"CalibrationStore",
|
|
124
|
+
"CalibrationController",
|
|
125
|
+
"CalibrationInteraction",
|
|
126
|
+
"CalibrationStage",
|
|
127
|
+
"CalibrationStageSample",
|
|
128
|
+
"Calibrator",
|
|
129
|
+
"BuilderContext",
|
|
130
|
+
"BufferRecord",
|
|
131
|
+
"ConnectionKind",
|
|
132
|
+
"ConnectionSpec",
|
|
133
|
+
"CommandGate",
|
|
134
|
+
"ControlGate",
|
|
135
|
+
"DataKeys",
|
|
136
|
+
"DebugConfig",
|
|
137
|
+
"Device",
|
|
138
|
+
"DeviceCommandController",
|
|
139
|
+
"DeviceCommandWriter",
|
|
140
|
+
"DeviceDriver",
|
|
141
|
+
"DeviceKind",
|
|
142
|
+
"DeviceSpec",
|
|
143
|
+
"DeviceStatus",
|
|
144
|
+
"DeviceFactory",
|
|
145
|
+
"ExternalConnection",
|
|
146
|
+
"DiagnosticsEmitter",
|
|
147
|
+
"DiagnosticEvent",
|
|
148
|
+
"DiagnosticLevel",
|
|
149
|
+
"DiagnosticSample",
|
|
150
|
+
"DriverFactory",
|
|
151
|
+
"DriverFactoryKey",
|
|
152
|
+
"GloveHapticsCommand",
|
|
153
|
+
"HandJointCommand",
|
|
154
|
+
"HapticsCommand",
|
|
155
|
+
"HapticsController",
|
|
156
|
+
"InputManager",
|
|
157
|
+
"ModuleRegistry",
|
|
158
|
+
"MissingRequiredCalibration",
|
|
159
|
+
"OutputManager",
|
|
160
|
+
"QnBotConfig",
|
|
161
|
+
"QnBotModule",
|
|
162
|
+
"QnBotSDK",
|
|
163
|
+
"RecordingController",
|
|
164
|
+
"ResolvedRetargetingBinding",
|
|
165
|
+
"Retargeter",
|
|
166
|
+
"RetargeterRegistry",
|
|
167
|
+
"SourcePattern",
|
|
168
|
+
"TargetPattern",
|
|
169
|
+
"RetargetingAlgorithmEntry",
|
|
170
|
+
"RetargetingBinding",
|
|
171
|
+
"RetargetingBindingResolver",
|
|
172
|
+
"RetargetingContext",
|
|
173
|
+
"RetargetingPipeline",
|
|
174
|
+
"RetargetingPlan",
|
|
175
|
+
"RetargetingPlanResult",
|
|
176
|
+
"RetargetingPreparation",
|
|
177
|
+
"RetargetingResult",
|
|
178
|
+
"RobotSystem",
|
|
179
|
+
"RuntimeLoop",
|
|
180
|
+
"SafetyState",
|
|
181
|
+
"SafetyController",
|
|
182
|
+
"SerialConnection",
|
|
183
|
+
"SerialDiscoveryHook",
|
|
184
|
+
"SerialEndpoint",
|
|
185
|
+
"SerialFactory",
|
|
186
|
+
"Side",
|
|
187
|
+
"Source",
|
|
188
|
+
"SourceKind",
|
|
189
|
+
"Target",
|
|
190
|
+
"TargetKind",
|
|
191
|
+
"Telemetry",
|
|
192
|
+
"TelemetryMeta",
|
|
193
|
+
"TimeSeriesBuffer",
|
|
194
|
+
"SystemBuilder",
|
|
195
|
+
"WorkflowFacade",
|
|
196
|
+
"load_python_algorithm",
|
|
197
|
+
"load_wheel_algorithm",
|
|
198
|
+
"normalize_algorithm_manifest",
|
|
199
|
+
"prepare_retargeting",
|
|
200
|
+
"serial_connection_label",
|
|
201
|
+
]
|
qnbot_sdk/core/buffer.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""In-memory time-series buffer for SDK runtime state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict, deque
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from math import inf, isfinite, nextafter
|
|
9
|
+
from threading import Condition
|
|
10
|
+
from time import monotonic
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class BufferRecord:
|
|
16
|
+
key: str
|
|
17
|
+
value: Any
|
|
18
|
+
timestamp: float
|
|
19
|
+
sequence: int
|
|
20
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TimeSeriesBuffer:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
default_capacity: int = 1024,
|
|
28
|
+
capacities: Mapping[str, int] | None = None,
|
|
29
|
+
clock: Callable[[], float] = monotonic,
|
|
30
|
+
) -> None:
|
|
31
|
+
if default_capacity <= 0:
|
|
32
|
+
raise ValueError("default_capacity must be positive")
|
|
33
|
+
|
|
34
|
+
self._default_capacity = default_capacity
|
|
35
|
+
self._capacities = dict(capacities or {})
|
|
36
|
+
for key, capacity in self._capacities.items():
|
|
37
|
+
if capacity <= 0:
|
|
38
|
+
raise ValueError(f"capacity for {key} must be positive")
|
|
39
|
+
|
|
40
|
+
self._clock = clock
|
|
41
|
+
self._records: dict[str, deque[BufferRecord]] = defaultdict(deque)
|
|
42
|
+
self._last_input_timestamps: dict[str, float] = {}
|
|
43
|
+
self._sequence = 0
|
|
44
|
+
self._condition = Condition()
|
|
45
|
+
self._subscribers: dict[str, list[Callable[[Any], None]]] = defaultdict(list)
|
|
46
|
+
|
|
47
|
+
def append(
|
|
48
|
+
self,
|
|
49
|
+
key: str,
|
|
50
|
+
value: Any,
|
|
51
|
+
*,
|
|
52
|
+
timestamp: float | None = None,
|
|
53
|
+
metadata: Mapping[str, Any] | None = None,
|
|
54
|
+
) -> BufferRecord:
|
|
55
|
+
with self._condition:
|
|
56
|
+
input_timestamp = float(self._clock() if timestamp is None else timestamp)
|
|
57
|
+
if not isfinite(input_timestamp):
|
|
58
|
+
raise ValueError(f"timestamp for {key} must be finite")
|
|
59
|
+
|
|
60
|
+
key_records = self._records[key]
|
|
61
|
+
last_input_timestamp = self._last_input_timestamps.get(key)
|
|
62
|
+
if (
|
|
63
|
+
last_input_timestamp is not None
|
|
64
|
+
and input_timestamp < last_input_timestamp
|
|
65
|
+
):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"timestamps for {key} must be monotonic non-decreasing"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
stored_timestamp = input_timestamp
|
|
71
|
+
if key_records and stored_timestamp <= key_records[-1].timestamp:
|
|
72
|
+
stored_timestamp = nextafter(key_records[-1].timestamp, inf)
|
|
73
|
+
if not isfinite(stored_timestamp):
|
|
74
|
+
raise ValueError(f"timestamp precision exhausted for {key}")
|
|
75
|
+
|
|
76
|
+
self._sequence += 1
|
|
77
|
+
record = BufferRecord(
|
|
78
|
+
key=key,
|
|
79
|
+
value=value,
|
|
80
|
+
timestamp=stored_timestamp,
|
|
81
|
+
sequence=self._sequence,
|
|
82
|
+
metadata=dict(metadata or {}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
key_records.append(record)
|
|
86
|
+
self._last_input_timestamps[key] = input_timestamp
|
|
87
|
+
while len(key_records) > self._capacity_for(key):
|
|
88
|
+
key_records.popleft()
|
|
89
|
+
self._condition.notify_all()
|
|
90
|
+
subscribers = tuple(self._subscribers.get(key, ()))
|
|
91
|
+
for subscriber in subscribers:
|
|
92
|
+
subscriber(value)
|
|
93
|
+
return record
|
|
94
|
+
|
|
95
|
+
def latest(self, key: str, default: Any = None) -> Any:
|
|
96
|
+
record = self.latest_record(key)
|
|
97
|
+
if record is None:
|
|
98
|
+
return default
|
|
99
|
+
return record.value
|
|
100
|
+
|
|
101
|
+
def latest_record(self, key: str) -> BufferRecord | None:
|
|
102
|
+
with self._condition:
|
|
103
|
+
return self._latest_record_unlocked(key)
|
|
104
|
+
|
|
105
|
+
def records(self, key: str) -> list[BufferRecord]:
|
|
106
|
+
with self._condition:
|
|
107
|
+
return list(self._records.get(key, ()))
|
|
108
|
+
|
|
109
|
+
def keys(self) -> tuple[str, ...]:
|
|
110
|
+
with self._condition:
|
|
111
|
+
return tuple(self._records.keys())
|
|
112
|
+
|
|
113
|
+
def subscribe(self, key: str, callback: Callable[[Any], None]) -> None:
|
|
114
|
+
with self._condition:
|
|
115
|
+
self._subscribers[key].append(callback)
|
|
116
|
+
|
|
117
|
+
def unsubscribe(self, key: str, callback: Callable[[Any], None]) -> None:
|
|
118
|
+
with self._condition:
|
|
119
|
+
subscribers = self._subscribers.get(key, [])
|
|
120
|
+
remaining = [subscriber for subscriber in subscribers if subscriber is not callback]
|
|
121
|
+
if remaining:
|
|
122
|
+
self._subscribers[key] = remaining
|
|
123
|
+
else:
|
|
124
|
+
self._subscribers.pop(key, None)
|
|
125
|
+
|
|
126
|
+
def wait_next_data(
|
|
127
|
+
self,
|
|
128
|
+
key: str,
|
|
129
|
+
*,
|
|
130
|
+
after_sequence: int | None = None,
|
|
131
|
+
timeout: float | None = None,
|
|
132
|
+
) -> BufferRecord | None:
|
|
133
|
+
deadline = None if timeout is None else monotonic() + timeout
|
|
134
|
+
with self._condition:
|
|
135
|
+
if after_sequence is None:
|
|
136
|
+
start_record = self._latest_record_unlocked(key)
|
|
137
|
+
start_sequence = 0 if start_record is None else start_record.sequence
|
|
138
|
+
else:
|
|
139
|
+
start_sequence = after_sequence
|
|
140
|
+
while True:
|
|
141
|
+
record = self._first_record_after_unlocked(key, start_sequence)
|
|
142
|
+
if record is not None:
|
|
143
|
+
return record
|
|
144
|
+
if deadline is None:
|
|
145
|
+
self._condition.wait()
|
|
146
|
+
continue
|
|
147
|
+
remaining = deadline - monotonic()
|
|
148
|
+
if remaining <= 0:
|
|
149
|
+
return None
|
|
150
|
+
self._condition.wait(remaining)
|
|
151
|
+
|
|
152
|
+
def _latest_record_unlocked(self, key: str) -> BufferRecord | None:
|
|
153
|
+
key_records = self._records.get(key)
|
|
154
|
+
if not key_records:
|
|
155
|
+
return None
|
|
156
|
+
return key_records[-1]
|
|
157
|
+
|
|
158
|
+
def _first_record_after_unlocked(
|
|
159
|
+
self,
|
|
160
|
+
key: str,
|
|
161
|
+
sequence: int,
|
|
162
|
+
) -> BufferRecord | None:
|
|
163
|
+
key_records = self._records.get(key)
|
|
164
|
+
if not key_records:
|
|
165
|
+
return None
|
|
166
|
+
for record in key_records:
|
|
167
|
+
if record.sequence > sequence:
|
|
168
|
+
return record
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def _capacity_for(self, key: str) -> int:
|
|
172
|
+
return self._capacities.get(key, self._default_capacity)
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Calibration record storage contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from packaging.version import InvalidVersion, Version
|
|
13
|
+
|
|
14
|
+
from qnbot_sdk.core.config import Side, Source, SourceKind, Target, TargetKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class CalibrationRecord:
|
|
19
|
+
source: Source
|
|
20
|
+
target: Target
|
|
21
|
+
retargeting_algorithm_id: str
|
|
22
|
+
retargeting_algorithm_version: str | None = None
|
|
23
|
+
calibration_algorithm_id: str | None = None
|
|
24
|
+
calibration_algorithm_version: str | None = None
|
|
25
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
operator_id: str | None = None
|
|
28
|
+
record_id: str = field(default_factory=lambda: uuid4().hex)
|
|
29
|
+
created_at: float = field(default_factory=monotonic)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CalibrationStore:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
records: list[CalibrationRecord] | tuple[CalibrationRecord, ...] = (),
|
|
36
|
+
*,
|
|
37
|
+
path: str | Path | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.path = None if path is None else Path(path)
|
|
40
|
+
self._records = list(records)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def default(cls, *, load: bool = True) -> CalibrationStore:
|
|
44
|
+
store = cls(path=Path.home() / ".qnbot" / "calibrations")
|
|
45
|
+
if load:
|
|
46
|
+
store.load()
|
|
47
|
+
return store
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def records(self) -> tuple[CalibrationRecord, ...]:
|
|
51
|
+
return tuple(self._records)
|
|
52
|
+
|
|
53
|
+
def find(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
source: Source,
|
|
57
|
+
target: Target,
|
|
58
|
+
retargeting_algorithm_id: str | None = None,
|
|
59
|
+
retargeting_algorithm_version: str | None = None,
|
|
60
|
+
calibration_algorithm_id: str | None = None,
|
|
61
|
+
calibration_algorithm_version: str | None = None,
|
|
62
|
+
minimum_calibration_version: str | None = None,
|
|
63
|
+
operator_id: str | None = None,
|
|
64
|
+
match_operator: bool = False,
|
|
65
|
+
) -> CalibrationRecord | None:
|
|
66
|
+
matches = [
|
|
67
|
+
record
|
|
68
|
+
for record in self._records
|
|
69
|
+
if record.source == source
|
|
70
|
+
and record.target == target
|
|
71
|
+
and (
|
|
72
|
+
calibration_algorithm_id is not None
|
|
73
|
+
or retargeting_algorithm_id is None
|
|
74
|
+
or record.retargeting_algorithm_id == retargeting_algorithm_id
|
|
75
|
+
)
|
|
76
|
+
and (
|
|
77
|
+
calibration_algorithm_id is None
|
|
78
|
+
or record.calibration_algorithm_id == calibration_algorithm_id
|
|
79
|
+
)
|
|
80
|
+
and (
|
|
81
|
+
calibration_algorithm_version is None
|
|
82
|
+
or _version_at_most(
|
|
83
|
+
record.calibration_algorithm_version,
|
|
84
|
+
calibration_algorithm_version,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
and (
|
|
88
|
+
minimum_calibration_version is None
|
|
89
|
+
or _version_at_least(
|
|
90
|
+
record.calibration_algorithm_version,
|
|
91
|
+
minimum_calibration_version,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
and (not match_operator or record.operator_id == operator_id)
|
|
95
|
+
]
|
|
96
|
+
if not matches:
|
|
97
|
+
return None
|
|
98
|
+
return matches[0]
|
|
99
|
+
|
|
100
|
+
def add(self, record: CalibrationRecord) -> CalibrationRecord:
|
|
101
|
+
self._records = [
|
|
102
|
+
existing
|
|
103
|
+
for existing in self._records
|
|
104
|
+
if _record_identity(existing) != _record_identity(record)
|
|
105
|
+
]
|
|
106
|
+
self._records.append(record)
|
|
107
|
+
self.save()
|
|
108
|
+
return record
|
|
109
|
+
|
|
110
|
+
def load(self) -> None:
|
|
111
|
+
if self.path is None or not self.path.exists():
|
|
112
|
+
return
|
|
113
|
+
records: list[CalibrationRecord] = []
|
|
114
|
+
for path in sorted(self.path.glob("*/hand_system.yaml")):
|
|
115
|
+
record = _record_from_hand_system_yaml(
|
|
116
|
+
yaml.safe_load(path.read_text(encoding="utf-8")),
|
|
117
|
+
)
|
|
118
|
+
if record is not None:
|
|
119
|
+
records.append(record)
|
|
120
|
+
self._records = records
|
|
121
|
+
|
|
122
|
+
def save(self) -> None:
|
|
123
|
+
if self.path is None:
|
|
124
|
+
return
|
|
125
|
+
self.path.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
for record in self._records:
|
|
127
|
+
hand_system_path = self.hand_system_path_for(record)
|
|
128
|
+
hand_system_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
hand_system_path.write_text(
|
|
130
|
+
yaml.safe_dump(
|
|
131
|
+
_record_to_hand_system_yaml(record),
|
|
132
|
+
allow_unicode=True,
|
|
133
|
+
sort_keys=True,
|
|
134
|
+
),
|
|
135
|
+
encoding="utf-8",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def path_for(self, record: CalibrationRecord) -> Path:
|
|
139
|
+
if self.path is None:
|
|
140
|
+
raise ValueError("calibration store path is not configured")
|
|
141
|
+
return self.hand_system_path_for(record)
|
|
142
|
+
|
|
143
|
+
def hand_system_path_for(self, record: CalibrationRecord) -> Path:
|
|
144
|
+
if self.path is None:
|
|
145
|
+
raise ValueError("calibration store path is not configured")
|
|
146
|
+
return self.path / _identity_stem(record) / "hand_system.yaml"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def default_hand_system_path(record: CalibrationRecord) -> Path:
|
|
150
|
+
return CalibrationStore.default(load=False).hand_system_path_for(record)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _record_to_hand_system_yaml(record: CalibrationRecord) -> dict[str, Any]:
|
|
154
|
+
return {
|
|
155
|
+
"_metadata": {
|
|
156
|
+
"schema": "qnbot.hand_system.v1",
|
|
157
|
+
"record_id": record.record_id,
|
|
158
|
+
"source": _source_to_yaml(record.source),
|
|
159
|
+
"target": _target_to_yaml(record.target),
|
|
160
|
+
"retargeting_algorithm": {
|
|
161
|
+
"id": record.retargeting_algorithm_id,
|
|
162
|
+
"version": record.retargeting_algorithm_version,
|
|
163
|
+
},
|
|
164
|
+
"calibration_algorithm": {
|
|
165
|
+
"id": record.calibration_algorithm_id,
|
|
166
|
+
"version": record.calibration_algorithm_version,
|
|
167
|
+
},
|
|
168
|
+
"operator_id": record.operator_id,
|
|
169
|
+
"created_at": record.created_at,
|
|
170
|
+
"diagnostics": record.diagnostics,
|
|
171
|
+
},
|
|
172
|
+
"data": record.data,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _record_identity(
|
|
177
|
+
record: CalibrationRecord,
|
|
178
|
+
) -> tuple[Source, Target, str | None, str | None]:
|
|
179
|
+
return (
|
|
180
|
+
record.source,
|
|
181
|
+
record.target,
|
|
182
|
+
record.calibration_algorithm_id,
|
|
183
|
+
record.operator_id,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _identity_stem(record: CalibrationRecord) -> str:
|
|
188
|
+
parts = [
|
|
189
|
+
"operator",
|
|
190
|
+
_filename_part(record.operator_id),
|
|
191
|
+
"source",
|
|
192
|
+
_filename_part(record.source.id),
|
|
193
|
+
"target",
|
|
194
|
+
_filename_part(record.target.id),
|
|
195
|
+
"calibration",
|
|
196
|
+
_filename_part(record.calibration_algorithm_id),
|
|
197
|
+
]
|
|
198
|
+
return "__".join(parts)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _filename_part(value: str | None) -> str:
|
|
202
|
+
if value is None or value == "":
|
|
203
|
+
return "none"
|
|
204
|
+
return "".join(
|
|
205
|
+
character
|
|
206
|
+
if character.isalnum() or character in {".", "-", "_"}
|
|
207
|
+
else "-"
|
|
208
|
+
for character in value
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _record_from_hand_system_yaml(data: object) -> CalibrationRecord | None:
|
|
213
|
+
if not isinstance(data, dict):
|
|
214
|
+
return None
|
|
215
|
+
metadata = data.get("_metadata")
|
|
216
|
+
if not isinstance(metadata, dict) or metadata.get("schema") != "qnbot.hand_system.v1":
|
|
217
|
+
return None
|
|
218
|
+
calibration_algorithm = metadata.get("calibration_algorithm", {})
|
|
219
|
+
retargeting_algorithm = metadata.get("retargeting_algorithm", {})
|
|
220
|
+
if not isinstance(calibration_algorithm, dict) or not isinstance(
|
|
221
|
+
retargeting_algorithm,
|
|
222
|
+
dict,
|
|
223
|
+
):
|
|
224
|
+
return None
|
|
225
|
+
return CalibrationRecord(
|
|
226
|
+
source=_source_from_yaml(metadata["source"]),
|
|
227
|
+
target=_target_from_yaml(metadata["target"]),
|
|
228
|
+
retargeting_algorithm_id=retargeting_algorithm.get("id"),
|
|
229
|
+
retargeting_algorithm_version=retargeting_algorithm.get("version"),
|
|
230
|
+
calibration_algorithm_id=calibration_algorithm.get("id"),
|
|
231
|
+
calibration_algorithm_version=calibration_algorithm.get("version"),
|
|
232
|
+
data=dict(data.get("data", {})),
|
|
233
|
+
diagnostics=dict(metadata.get("diagnostics", {})),
|
|
234
|
+
operator_id=metadata.get("operator_id"),
|
|
235
|
+
record_id=metadata["record_id"],
|
|
236
|
+
created_at=float(metadata["created_at"]),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _source_to_yaml(source: Source) -> dict[str, str | None]:
|
|
241
|
+
return {
|
|
242
|
+
"kind": source.kind.value,
|
|
243
|
+
"name": source.name,
|
|
244
|
+
"side": None if source.side is None else source.side.value,
|
|
245
|
+
"id": source.id,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _target_to_yaml(target: Target) -> dict[str, str | None]:
|
|
250
|
+
return {
|
|
251
|
+
"kind": target.kind.value,
|
|
252
|
+
"name": target.name,
|
|
253
|
+
"side": None if target.side is None else target.side.value,
|
|
254
|
+
"id": target.id,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _source_from_yaml(data: dict[str, Any]) -> Source:
|
|
259
|
+
return Source(
|
|
260
|
+
kind=SourceKind(data["kind"]),
|
|
261
|
+
name=data.get("name"),
|
|
262
|
+
side=_optional_side(data.get("side")),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _target_from_yaml(data: dict[str, Any]) -> Target:
|
|
267
|
+
return Target(
|
|
268
|
+
kind=TargetKind(data["kind"]),
|
|
269
|
+
name=data.get("name"),
|
|
270
|
+
side=_optional_side(data.get("side")),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _optional_side(value: Any) -> Side | None:
|
|
275
|
+
if value is None:
|
|
276
|
+
return None
|
|
277
|
+
return Side(value)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _version_at_least(actual: str | None, minimum: str) -> bool:
|
|
281
|
+
if actual is None:
|
|
282
|
+
return False
|
|
283
|
+
return _parse_calibration_version(actual) >= _parse_calibration_version(minimum)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _version_at_most(actual: str | None, maximum: str) -> bool:
|
|
287
|
+
if actual is None:
|
|
288
|
+
return False
|
|
289
|
+
return _parse_calibration_version(actual) <= _parse_calibration_version(maximum)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _parse_calibration_version(version: str) -> Version:
|
|
293
|
+
try:
|
|
294
|
+
return Version(version)
|
|
295
|
+
except InvalidVersion as exc:
|
|
296
|
+
raise ValueError(f"invalid calibration version: {version}") from exc
|
qnbot_sdk/core/compat.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Compatibility helpers for supported Python versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
except ImportError: # pragma: no cover - exercised on Python 3.10.
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
class StrEnum(str, Enum):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["StrEnum"]
|