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.
@@ -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
+ ]
@@ -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
@@ -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"]