loop-sdk 0.1.0__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.0/.github/workflows/publish.yml +80 -0
- loop_sdk-0.1.0/.gitignore +10 -0
- loop_sdk-0.1.0/Makefile +49 -0
- loop_sdk-0.1.0/PKG-INFO +9 -0
- loop_sdk-0.1.0/README.md +152 -0
- loop_sdk-0.1.0/docs/data_model/README.md +66 -0
- loop_sdk-0.1.0/docs/data_model/build_descriptor.py +116 -0
- loop_sdk-0.1.0/docs/data_model/build_journey.py +462 -0
- loop_sdk-0.1.0/docs/data_model/build_sequence.py +198 -0
- loop_sdk-0.1.0/docs/data_model/build_visualizer.py +380 -0
- loop_sdk-0.1.0/docs/data_model/consumer_plan.html +179 -0
- loop_sdk-0.1.0/docs/data_model/descriptor.html +96 -0
- loop_sdk-0.1.0/docs/data_model/extract_real_examples.py +405 -0
- loop_sdk-0.1.0/docs/data_model/flow.html +266 -0
- loop_sdk-0.1.0/docs/data_model/flow_sequence.html +35 -0
- loop_sdk-0.1.0/docs/data_model/journey.html +442 -0
- loop_sdk-0.1.0/docs/data_model/real_examples.json +1 -0
- loop_sdk-0.1.0/docs/data_model/visualizer.html +359 -0
- loop_sdk-0.1.0/docs/dev_requirements.md +20 -0
- loop_sdk-0.1.0/docs/foundation_request_consumer_rpc.md +150 -0
- loop_sdk-0.1.0/docs/pm_questions.md +192 -0
- loop_sdk-0.1.0/docs/source_connection_spec.md +109 -0
- loop_sdk-0.1.0/docs/spec_discrepancies.md +289 -0
- loop_sdk-0.1.0/examples/README.md +31 -0
- loop_sdk-0.1.0/examples/policy_action_consumer.py +45 -0
- loop_sdk-0.1.0/examples/quickstart.py +52 -0
- loop_sdk-0.1.0/examples/robot_step_producer.py +76 -0
- loop_sdk-0.1.0/proto/buf.gen.yaml +19 -0
- loop_sdk-0.1.0/proto/buf.yaml +23 -0
- loop_sdk-0.1.0/proto/loop/foundation/source/v1/source_ingest.proto +428 -0
- loop_sdk-0.1.0/pyproject.toml +44 -0
- loop_sdk-0.1.0/src/loop_sdk/__init__.py +84 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/__init__.pyi +1 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/v1/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/v1/__init__.pyi +1 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/v1/source_ingest_pb2.py +132 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/v1/source_ingest_pb2.pyi +466 -0
- loop_sdk-0.1.0/src/loop_sdk/gen/v1/source_ingest_pb2_grpc.py +288 -0
- loop_sdk-0.1.0/src/loop_sdk/source/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/client.py +123 -0
- loop_sdk-0.1.0/src/loop_sdk/source/consumer.py +54 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/config.py +61 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/frame.py +108 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/schema.py +193 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/source_exception.py +48 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/source_kind.py +18 -0
- loop_sdk-0.1.0/src/loop_sdk/source/domain/stats.py +22 -0
- loop_sdk-0.1.0/src/loop_sdk/source/outbound/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/outbound/frame_queue.py +85 -0
- loop_sdk-0.1.0/src/loop_sdk/source/outbound/grpc_source_client.py +360 -0
- loop_sdk-0.1.0/src/loop_sdk/source/outbound/grpc_source_reader.py +82 -0
- loop_sdk-0.1.0/src/loop_sdk/source/outbound/proto_mapping.py +239 -0
- loop_sdk-0.1.0/src/loop_sdk/source/port/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/port/source_client.py +49 -0
- loop_sdk-0.1.0/src/loop_sdk/source/port/source_reader.py +34 -0
- loop_sdk-0.1.0/src/loop_sdk/source/robot_step_sender.py +410 -0
- loop_sdk-0.1.0/src/loop_sdk/source/service/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/service/producer_session.py +81 -0
- loop_sdk-0.1.0/src/loop_sdk/source/setup/__init__.py +0 -0
- loop_sdk-0.1.0/src/loop_sdk/source/setup/config.py +30 -0
- loop_sdk-0.1.0/tests/integration/__init__.py +0 -0
- loop_sdk-0.1.0/tests/integration/source/__init__.py +0 -0
- loop_sdk-0.1.0/tests/integration/source/outbound/__init__.py +0 -0
- loop_sdk-0.1.0/tests/integration/source/outbound/test_consumer_lifecycle.py +93 -0
- loop_sdk-0.1.0/tests/integration/source/outbound/test_consumer_roundtrip.py +84 -0
- loop_sdk-0.1.0/tests/integration/source/outbound/test_grpc_roundtrip.py +396 -0
- loop_sdk-0.1.0/tests/unit/__init__.py +0 -0
- loop_sdk-0.1.0/tests/unit/source/__init__.py +0 -0
- loop_sdk-0.1.0/tests/unit/source/domain/__init__.py +0 -0
- loop_sdk-0.1.0/tests/unit/source/domain/test_schema.py +115 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/__init__.py +0 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/test_frame_queue.py +70 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/test_policy_action_decode.py +46 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/test_proto_mapping.py +170 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/test_robot_config_negotiation.py +97 -0
- loop_sdk-0.1.0/tests/unit/source/outbound/test_send_latency.py +43 -0
- loop_sdk-0.1.0/tests/unit/source/service/__init__.py +0 -0
- loop_sdk-0.1.0/tests/unit/source/service/test_producer_session.py +139 -0
- loop_sdk-0.1.0/tests/unit/source/test_robot_step_sender.py +246 -0
- loop_sdk-0.1.0/tools/replay_fidelity_check.py +229 -0
- loop_sdk-0.1.0/uv.lock +281 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Publish loop-sdk on every version tag.
|
|
2
|
+
#
|
|
3
|
+
# Routing by tag name:
|
|
4
|
+
# v0.1.0 -> PyPI (real release)
|
|
5
|
+
# v0.1.0-test -> TestPyPI (rehearsal)
|
|
6
|
+
#
|
|
7
|
+
# Versioning is tag-driven (hatch-vcs): a plain tag `vX.Y.Z` -> version `X.Y.Z`.
|
|
8
|
+
# A `-test` tag is NOT a valid PEP 440 version, so the TestPyPI path overrides the
|
|
9
|
+
# version with `X.Y.Z.dev<run_number>` (valid + unique per run, so re-runs don't
|
|
10
|
+
# collide on TestPyPI).
|
|
11
|
+
#
|
|
12
|
+
# Auth uses Trusted Publishing (OIDC) — no API tokens. ONE-TIME setup: register a
|
|
13
|
+
# trusted publisher on BOTH indexes (workflow=publish.yml):
|
|
14
|
+
# pypi.org -> project loop-sdk, environment "pypi"
|
|
15
|
+
# test.pypi.org -> project loop-sdk, environment "testpypi"
|
|
16
|
+
# (Before the project exists, add it as a "pending publisher".)
|
|
17
|
+
name: Publish to PyPI
|
|
18
|
+
|
|
19
|
+
on:
|
|
20
|
+
push:
|
|
21
|
+
tags:
|
|
22
|
+
- "v*"
|
|
23
|
+
|
|
24
|
+
permissions:
|
|
25
|
+
contents: read
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
testpypi:
|
|
29
|
+
name: Publish to TestPyPI
|
|
30
|
+
if: contains(github.ref_name, '-test')
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment:
|
|
33
|
+
name: testpypi
|
|
34
|
+
url: https://test.pypi.org/p/loop-sdk
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write # OIDC token for Trusted Publishing
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
with:
|
|
40
|
+
fetch-depth: 0 # hatch-vcs needs full history + tags
|
|
41
|
+
- name: Install uv
|
|
42
|
+
uses: astral-sh/setup-uv@v5
|
|
43
|
+
- name: Compute a valid TestPyPI version
|
|
44
|
+
id: ver
|
|
45
|
+
run: |
|
|
46
|
+
base="${GITHUB_REF_NAME#v}" # strip leading v
|
|
47
|
+
base="${base%%-test*}" # strip the -test suffix
|
|
48
|
+
echo "version=${base}.dev${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
|
49
|
+
- name: Build (version overridden for TestPyPI)
|
|
50
|
+
env:
|
|
51
|
+
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.ver.outputs.version }}
|
|
52
|
+
run: uv build
|
|
53
|
+
- name: Check package metadata
|
|
54
|
+
run: uvx twine check dist/*
|
|
55
|
+
- name: Publish to TestPyPI
|
|
56
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
57
|
+
with:
|
|
58
|
+
repository-url: https://test.pypi.org/legacy/
|
|
59
|
+
|
|
60
|
+
pypi:
|
|
61
|
+
name: Publish to PyPI
|
|
62
|
+
if: ${{ !contains(github.ref_name, '-test') }}
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
environment:
|
|
65
|
+
name: pypi
|
|
66
|
+
url: https://pypi.org/p/loop-sdk
|
|
67
|
+
permissions:
|
|
68
|
+
id-token: write # OIDC token for Trusted Publishing
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@v4
|
|
71
|
+
with:
|
|
72
|
+
fetch-depth: 0 # hatch-vcs needs full history + tags
|
|
73
|
+
- name: Install uv
|
|
74
|
+
uses: astral-sh/setup-uv@v5
|
|
75
|
+
- name: Build
|
|
76
|
+
run: uv build
|
|
77
|
+
- name: Check package metadata
|
|
78
|
+
run: uvx twine check dist/*
|
|
79
|
+
- name: Publish to PyPI
|
|
80
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
loop_sdk-0.1.0/Makefile
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
SDK_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
|
|
2
|
+
PROTO_DIR := $(SDK_DIR)/proto
|
|
3
|
+
PY_PACKAGE ?= loop_sdk
|
|
4
|
+
# The vendored contract is owned by the foundation module; its package root is
|
|
5
|
+
# loop/foundation/source. Generated Python lands directly under gen/vN.
|
|
6
|
+
PROTO_PACKAGE_ROOT ?= loop/foundation/source
|
|
7
|
+
GEN_DIR ?= $(SDK_DIR)/src/$(PY_PACKAGE)/gen
|
|
8
|
+
# Generation-only Buf config. buf.yaml keeps the module root at proto/ so
|
|
9
|
+
# lint/breaking enforce package-mirrored IDL paths. Python generation uses the
|
|
10
|
+
# contract owner directory as its root so generated modules land directly under
|
|
11
|
+
# gen/vN instead of gen/loop/foundation/source/vN.
|
|
12
|
+
GEN_BUF_CONFIG ?= {"version":"v2","modules":[{"path":"$(PROTO_PACKAGE_ROOT)"}]}
|
|
13
|
+
GEN_BUF_TEMPLATE ?= {"version":"v2","clean":true,"plugins":[{"remote":"buf.build/protocolbuffers/python","out":"$(GEN_DIR)"},{"remote":"buf.build/protocolbuffers/pyi","out":"$(GEN_DIR)"},{"remote":"buf.build/grpc/python","out":"$(GEN_DIR)"}]}
|
|
14
|
+
|
|
15
|
+
# Prerequisites:
|
|
16
|
+
# - buf CLI on PATH (https://buf.build/docs/installation) — runs codegen via
|
|
17
|
+
# remote plugins, lint, and breaking-change detection.
|
|
18
|
+
# - uv (provides `uvx` to run protoletariat in an isolated env). protoletariat
|
|
19
|
+
# rewrites absolute proto imports into relative ones; it is run isolated
|
|
20
|
+
# because it caps protobuf <6 and must not touch the project runtime.
|
|
21
|
+
PROTOLETARIAT_SPEC ?= protoletariat
|
|
22
|
+
|
|
23
|
+
# Upstream contract location, for drift/breaking checks against the source of truth.
|
|
24
|
+
UPSTREAM_PROTO ?= ../loop/module/foundation/proto
|
|
25
|
+
|
|
26
|
+
.PHONY: proto proto-gen proto-lint proto-breaking proto-sync
|
|
27
|
+
|
|
28
|
+
## proto: lint then regenerate stubs (full pipeline)
|
|
29
|
+
proto: proto-lint proto-gen
|
|
30
|
+
|
|
31
|
+
## proto-gen: regenerate Python stubs for the vendored contract
|
|
32
|
+
proto-gen:
|
|
33
|
+
cd "$(PROTO_DIR)" && buf generate --config '$(GEN_BUF_CONFIG)' --template '$(GEN_BUF_TEMPLATE)'
|
|
34
|
+
cd "$(PROTO_DIR)" && buf build --config '$(GEN_BUF_CONFIG)' -o - | \
|
|
35
|
+
uvx --from "$(PROTOLETARIAT_SPEC)" \
|
|
36
|
+
protol --create-package --in-place --python-out "$(GEN_DIR)" raw -
|
|
37
|
+
|
|
38
|
+
## proto-lint: lint the vendored .proto contract
|
|
39
|
+
proto-lint:
|
|
40
|
+
cd "$(PROTO_DIR)" && buf lint
|
|
41
|
+
|
|
42
|
+
## proto-breaking: fail if the vendored contract breaks wire compatibility vs the upstream foundation contract
|
|
43
|
+
proto-breaking:
|
|
44
|
+
cd "$(PROTO_DIR)" && buf breaking --against "$(UPSTREAM_PROTO)"
|
|
45
|
+
|
|
46
|
+
## proto-sync: refresh the vendored .proto from the upstream foundation contract
|
|
47
|
+
proto-sync:
|
|
48
|
+
cp "$(UPSTREAM_PROTO)/loop/foundation/source/v1/source_ingest.proto" \
|
|
49
|
+
"$(PROTO_DIR)/loop/foundation/source/v1/source_ingest.proto"
|
loop_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loop-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Customer-side gRPC client SDK for the Loop Source Bus.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: grpcio>=1.81.1
|
|
7
|
+
Requires-Dist: protobuf>=7.35.1
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.13.0
|
|
9
|
+
Requires-Dist: pydantic>=2.12.0
|
loop_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# loop-sdk
|
|
2
|
+
|
|
3
|
+
[English](#loop-sdk) · [한국어](#한국어)
|
|
4
|
+
|
|
5
|
+
Stream your robot onto the Loop **Source Bus** with one class — `connect`,
|
|
6
|
+
`send` each tick, `disconnect`.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from loop_sdk import RobotStepSender, RobotConfig, RobotConfigOptions
|
|
12
|
+
|
|
13
|
+
control_hz = 20 # the control rate; updated when Loop negotiates one below
|
|
14
|
+
|
|
15
|
+
# At Open, Loop picks a config from `options`; apply it to your robot and confirm.
|
|
16
|
+
# (robot-control-interface assigns this to env.control_hz.)
|
|
17
|
+
def apply_robot_config(cfg: RobotConfig) -> RobotConfig | None:
|
|
18
|
+
global control_hz
|
|
19
|
+
control_hz = cfg.control_hz
|
|
20
|
+
return cfg
|
|
21
|
+
|
|
22
|
+
# loop_addr defaults to the LOOP_ADDR env var (else localhost:50051),
|
|
23
|
+
# source_id to "robot-step". Advertise the configs you can open with.
|
|
24
|
+
sender = RobotStepSender(
|
|
25
|
+
options=RobotConfigOptions(control_hz=(10, 20, 30), action_space=("target_cartesian_delta",)),
|
|
26
|
+
apply_config=apply_robot_config,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
sender.connect()
|
|
30
|
+
while running: # your control loop
|
|
31
|
+
t_capture_us = time.time_ns() // 1_000 # capture time, epoch microseconds
|
|
32
|
+
# A nested dict — the SDK flattens it to dotted channel keys. The FIRST send
|
|
33
|
+
# fixes the layout, so this first frame must be complete. A numeric list is ONE
|
|
34
|
+
# vector channel (robot0.action.joint_position); build key[0], key[1], … yourself
|
|
35
|
+
# for per-index scalar channels.
|
|
36
|
+
sender.send(t_capture_us, {
|
|
37
|
+
"robot0": {
|
|
38
|
+
"observation": {"robot_state": {"joint_positions": [0.12, -0.34, 1.57], "gripper": 0.80}},
|
|
39
|
+
"action": {"joint_position": [0.13, -0.33, 1.55]},
|
|
40
|
+
"policy_action": [0.01, -0.02, 0.00], # or None when teleop is driving
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
wait_for_next_tick(control_hz) # your loop's rate control — send does not pace
|
|
44
|
+
sender.disconnect() # on shutdown (not for pauses)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`RobotStepSender` owns the connection, sequence numbering, config validation,
|
|
48
|
+
retries, and reconnection. The **first `send` declares the layout** from its keys —
|
|
49
|
+
no separate step — so make the first frame complete (a channel you'll only
|
|
50
|
+
*sometimes* have should still appear, as `None`). A key you omit — or set to
|
|
51
|
+
`None` — means "no reading this tick". At Open the SDK validates Loop's config
|
|
52
|
+
selection against your `options` and calls `apply_config`. `send` never blocks and
|
|
53
|
+
**does not pace your loop** — your control loop owns its cadence (the negotiated
|
|
54
|
+
`control_hz`); the SDK just streams. The operator — not you — starts and stops
|
|
55
|
+
recording.
|
|
56
|
+
|
|
57
|
+
Want the config negotiated *before* you start sending (e.g. apply `control_hz`
|
|
58
|
+
first)? Call `sender.declare(channels)` once after `connect()`.
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install loop-sdk # Python ≥ 3.10
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
[`examples/`](examples/): `quickstart.py` (start here) · `robot_step_producer.py`
|
|
69
|
+
(full robot-control-interface producer + config negotiation) ·
|
|
70
|
+
`policy_action_consumer.py` (read actions back out).
|
|
71
|
+
|
|
72
|
+
## Lower level
|
|
73
|
+
|
|
74
|
+
`RobotStepSender` is robot-only and opinionated. For per-channel metadata
|
|
75
|
+
(`role` / `unit` / `rot_type`) or non-robot source kinds (tactile, marker), use
|
|
76
|
+
`loop_sdk.SourceProducer` / `SourceConsumer` directly — the high-level sender is
|
|
77
|
+
built on them. Internals and dev setup are in [`docs/`](docs/).
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
<a id="한국어"></a>
|
|
82
|
+
|
|
83
|
+
# loop-sdk (한국어)
|
|
84
|
+
|
|
85
|
+
[English](#loop-sdk) · [한국어](#한국어)
|
|
86
|
+
|
|
87
|
+
로봇을 Loop **Source Bus**로 스트리밍 — 클래스 하나로 `connect`, 매 tick `send`, `disconnect`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import time
|
|
91
|
+
|
|
92
|
+
from loop_sdk import RobotStepSender, RobotConfig, RobotConfigOptions
|
|
93
|
+
|
|
94
|
+
control_hz = 20 # 제어율; 아래에서 Loop가 협상하면 갱신됨
|
|
95
|
+
|
|
96
|
+
# Open 시 Loop가 `options` 중 하나를 고름 → 로봇에 적용하고 확인 반환.
|
|
97
|
+
# (robot-control-interface는 이를 env.control_hz에 대입.)
|
|
98
|
+
def apply_robot_config(cfg: RobotConfig) -> RobotConfig | None:
|
|
99
|
+
global control_hz
|
|
100
|
+
control_hz = cfg.control_hz
|
|
101
|
+
return cfg
|
|
102
|
+
|
|
103
|
+
# loop_addr 기본값 = LOOP_ADDR 환경변수(없으면 localhost:50051), source_id 기본값 = "robot-step".
|
|
104
|
+
# 열 수 있는 config들을 광고.
|
|
105
|
+
sender = RobotStepSender(
|
|
106
|
+
options=RobotConfigOptions(control_hz=(10, 20, 30), action_space=("target_cartesian_delta",)),
|
|
107
|
+
apply_config=apply_robot_config,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
sender.connect()
|
|
111
|
+
while running: # 네 제어 루프
|
|
112
|
+
t_capture_us = time.time_ns() // 1_000 # 캡처 시각, epoch 마이크로초
|
|
113
|
+
# 중첩 dict → SDK가 점-키로 평탄화. 첫 send가 레이아웃을 고정하므로 첫 프레임은 완전해야.
|
|
114
|
+
# 숫자 리스트는 벡터 채널 하나(robot0.action.joint_position); per-index 스칼라 채널을
|
|
115
|
+
# 원하면 key[0], key[1], … 를 직접 만들어 넣을 것.
|
|
116
|
+
sender.send(t_capture_us, {
|
|
117
|
+
"robot0": {
|
|
118
|
+
"observation": {"robot_state": {"joint_positions": [0.12, -0.34, 1.57], "gripper": 0.80}},
|
|
119
|
+
"action": {"joint_position": [0.13, -0.33, 1.55]},
|
|
120
|
+
"policy_action": [0.01, -0.02, 0.00], # teleop이 몰면 None
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
wait_for_next_tick(control_hz) # 네 루프의 rate control — send는 페이싱 안 함
|
|
124
|
+
sender.disconnect() # 종료 시에만 (일시정지엔 금지)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`RobotStepSender`가 연결·시퀀스 번호·config 검증·재시도·재연결을 떠안습니다. **첫 `send`가
|
|
128
|
+
키에서 레이아웃을 선언**(별도 단계 없음)하므로 첫 프레임은 완전해야 합니다(가끔만 있는 채널도
|
|
129
|
+
`None`으로 포함). 키를 빼거나 `None`으로 두면 "이번 tick 무판독"입니다. Open 시 SDK가 Loop의
|
|
130
|
+
config 선택을 `options`에 대조 검증하고 `apply_config`를 호출합니다. `send`는 블로킹하지 않고
|
|
131
|
+
**루프를 페이싱하지도 않습니다** — 케이던스는 네 제어 루프 몫(협상된 `control_hz`)이고 SDK는
|
|
132
|
+
스트리밍만 합니다. 녹화 시작·정지는 네가 아니라 operator가 합니다.
|
|
133
|
+
|
|
134
|
+
스트리밍 *전에* config를 협상하고 싶다면(예: `control_hz`를 먼저 적용) `connect()` 뒤에
|
|
135
|
+
`sender.declare(channels)`를 한 번 호출하세요.
|
|
136
|
+
|
|
137
|
+
## 설치
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install loop-sdk # Python ≥ 3.10
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 예제
|
|
144
|
+
|
|
145
|
+
[`examples/`](examples/): `quickstart.py`(여기서 시작) · `robot_step_producer.py`(전체
|
|
146
|
+
robot-control-interface 생산자 + config 협상) · `policy_action_consumer.py`(액션 되읽기).
|
|
147
|
+
|
|
148
|
+
## 저수준
|
|
149
|
+
|
|
150
|
+
`RobotStepSender`는 로봇 전용이고 의견이 강합니다. 채널별 메타(`role`/`unit`/`rot_type`)나
|
|
151
|
+
비로봇 소스 종류(tactile, marker)가 필요하면 `loop_sdk.SourceProducer` / `SourceConsumer`를
|
|
152
|
+
직접 쓰세요 — 고수준 sender가 그 위에 얹혀 있습니다. 내부 구조와 개발 셋업은 [`docs/`](docs/).
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Robot data model — real examples & visualizer
|
|
2
|
+
|
|
3
|
+
Investigation backing `docs/spec_discrepancies.md` §A: what an SDK should stream
|
|
4
|
+
per tick (data plane / A1) vs declare once (control plane / A2). Grounded in
|
|
5
|
+
**real episodes** from three robot classes — no synthetic data.
|
|
6
|
+
|
|
7
|
+
## Source episodes (real)
|
|
8
|
+
|
|
9
|
+
Full originals stored outside this repo at
|
|
10
|
+
`/home/suchae/inference_workspace/product/recorder_data_samples/`
|
|
11
|
+
(override with the `RECORDER_DATA_SAMPLES` env var):
|
|
12
|
+
|
|
13
|
+
| dataset | format | rows | robot | DOF | notes |
|
|
14
|
+
|---|---|---|---|---|---|
|
|
15
|
+
| `franka/` | recorder parquet | 909 | `franka` (`y_frame`, robotiq) | 7×2 | from `s3://configint-raw/recorder/v2.8.9/…/17/00` |
|
|
16
|
+
| `vega/` | recorder parquet | 1125 | `vega-1-pro` (torso_frame) | 7×2 | from `s3://configint-raw/recorder/2.8.1/…/02/00` |
|
|
17
|
+
| `unitree_g1/sonic-sample-data/` | LeRobot v2.1 / GR00T | 8697/5084 | `unitree_g1` humanoid | 43 | G1 + "sonic" teleop, built dataset |
|
|
18
|
+
|
|
19
|
+
- **recorder** episodes: `data.parquet` (flat dotted columns from
|
|
20
|
+
`recorder/state_action_recorder.py`'s `pd.json_normalize`) + `tasks.jsonl`
|
|
21
|
+
session metadata. Names *fields* (`joint_positions[7]`), no per-scalar names, no
|
|
22
|
+
units, no group/rotation metadata.
|
|
23
|
+
- **LeRobot/GR00T** episode: per-feature vectors + `meta/info.json`
|
|
24
|
+
(per-dim names, dtype, shape), `meta/modality.json` (named vector-slice groups +
|
|
25
|
+
`rotation_type`), `meta/episodes_stats.jsonl` (per-dim min/max/mean/std). This
|
|
26
|
+
format **already ships** the §A2 metadata, so it anchors the v2 proposal.
|
|
27
|
+
|
|
28
|
+
## Files here
|
|
29
|
+
|
|
30
|
+
- `real_examples.json` — extracted example set (real values only): per dataset,
|
|
31
|
+
the source metadata + a unified channel list (each channel tagged with the
|
|
32
|
+
**provenance** of every attribute) + subsampled per-tick real values.
|
|
33
|
+
- `visualizer.html` — self-contained, data embedded inline. Open in a browser.
|
|
34
|
+
- `extract_real_examples.py` — reads the three episodes from
|
|
35
|
+
`recorder_data_samples/` and writes `real_examples.json`.
|
|
36
|
+
- `build_visualizer.py` — embeds `real_examples.json` into `visualizer.html`.
|
|
37
|
+
|
|
38
|
+
## Reproduce
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python3 extract_real_examples.py # reads recorder_data_samples/{franka,vega,unitree_g1}
|
|
42
|
+
python3 build_visualizer.py # embeds real_examples.json into visualizer.html
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## What the visualizer shows
|
|
46
|
+
|
|
47
|
+
Three robots in one unified channel table. Each row = one real scalar channel; the
|
|
48
|
+
left block is **real source data**, the right block is the **channel descriptor**,
|
|
49
|
+
and every descriptor cell is colour-coded by provenance:
|
|
50
|
+
|
|
51
|
+
- **teal `source`** — read from the dataset's own metadata (real). G1 supplies
|
|
52
|
+
names, groups, rotation types, and ranges this way.
|
|
53
|
+
- **amber `inferred`** — our heuristic annotation, *not* in the source (e.g. units
|
|
54
|
+
for Franka/Vega, which carry none).
|
|
55
|
+
- **faint `none` / `?`** — the source does not provide it at all.
|
|
56
|
+
|
|
57
|
+
Controls: robot (Franka/Vega/G1) → source (robot0/robot1 or g1) → tick scrub/play.
|
|
58
|
+
A top panel states the **proposed transfer protocol (v2)**; the summary line counts
|
|
59
|
+
how many channel attributes each dataset supplies from source vs inferred — making
|
|
60
|
+
the gap between the recorder format and the GR00T format explicit.
|
|
61
|
+
|
|
62
|
+
> Honesty note: values and `source field` are always real. For Franka/Vega the
|
|
63
|
+
> per-scalar name, group, rotation, and unit are our proposal (the source has
|
|
64
|
+
> none); for G1 most of them are real (read from `info.json` / `modality.json` /
|
|
65
|
+
> `episodes_stats.jsonl`). G1's `eef_state` ships 4 names for 14 dims — partial
|
|
66
|
+
> name lists are marked untrusted, which is why the spec requires `len(names) == shape`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Build a focused, toggle-able §A2 robot descriptor viewer (descriptor.html).
|
|
2
|
+
|
|
3
|
+
Pick a robot (Franka / Vega / G1) and see its full proposed §A2 control-plane
|
|
4
|
+
descriptor — groups + role + per-channel name/unit/rot_type/range — built from the
|
|
5
|
+
real per-robot data in real_examples.json. Value colour = provenance
|
|
6
|
+
(source vs inferred). Self-contained (data embedded inline).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
HERE = Path(__file__).parent
|
|
14
|
+
DATA = (HERE / "real_examples.json").read_text()
|
|
15
|
+
|
|
16
|
+
HTML = r"""<title>Robot descriptor (§A2) — Franka · Vega · G1</title>
|
|
17
|
+
<style>
|
|
18
|
+
:root{--bg:#0f1115;--panel:#171a21;--panel2:#1d212b;--line:#2a2f3a;--ink:#e6e9ef;--mut:#9aa3b2;--faint:#6b7384;
|
|
19
|
+
--accent:#7aa2f7;--key:#2dd4bf;--prop:#f5a524;--num:#bb9af7;--warn:#f7768e}
|
|
20
|
+
*{box-sizing:border-box}
|
|
21
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.6 ui-sans-serif,system-ui,-apple-system,"Apple SD Gothic Neo","Malgun Gothic",sans-serif}
|
|
22
|
+
.wrap{max-width:1080px;margin:0 auto;padding:22px 18px 60px}
|
|
23
|
+
h1{font-size:21px;margin:0 0 4px} .sub{color:var(--mut);margin:0 0 14px;font-size:13px}
|
|
24
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
|
25
|
+
.robtabs{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap}
|
|
26
|
+
.robtabs button{background:var(--panel);color:var(--mut);border:1px solid var(--line);border-radius:10px;padding:8px 16px;cursor:pointer;font-size:14px;font-weight:600}
|
|
27
|
+
.robtabs button.on{background:var(--accent);color:#0f1115;border-color:var(--accent)}
|
|
28
|
+
.robtabs .meta{margin-left:auto;color:var(--faint);font-size:12px;align-self:center}
|
|
29
|
+
.note{background:#1a1d25;border:1px solid var(--line);border-left:3px solid var(--prop);border-radius:8px;padding:9px 12px;font-size:12.5px;color:var(--mut);margin:0 0 12px}
|
|
30
|
+
.note b{color:var(--ink)} .note .s{color:var(--key)} .note .prop{color:var(--prop)}
|
|
31
|
+
pre.msg{background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow:auto;font-size:12px;max-height:62vh;margin:0 0 14px}
|
|
32
|
+
.msg .k{color:var(--mut)} .msg .c{color:var(--faint)} .msg .p{color:#565f70}
|
|
33
|
+
.msg .kk{color:var(--ink);font-weight:700} .msg .s{color:var(--key)} .msg .prop{color:var(--prop)}
|
|
34
|
+
.lbl{font-size:12px;color:var(--faint);font-weight:700;text-transform:uppercase;letter-spacing:.04em;margin:14px 0 6px}
|
|
35
|
+
.reqtbl{border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
36
|
+
.reqrow{display:grid;grid-template-columns:96px 96px 1fr;gap:10px;align-items:center;padding:5px 12px;border-bottom:1px solid #20242e;font-size:12.5px}
|
|
37
|
+
.reqrow:last-child{border-bottom:0}
|
|
38
|
+
.rqb{font-size:10.5px;font-weight:700;text-align:center;padding:2px 0;border-radius:6px}
|
|
39
|
+
.rqb.must{background:#3a1620;color:#f7768e} .rqb.cond{background:#2a1d05;color:#f5a524} .rqb.opt{background:#20242e;color:#9aa3b2}
|
|
40
|
+
.rqf{font-family:ui-monospace,Menlo,monospace;color:var(--ink)} .rqd{color:var(--mut)}
|
|
41
|
+
.footnote{color:var(--faint);font-size:12px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px}
|
|
42
|
+
.footnote code{color:var(--mut)}
|
|
43
|
+
</style>
|
|
44
|
+
<div class="wrap">
|
|
45
|
+
<h1>Robot descriptor (§A2) — control-plane blueprint</h1>
|
|
46
|
+
<p class="sub">소스가 <code>Describe</code> 때 한 번 선언하는 <b>설계도</b>의 §A2 제안형 — <b>실제 자료구조(JSON) 그대로</b>입니다. <code>channels</code>는 객체의 배열이고, <b>배열 순서가 곧 <code>values[]</code> 인덱스</b>(우측 <code>// [i]</code>). 필드명은 흰색, <b>값 색이 출처</b>를 뜻합니다: <span class="s">청록=소스 메타에서 옴(예: key=실제 컬럼명)</span> / <span class="prop">주황=우리 추론(예: unit)</span>. null인 필드(rot_type/group/range)는 객체에서 생략됩니다. (색은 표시용일 뿐 데이터에 <code>*_src</code> 같은 필드는 없습니다.)</p>
|
|
47
|
+
<div class="robtabs" id="robtabs"></div>
|
|
48
|
+
<div class="note" style="border-left-color:var(--key)"><b>핵심 원칙 — 이름은 무손실(verbatim).</b> 채널 이름은 클라이언트가 보낸 컬럼명 <b>그대로</b> 저장됩니다(예: <code>robot0.action.state.joint_positions[3]</code>). SDK가 중간을 깎지 않습니다(<code>.state.</code> 제거 금지) — 원본 복구가 보장돼야 하니까요. <b>observation/action 구분은 이름에 이미 있으니 거기엔 role을 안 씁니다.</b> 대신 <b>role</b>은 <b>로봇 경계를 실제로 넘었는지</b>로 2가지로 나눕니다 — <code>robot</code>(로봇에서 나온 측정 상태 또는 로봇에 실제 전달된 명령) / <code>aux</code>(그 외 곁다리: 실행 안 된 후보 액션·action_source·inference latency·prev_·success 등). 판별: "이 값이 로봇으로 실제 들어갔거나 로봇에서 나왔나?" 이름만으론 모르니 가치 있는 부가 필드입니다. 그 외 이름으로 복구 불가라 유용한 건 <b>unit·rot_type</b>. <code>group</code>은 선택적 묶음 태그. <b>role·rot_type은 닫힌 집합(proto enum)</b>이라 정해진 값 외엔 거부됩니다(role: robot/aux, rot_type: none/quaternion/euler/rotation_6d). <b>unit은 권장 집합 + 그 외는 경고</b>(단위는 무한하므로). 중복·불필요 채널(예: <code>action.state.*</code>가 <code>observation.state.*</code>와 동일, 한 명령의 여러 표현)은 <b>권고로 줄이되 강제로 제거하지 않습니다</b> — 클라이언트가 로깅 목적으로 보낼 수 있으니까요.</div>
|
|
49
|
+
<div class="note"><b>rot_type</b>은 3D 방향(orientation) 인코딩 표시입니다 — 사원수/오일러/6D. 1-DOF 관절각 같은 <b>스칼라 각도엔 none</b>(unit=rad만으로 충분), EE roll/pitch/yaw처럼 3채널이 한 방향이면 <code>euler</code>.</div>
|
|
50
|
+
<pre class="msg" id="desc"></pre>
|
|
51
|
+
<div class="lbl">필드별 필수도</div>
|
|
52
|
+
<div class="reqtbl">
|
|
53
|
+
<div class="reqrow"><span class="rqb must">필수</span><span class="rqf">name</span><span class="rqd">클라이언트 컬럼명 그대로(verbatim). observation/action 구분도 이름에 들어있음</span></div>
|
|
54
|
+
<div class="reqrow"><span class="rqb opt">선택</span><span class="rqf">role</span><span class="rqd">로봇 경계 기준 2분류: robot(로봇에 실제 입출력된 신호) / aux(그 외 곁다리: 미실행 후보 액션·action_source·latency·진단). 닫힌 enum</span></div>
|
|
55
|
+
<div class="reqrow"><span class="rqb must">필수</span><span class="rqf">unit</span><span class="rqd">rad · m · normalized … 한 벡터에 단위가 섞임</span></div>
|
|
56
|
+
<div class="reqrow"><span class="rqb cond">조건부 필수</span><span class="rqf">rot_type</span><span class="rqd">회전(quaternion/euler/rotation_6d) 채널일 때만. 아니면 none</span></div>
|
|
57
|
+
<div class="reqrow"><span class="rqb opt">선택</span><span class="rqf">range</span><span class="rqd">정규화·검증·이상치 (관측 min/max)</span></div>
|
|
58
|
+
<div class="reqrow"><span class="rqb opt">선택</span><span class="rqf">group</span><span class="rqd">left_leg 등 의미 묶음. 휴머노이드 슬라이싱</span></div>
|
|
59
|
+
<div class="reqrow"><span class="rqb opt">선택</span><span class="rqf">dtype</span><span class="rqd">wire는 double. int/bool/enum일 때만</span></div>
|
|
60
|
+
</div>
|
|
61
|
+
<p class="footnote">실제 에피소드(프랑카·베가=recorder, G1=LeRobot/GR00T)에서 추출. unit/rot_type은 best-effort 추론이며 <b>실제 계약은 클라이언트가 선언</b>합니다. <code>docs/spec_discrepancies.md</code> §A2.</p>
|
|
62
|
+
</div>
|
|
63
|
+
<script id="data" type="application/json">__DATA__</script>
|
|
64
|
+
<script>
|
|
65
|
+
const DS = JSON.parse(document.getElementById('data').textContent).datasets;
|
|
66
|
+
const ROBOTS = Object.keys(DS);
|
|
67
|
+
const KOR = { franka:"프랑카", vega:"베가", g1:"G1 (휴머노이드)" };
|
|
68
|
+
const st = { rob: ROBOTS[0] };
|
|
69
|
+
const src = () => { const s=DS[st.rob].sources; return s[Object.keys(s)[0]]; };
|
|
70
|
+
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<');
|
|
71
|
+
|
|
72
|
+
function groups(){ const ch=src().channels; const gs=[]; let cur=null;
|
|
73
|
+
ch.forEach((c,i)=>{ const gk=c.group||c.source_field;
|
|
74
|
+
if(!cur||cur.gk!==gk){cur={gk,label:(c.group||c.source_field),items:[]}; gs.push(cur);} cur.items.push({c,i}); });
|
|
75
|
+
return gs; }
|
|
76
|
+
const pv = (val,srcTag)=>{ if(val===null||val===undefined||val==="") return `<span class="c">none</span>`;
|
|
77
|
+
return `<span class="${srcTag==="source"?"s":"prop"}">${esc(val)}</span>`; };
|
|
78
|
+
|
|
79
|
+
// 실제 자료구조(JSON) 그대로 렌더. 필드명=흰색, 값 색=출처(청록 source / 주황 inferred).
|
|
80
|
+
// null인 필드(rot_type/group/range)는 객체에서 생략(= proto의 unset).
|
|
81
|
+
function descriptor(){
|
|
82
|
+
const ch=src().channels; const m=DS[st.rob].meta||{}; const L=[];
|
|
83
|
+
const q = s => `"${esc(s)}"`;
|
|
84
|
+
const cv = (txt, srcTag) => `<span class="${srcTag==="source"?"s":"prop"}">${txt}</span>`;
|
|
85
|
+
L.push(`<span class="p">{</span>`);
|
|
86
|
+
L.push(` <span class="kk">"robot_class"</span>: ${cv(q(m.robot_type||st.rob), m.robot_type?"source":"inf")},`);
|
|
87
|
+
if(m.control_frequency||m.fps) L.push(` <span class="kk">"control_hz"</span>: ${cv(m.control_frequency||m.fps,"source")},`);
|
|
88
|
+
L.push(` <span class="kk">"channels"</span>: [ <span class="c">// ${ch.length}개 · 배열 순서 = values[] 인덱스</span>`);
|
|
89
|
+
ch.forEach((c,i)=>{
|
|
90
|
+
const parts=[];
|
|
91
|
+
parts.push(`<span class="kk">"key"</span>: ${cv(q(c.name||c.key),"source")}`);
|
|
92
|
+
if(c.role) parts.push(`<span class="kk">"role"</span>: ${cv(q(c.role), c.role_src==="source"?"source":"inf")}`);
|
|
93
|
+
parts.push(`<span class="kk">"unit"</span>: ${cv(q(c.unit), c.unit_src==="source"?"source":"inf")}`);
|
|
94
|
+
if(c.rotation_type) parts.push(`<span class="kk">"rot_type"</span>: ${cv(q(c.rotation_type), c.rotation_src==="source"?"source":"inf")}`);
|
|
95
|
+
if(c.group && c.group_src==="source") parts.push(`<span class="kk">"group"</span>: ${cv(q(c.group),"source")}`);
|
|
96
|
+
if(c.range) parts.push(`<span class="kk">"range"</span>: ${cv("["+c.range.map(x=>typeof x==="number"?(+x.toFixed(2)):x).join(", ")+"]", c.range_src==="source"?"source":"inf")}`);
|
|
97
|
+
const comma = i < ch.length-1 ? "," : "";
|
|
98
|
+
L.push(` {${parts.join(", ")}}${comma} <span class="c">// [${i}]</span>`);
|
|
99
|
+
});
|
|
100
|
+
L.push(` ]`); L.push(`<span class="p">}</span>`);
|
|
101
|
+
return L.join("\n");
|
|
102
|
+
}
|
|
103
|
+
function render(){
|
|
104
|
+
document.getElementById('robtabs').innerHTML =
|
|
105
|
+
ROBOTS.map(r=>`<button data-r="${r}" class="${r===st.rob?'on':''}">${KOR[r]||r}</button>`).join("")
|
|
106
|
+
+ `<span class="meta">${esc(DS[st.rob].label)} · ${src().channels.length} ch</span>`;
|
|
107
|
+
for(const b of document.querySelectorAll('#robtabs button')) b.onclick=()=>{ st.rob=b.dataset.r; render(); };
|
|
108
|
+
document.getElementById('desc').innerHTML = descriptor();
|
|
109
|
+
}
|
|
110
|
+
render();
|
|
111
|
+
</script>
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
out = HERE / "descriptor.html"
|
|
115
|
+
out.write_text(HTML.replace("__DATA__", DATA))
|
|
116
|
+
print(f"wrote {out} ({out.stat().st_size} bytes)")
|