loop-sdk 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/PKG-INFO +1 -1
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/schema.py +6 -1
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/robot_step_sender.py +26 -18
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/service/producer_session.py +4 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/domain/test_schema.py +7 -3
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/test_robot_step_sender.py +22 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/.github/workflows/publish.yml +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/.gitignore +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/CONTRIBUTING.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/Makefile +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/README.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/README.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_descriptor.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_journey.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_sequence.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_visualizer.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/consumer_plan.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/descriptor.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/extract_real_examples.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/flow.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/flow_sequence.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/journey.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/real_examples.json +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/visualizer.html +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/dev_requirements.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/foundation_request_consumer_rpc.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/pm_questions.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/source_connection_spec.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/spec_discrepancies.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/README.md +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/policy_action_consumer.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/quickstart.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/robot_step_producer.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/buf.gen.yaml +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/buf.yaml +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/loop/foundation/source/v1/source_ingest.proto +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/pyproject.toml +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/__init__.pyi +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/__init__.pyi +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2.pyi +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2_grpc.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/client.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/consumer.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/config.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/frame.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/source_exception.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/source_kind.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/stats.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/frame_queue.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/grpc_source_client.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/grpc_source_reader.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/proto_mapping.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/source_client.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/source_reader.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/service/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/setup/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/setup/config.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_lifecycle.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_roundtrip.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_grpc_roundtrip.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/domain/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_frame_queue.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_policy_action_decode.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_proto_mapping.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_robot_config_negotiation.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_send_latency.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/service/__init__.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/service/test_producer_session.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tools/replay_fidelity_check.py +0 -0
- {loop_sdk-0.1.1 → loop_sdk-0.1.2}/uv.lock +0 -0
|
@@ -99,7 +99,12 @@ class RobotStreamSchema(BaseModel):
|
|
|
99
99
|
|
|
100
100
|
source_id: str
|
|
101
101
|
name: str = ""
|
|
102
|
-
|
|
102
|
+
# A robot sample is self-describing (its ``state`` keys define the columns the
|
|
103
|
+
# recorder writes), so the declared channel layout is OPTIONAL metadata. Leave
|
|
104
|
+
# it empty to announce the source for discovery/config-negotiation and let the
|
|
105
|
+
# streamed samples define the channels; declare it to attach per-channel
|
|
106
|
+
# metadata (role/unit/...) and have the SDK validate sends against it.
|
|
107
|
+
channels: tuple[ChannelSpec, ...] = ()
|
|
103
108
|
# Per-axis config candidates the source can open with. Empty (the default)
|
|
104
109
|
# means no negotiation: the Source Bus opens it with the device default and
|
|
105
110
|
# no ``on_select`` callback fires.
|
|
@@ -225,22 +225,26 @@ class RobotStepSender:
|
|
|
225
225
|
|
|
226
226
|
def declare(
|
|
227
227
|
self,
|
|
228
|
-
channels: ChannelLayout,
|
|
228
|
+
channels: ChannelLayout = (),
|
|
229
229
|
*,
|
|
230
230
|
name: str | None = None,
|
|
231
231
|
options: RobotConfigOptions | None = None,
|
|
232
232
|
) -> bool:
|
|
233
|
-
"""
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
``
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
233
|
+
"""Open the source so the Source Bus can discover and negotiate it (→ "ready"),
|
|
234
|
+
before any data flows. Idempotent once the source is open.
|
|
235
|
+
|
|
236
|
+
``channels`` is OPTIONAL — a robot sample is self-describing, so the recorder
|
|
237
|
+
takes its columns from the streamed samples, not from this layout:
|
|
238
|
+
|
|
239
|
+
* **Omit it (options-only)** to announce the source for discovery / config
|
|
240
|
+
negotiation now and let the FIRST ``send`` capture the channel layout from
|
|
241
|
+
its keys. Use this to reach "ready" at startup without knowing the full
|
|
242
|
+
channel set yet.
|
|
243
|
+
* **Pass it** (keys / ``ChannelSpec``s / a representative step dict) to fix
|
|
244
|
+
the layout up front and have the SDK validate sends against it (and attach
|
|
245
|
+
per-channel metadata).
|
|
246
|
+
|
|
247
|
+
Either way ``options`` + ``apply_config`` negotiation happens at Open.
|
|
244
248
|
"""
|
|
245
249
|
if not self.connected:
|
|
246
250
|
self.connect()
|
|
@@ -252,10 +256,7 @@ class RobotStepSender:
|
|
|
252
256
|
self.name = name
|
|
253
257
|
|
|
254
258
|
specs = self._resolve_specs(channels)
|
|
255
|
-
if
|
|
256
|
-
logger.warning("RobotStepSender: empty channel layout; not opening source %s", self.source_id)
|
|
257
|
-
return False
|
|
258
|
-
self._order = [spec.key for spec in specs]
|
|
259
|
+
self._order = [spec.key for spec in specs] if specs else None
|
|
259
260
|
schema = SourceSchema(
|
|
260
261
|
robot=(
|
|
261
262
|
RobotStreamSchema(
|
|
@@ -274,7 +275,10 @@ class RobotStepSender:
|
|
|
274
275
|
on_select=self._on_select,
|
|
275
276
|
)
|
|
276
277
|
logger.info(
|
|
277
|
-
"RobotStepSender: source %s open on %s (%
|
|
278
|
+
"RobotStepSender: source %s open on %s (%s)",
|
|
279
|
+
self.source_id,
|
|
280
|
+
self.loop_addr,
|
|
281
|
+
f"{len(specs)} channels" if specs else "options-only; layout from first send",
|
|
278
282
|
)
|
|
279
283
|
return True
|
|
280
284
|
|
|
@@ -312,7 +316,11 @@ class RobotStepSender:
|
|
|
312
316
|
try:
|
|
313
317
|
if self._producer is None and not self.declare(flat):
|
|
314
318
|
return False
|
|
315
|
-
|
|
319
|
+
if self._order is None:
|
|
320
|
+
# Source was declared options-only — the first frame fixes the layout
|
|
321
|
+
# (and thus the recorder's columns), so make this first frame complete.
|
|
322
|
+
self._order = list(flat)
|
|
323
|
+
assert self._producer is not None
|
|
316
324
|
self._warn_unknown_keys(flat)
|
|
317
325
|
state = {key: _normalize_value(flat.get(key)) for key in self._order}
|
|
318
326
|
seq = self._seq if sequence is None else sequence
|
|
@@ -67,6 +67,10 @@ class ProducerSession:
|
|
|
67
67
|
|
|
68
68
|
def _validate_robot(self, frame: RobotFrame) -> None:
|
|
69
69
|
declared = self._schema.robot_channel_keys(frame.source_id)
|
|
70
|
+
if not declared:
|
|
71
|
+
# Options-only / self-describing source: no declared layout to enforce —
|
|
72
|
+
# the recorder writes whatever keys each sample carries.
|
|
73
|
+
return
|
|
70
74
|
unknown = frame.state.keys() - declared
|
|
71
75
|
if unknown:
|
|
72
76
|
raise SchemaMismatchError(
|
|
@@ -31,9 +31,13 @@ def test_robot_stream_channel_keys_match_declared_channels() -> None:
|
|
|
31
31
|
assert stream.kind is SourceKind.ROBOT
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
def test_robot_stream_allows_empty_channels() -> None:
|
|
35
|
+
# A robot sample is self-describing, so the channel layout is optional metadata:
|
|
36
|
+
# an empty layout announces the source (discovery / config negotiation) and lets
|
|
37
|
+
# the streamed samples define the columns.
|
|
38
|
+
stream = RobotStreamSchema(source_id="arm/state", channels=())
|
|
39
|
+
assert stream.channel_keys == frozenset()
|
|
40
|
+
assert stream.kind is SourceKind.ROBOT
|
|
37
41
|
|
|
38
42
|
|
|
39
43
|
def test_schema_rejects_empty() -> None:
|
|
@@ -105,6 +105,28 @@ def test_first_send_auto_declares_from_step() -> None:
|
|
|
105
105
|
assert {c.key for c in schema.robot[0].channels} == {"x", "y"}
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def test_options_only_declare_reaches_ready_then_first_send_fixes_layout() -> None:
|
|
109
|
+
# declare() with NO channels announces the source (discovery/negotiation -> ready)
|
|
110
|
+
# with an empty layout; the first send fixes the channels from its keys.
|
|
111
|
+
opts = RobotConfigOptions(control_hz=(20,), action_space=("target_cartesian_delta",))
|
|
112
|
+
sender, harness = _sender(options=opts)
|
|
113
|
+
sender.connect()
|
|
114
|
+
assert sender.declare() is True # options-only, no channels
|
|
115
|
+
robot = harness.connect_calls[0]["schema"].robot[0]
|
|
116
|
+
assert robot.channels == () # announced with an empty layout
|
|
117
|
+
assert robot.available_options.control_hz == (20,) # options still negotiated
|
|
118
|
+
assert sender._order is None # layout not fixed yet
|
|
119
|
+
assert harness.producer.sent == [] # declaring sends no sample
|
|
120
|
+
|
|
121
|
+
sender.send(1, {"a": 1.0, "b": 2.0}) # first frame fixes the layout
|
|
122
|
+
_sid, _ts, _seq, state = harness.producer.sent[0]
|
|
123
|
+
assert set(state) == {"a", "b"} and sender._order == ["a", "b"]
|
|
124
|
+
|
|
125
|
+
sender.send(2, {"a": 3.0, "c": 9.0}) # stable key set: c dropped, b -> no reading
|
|
126
|
+
_sid, _ts, _seq, state2 = harness.producer.sent[1]
|
|
127
|
+
assert set(state2) == {"a", "b"} and state2["b"] is None
|
|
128
|
+
|
|
129
|
+
|
|
108
130
|
def test_nested_step_is_flattened_on_send_not_dropped() -> None:
|
|
109
131
|
sender, harness = _sender()
|
|
110
132
|
sender.connect()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_lifecycle.py
RENAMED
|
File without changes
|
{loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_roundtrip.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_robot_config_negotiation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|