pyrobostudio-sidecar 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.
Files changed (28) hide show
  1. pyrobostudio_sidecar-0.1.0/PKG-INFO +32 -0
  2. pyrobostudio_sidecar-0.1.0/README.md +12 -0
  3. pyrobostudio_sidecar-0.1.0/pyproject.toml +33 -0
  4. pyrobostudio_sidecar-0.1.0/pyproject.toml.orig +32 -0
  5. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/__init__.py +5 -0
  6. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/fixtures_env.py +50 -0
  7. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/lerobot.py +115 -0
  8. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/make_fixture.py +255 -0
  9. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/mission.py +85 -0
  10. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/networkintel.py +112 -0
  11. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/recorded.py +104 -0
  12. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/repos.py +199 -0
  13. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/ros2bag.py +219 -0
  14. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/server.py +182 -0
  15. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/spikes/test_foxglove_handshake.py +78 -0
  16. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/syncfleet.py +91 -0
  17. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/terrain_fixture.py +36 -0
  18. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_adapters.py +97 -0
  19. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_env_adapters.py +117 -0
  20. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_lerobot.py +127 -0
  21. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_mission.py +75 -0
  22. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_repos_api.py +80 -0
  23. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_ros2bag.py +114 -0
  24. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_security.py +97 -0
  25. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/test_server.py +117 -0
  26. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/vision_tracks.py +63 -0
  27. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/worldsim.py +61 -0
  28. pyrobostudio_sidecar-0.1.0/src/pyrobostudio_sidecar/ws_transport.py +77 -0
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyrobostudio-sidecar
3
+ Version: 0.1.0
4
+ Summary: PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs
5
+ Author: Georgi Mullassery
6
+ Author-email: Georgi Mullassery <mullassery@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: fastapi>=0.162.0
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: mcap>=1.6.0
11
+ Requires-Dist: mcap-ros2-support>=0.6.0
12
+ Requires-Dist: foxglove-sdk>=0.30
13
+ Requires-Dist: websockets>=17.0
14
+ Requires-Dist: uvicorn[standard]>=0.54.0
15
+ Requires-Dist: h3>=4.5.0
16
+ Requires-Dist: rosbags>=0.11.9
17
+ Requires-Dist: pyarrow>=26.0.1
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pyrobostudio-sidecar
22
+
23
+ Python data sidecar for [PyRoboStudio](https://github.com/Mullassery/PyRoboStudio):
24
+ a read-only FastAPI service that serves recorded robotics sessions, adapter views
25
+ of PyRobo* repositories, mission manifests, and a Foxglove WebSocket transport.
26
+
27
+ ```sh
28
+ uvx pyrobostudio-sidecar # serves 127.0.0.1:8787
29
+ ```
30
+
31
+ Read-only by design: the sidecar never publishes to robots or writes to source
32
+ repositories. See the main repository for architecture, security model, and docs.
@@ -0,0 +1,12 @@
1
+ # pyrobostudio-sidecar
2
+
3
+ Python data sidecar for [PyRoboStudio](https://github.com/Mullassery/PyRoboStudio):
4
+ a read-only FastAPI service that serves recorded robotics sessions, adapter views
5
+ of PyRobo* repositories, mission manifests, and a Foxglove WebSocket transport.
6
+
7
+ ```sh
8
+ uvx pyrobostudio-sidecar # serves 127.0.0.1:8787
9
+ ```
10
+
11
+ Read-only by design: the sidecar never publishes to robots or writes to source
12
+ repositories. See the main repository for architecture, security model, and docs.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "pyrobostudio-sidecar"
3
+ version = "0.1.0"
4
+ description = "PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs"
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ dependencies = [
9
+ "fastapi>=0.162.0",
10
+ "httpx>=0.28.1",
11
+ "mcap>=1.6.0",
12
+ "mcap-ros2-support>=0.6.0",
13
+ "foxglove-sdk>=0.30",
14
+ "websockets>=17.0",
15
+ "uvicorn[standard]>=0.54.0",
16
+ "h3>=4.5.0",
17
+ "rosbags>=0.11.9",
18
+ "pyarrow>=26.0.1",
19
+ ]
20
+
21
+ [[project.authors]]
22
+ name = "Georgi Mullassery"
23
+ email = "mullassery@gmail.com"
24
+
25
+ [project.scripts]
26
+ pyrobostudio-sidecar = "pyrobostudio_sidecar:main"
27
+
28
+ [dependency-groups]
29
+ dev = ["pytest>=9.2.0"]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.13.0,<0.14.0"]
33
+ build-backend = "uv_build"
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "pyrobostudio-sidecar"
3
+ version = "0.1.0"
4
+ description = "PyRoboStudio data sidecar: recorded-session serving, capabilities, mission/time APIs"
5
+ authors = [
6
+ { name = "Georgi Mullassery", email = "mullassery@gmail.com" }
7
+ ]
8
+ requires-python = ">=3.12"
9
+ license = "MIT"
10
+ readme = "README.md"
11
+ dependencies = [
12
+ "fastapi>=0.162.0",
13
+ "httpx>=0.28.1",
14
+ "mcap>=1.6.0",
15
+ "mcap-ros2-support>=0.6.0",
16
+ "foxglove-sdk>=0.30",
17
+ "websockets>=17.0",
18
+ "uvicorn[standard]>=0.54.0",
19
+ "h3>=4.5.0",
20
+ "rosbags>=0.11.9",
21
+ "pyarrow>=26.0.1",
22
+ ]
23
+
24
+ [project.scripts]
25
+ pyrobostudio-sidecar = "pyrobostudio_sidecar:main"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=9.2.0"]
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.13.0,<0.14.0"]
32
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ def main() -> None:
2
+ """Console entry point: run the sidecar server on 127.0.0.1:8787."""
3
+ import uvicorn
4
+
5
+ uvicorn.run("pyrobostudio_sidecar.server:app", host="127.0.0.1", port=8787)
@@ -0,0 +1,50 @@
1
+ """Environment-test fixtures for networkintel + syncfleet adapters (verbatim upstream DDL subsets)."""
2
+ import sqlite3
3
+ from pathlib import Path
4
+
5
+ NI_DDL = [
6
+ "CREATE TABLE scan_sessions (id INTEGER PRIMARY KEY, scan_time DATETIME NOT NULL, duration_seconds REAL, target VARCHAR(255), device_count INTEGER DEFAULT 0, finding_count INTEGER DEFAULT 0, status VARCHAR(50) DEFAULT 'completed', error_message TEXT)",
7
+ "CREATE TABLE devices (id INTEGER PRIMARY KEY, scan_id INTEGER REFERENCES scan_sessions(id), ip VARCHAR(45) NOT NULL, hostname VARCHAR(255), os VARCHAR(255), device_type VARCHAR(50) DEFAULT 'linux', is_online BOOLEAN DEFAULT 1, last_seen DATETIME NOT NULL, first_seen DATETIME NOT NULL)",
8
+ "CREATE TABLE services (id INTEGER PRIMARY KEY, device_id INTEGER REFERENCES devices(id), port INTEGER NOT NULL, name VARCHAR(100), version VARCHAR(255), protocol VARCHAR(10) DEFAULT 'tcp', state VARCHAR(50) DEFAULT 'open', first_seen DATETIME, last_seen DATETIME)",
9
+ "CREATE TABLE findings (id INTEGER PRIMARY KEY, scan_id INTEGER REFERENCES scan_sessions(id), device_ip VARCHAR(45), finding_type VARCHAR(50), severity VARCHAR(20), title VARCHAR(500), description TEXT, evidence TEXT, recommendation TEXT, business_impact TEXT, cve_id VARCHAR(50), cvss_score REAL, exploit_available BOOLEAN DEFAULT 0, found_at DATETIME, resolved_at DATETIME)",
10
+ ]
11
+
12
+ SF_DDL = [
13
+ "CREATE TABLE operation_journal (id INTEGER PRIMARY KEY, dataset_id INTEGER REFERENCES datasets(id), entity_id VARCHAR(255), origin_node_id VARCHAR(255), origin_seq INTEGER NOT NULL, op_type INTEGER, schema_version INTEGER, payload BLOB, causal_metadata BLOB, created_at DATETIME, checksum BLOB)",
14
+ "CREATE TABLE replication_checkpoints (id INTEGER PRIMARY KEY, peer_id VARCHAR(255), dataset_id INTEGER, last_acked_seq INTEGER, last_applied_seq INTEGER, updated_at DATETIME)",
15
+ "CREATE TABLE conflicts (id INTEGER PRIMARY KEY, operation_id VARCHAR(255), local_operation_id VARCHAR(255), remote_operation_id VARCHAR(255), conflict_type INTEGER, detected_at DATETIME, resolved_at DATETIME, resolution TEXT)",
16
+ "CREATE TABLE datasets (id INTEGER PRIMARY KEY, dataset_id VARCHAR(255), schema_version INTEGER, conflict_policy VARCHAR(50), created_at DATETIME)",
17
+ ]
18
+
19
+
20
+ def make_ni_db(path: Path) -> dict:
21
+ con = sqlite3.connect(path)
22
+ for ddl in NI_DDL:
23
+ con.execute(ddl)
24
+ con.execute("INSERT INTO scan_sessions VALUES (1, '2026-11-01 10:00:00', 4.2, '192.168.1.0/24', 2, 1, 'completed', NULL)")
25
+ con.execute("INSERT INTO devices VALUES (1, 1, '192.168.1.2', 'robot-1', 'Ubuntu', 'linux', 1, '2026-11-01', '2026-11-01')")
26
+ con.execute("INSERT INTO devices VALUES (2, 1, '192.168.1.3', 'robot-2', 'Ubuntu', 'linux', 0, '2026-11-01', '2026-11-01')")
27
+ con.execute("INSERT INTO services VALUES (1, 1, 2224, 'ros2-dds', NULL, 'udp', 'open', '2026-11-01', '2026-11-01')")
28
+ con.execute("INSERT INTO services VALUES (2, 1, 22, 'ssh', 'OpenSSH_9.6', 'tcp', 'open', '2026-11-01', '2026-11-01')")
29
+ con.execute("INSERT INTO findings VALUES (1, 1, '192.168.1.3', 'exposure', 'high', 'Open exploit reachable', 'nmap', 'svc', 'patch', 'disruption', 'CVE-9999-0001', 8.1, 1, '2026-11-01', NULL)")
30
+ con.commit()
31
+ con.close()
32
+ return {"scan_id": 1}
33
+
34
+
35
+ def make_sf_db(path: Path) -> dict:
36
+ con = sqlite3.connect(path)
37
+ for ddl in SF_DDL:
38
+ con.execute(ddl)
39
+ con.execute("INSERT INTO datasets VALUES (1, 'terrain-cells', 1, 'last-write-wins', '2026-11-01')")
40
+ for seq in range(1, 6):
41
+ con.execute(
42
+ "INSERT INTO operation_journal VALUES (?, 1, ?, 'nodeA', ?, 0, 1, ?, NULL, '2026-11-01 10:00:00', NULL)",
43
+ (seq, f"cell-{seq}", seq, b"payload"),
44
+ )
45
+ con.execute("INSERT INTO replication_checkpoints VALUES (1, 'nodeB', 1, 4, 4, '2026-11-01 10:01:00')")
46
+ con.execute("INSERT INTO replication_checkpoints VALUES (2, 'nodeC', 1, 3, 3, '2026-11-01 10:01:00')")
47
+ con.execute("INSERT INTO conflicts VALUES (1, 'op-x', 'op-x-local', 'op-x-remote', 0, '2026-11-01 10:02:00', NULL, NULL)")
48
+ con.commit()
49
+ con.close()
50
+ return {"peer_lagging": "nodeC", "lag": 2}
@@ -0,0 +1,115 @@
1
+ """LeRobotDataset v3.0 Parquet adapter — read-only.
2
+
3
+ Layout per docs/research/adapter-schemas.md §2 and verified against the
4
+ PyRoboFrames writer (lerobot.py). A directory without meta/info.json is
5
+ rejected rather than guessed (mirrors PyRoboFrames Rust reader behavior).
6
+ """
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import pyarrow.parquet as pq
11
+
12
+ INFO_REQUIRED = ["codebase_version", "robot_type", "fps", "total_episodes", "total_frames", "features"]
13
+
14
+
15
+ class DatasetError(ValueError):
16
+ pass
17
+
18
+
19
+ class LeRobotAdapter:
20
+ def __init__(self, root: Path) -> None:
21
+ self.root = root
22
+ info_path = root / "meta" / "info.json"
23
+ if not info_path.exists():
24
+ raise DatasetError(f"{root}: not a LeRobotDataset v3.0 (missing meta/info.json)")
25
+ info = json.loads(info_path.read_text())
26
+ missing = [k for k in INFO_REQUIRED if k not in info]
27
+ if missing:
28
+ raise DatasetError(f"{root}: meta/info.json missing keys {missing}")
29
+ if info["codebase_version"] != "v3.0":
30
+ raise DatasetError(f"{root}: codebase_version {info['codebase_version']!r} != v3.0")
31
+ self.info = info
32
+
33
+ def dataset_id(self) -> str:
34
+ return self.root.name
35
+
36
+ def episodes(self) -> list[dict]:
37
+ out: list[dict] = []
38
+ for chunk in sorted((self.root / "meta" / "episodes").glob("chunk-*/file-*.parquet")):
39
+ table = pq.read_table(chunk)
40
+ cols = {name: table.column(name).to_pylist() for name in table.column_names}
41
+ for i in range(table.num_rows):
42
+ out.append(
43
+ {
44
+ "episode_index": cols["episode_index"][i],
45
+ "length": cols["length"][i],
46
+ "dataset_from_index": cols["dataset_from_index"][i],
47
+ "dataset_to_index": cols["dataset_to_index"][i],
48
+ "data/chunk_index": cols["data/chunk_index"][i],
49
+ "data/file_index": cols["data/file_index"][i],
50
+ }
51
+ )
52
+ return out
53
+
54
+ def features(self) -> dict:
55
+ return self.info["features"]
56
+
57
+ def fps(self) -> int:
58
+ return int(self.info["fps"])
59
+
60
+ def steps(self, episode_index: int, max_steps: int = 64) -> list[dict]:
61
+ eps = self.episodes()
62
+ ep = next((e for e in eps if e["episode_index"] == episode_index), None)
63
+ if ep is None:
64
+ raise KeyError(f"unknown episode {episode_index}")
65
+ from_idx = ep["dataset_from_index"]
66
+ to_idx = min(ep["dataset_to_index"], from_idx + max_steps)
67
+ chunk = self.root / "data" / f"chunk-{ep['data/chunk_index']:03d}" / f"file-{ep['data/file_index']:03d}.parquet"
68
+ table = pq.read_table(chunk)
69
+ stats = self.stats()
70
+ cols = {name: table.column(name) for name in table.column_names}
71
+ # Shards store one row per step; each column value is the step's fixed-size vector.
72
+ def vec(name: str, row_i: int) -> list:
73
+ return cols[name][row_i].as_py()
74
+
75
+ out: list[dict] = []
76
+ for gidx in range(from_idx, to_idx):
77
+ row_i = gidx # one row per step in this layout
78
+ if row_i >= table.num_rows:
79
+ raise DatasetError(f"{self.root}: data shard has {table.num_rows} rows, need row {row_i}")
80
+ out.append(
81
+ {
82
+ "globalStep": gidx,
83
+ "episode_index": episode_index,
84
+ "features": {
85
+ name: vec(name, row_i) for name in cols
86
+ },
87
+ "stats": {name: stats.get(name, {}) for name in cols},
88
+ }
89
+ )
90
+ return out
91
+
92
+ def stats(self) -> dict:
93
+ stats_path = self.root / "meta" / "stats.json"
94
+ if not stats_path.exists():
95
+ raise DatasetError(f"{self.root}: missing meta/stats.json")
96
+ return json.loads(stats_path.read_text())
97
+
98
+ def summary(self) -> dict:
99
+ return {
100
+ "dataset_id": self.dataset_id(),
101
+ "codebase_version": self.info["codebase_version"],
102
+ "robot_type": self.info.get("robot_type"),
103
+ "fps": self.fps(),
104
+ "total_episodes": self.info["total_episodes"],
105
+ "total_frames": self.info["total_frames"],
106
+ "features": {name: f["shape"] for name, f in self.info["features"].items()},
107
+ }
108
+
109
+ def provenance(self) -> dict:
110
+ versions = {}
111
+ for name in (".versions.json", "manifest.json"):
112
+ p = self.root / name
113
+ if p.exists():
114
+ versions[name] = json.loads(p.read_text())
115
+ return versions
@@ -0,0 +1,255 @@
1
+ """Generate a deterministic MCAP fixture plus graph/event metadata for tests.
2
+ Run: uv run python -m pyrobostudio_sidecar.make_fixture
3
+ Output: sidecar/fixtures/sample_session.mcap, sample_events.json
4
+ """
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+ from mcap.writer import Writer
9
+ FIXTURE_DIR = Path(__file__).parent.parent.parent / "fixtures"
10
+ SPAN_NS = 10_000_000_000
11
+ STEP_NS = 200_000_000
12
+ CMDVEL_SCHEMA = "geometry_msgs/msg/Twist"
13
+ SCAN_SCHEMA = "sensor_msgs/msg/LaserScan"
14
+ def make_fixture() -> None:
15
+ FIXTURE_DIR.mkdir(exist_ok=True)
16
+ path = FIXTURE_DIR / "sample_session.mcap"
17
+ with open(path, "wb") as fp:
18
+ w = Writer(fp)
19
+ w.start(profile="ros2")
20
+ schema_cmdvel = w.register_schema(CMDVEL_SCHEMA, "jsonschema", b"{}")
21
+ schema_scan = w.register_schema(SCAN_SCHEMA, "jsonschema", b"{}")
22
+ ch_vel = w.register_channel("/robot1/cmd_vel", message_encoding="json", schema_id=schema_cmdvel)
23
+ ch_scan = w.register_channel("/robot1/scan", message_encoding="json", schema_id=schema_scan)
24
+ for i in range(SPAN_NS // STEP_NS):
25
+ t = i * STEP_NS
26
+ gap = 3 <= i <= 5
27
+ if not gap:
28
+ x = 1.0 + math.sin(i / 5) * 0.2
29
+ payload = json.dumps(
30
+ {"linear": {"x": round(x, 3), "y": 0.0, "z": 0.0},
31
+ "angular": {"x": 0.0, "y": 0.0, "z": round(math.sin(i / 7), 3)}}
32
+ ).encode()
33
+ w.add_message(ch_vel, log_time=t, publish_time=t, data=payload)
34
+ if i % 5 == 0:
35
+ payload = json.dumps(
36
+ {"capacity": 16, "ranges": [round(0.4 + 0.2 * abs(math.cos(t / 1e9)), 2)] * 10}
37
+ ).encode()
38
+ w.add_message(ch_scan, log_time=t, publish_time=t, data=payload)
39
+ w.finish()
40
+ events = [
41
+ {
42
+ "id": "ev-gap",
43
+ "sessionId": "sample_session",
44
+ "timestampNs": 1_000_000_000,
45
+ "kind": "communication_gap",
46
+ "label": "cmd_vel gap 0.8s (i=3..5)",
47
+ "severity": "warn",
48
+ },
49
+ {
50
+ "id": "ev-scan-anomaly",
51
+ "sessionId": "sample_session",
52
+ "timestampNs": 6_000_000_000,
53
+ "kind": "scan_range_spike",
54
+ "label": "scan ranges spike 2.0x",
55
+ "severity": "error",
56
+ },
57
+ {
58
+ "id": "ev-recovered",
59
+ "sessionId": "sample_session",
60
+ "timestampNs": 9_000_000_000,
61
+ "kind": "recovery",
62
+ "label": "teleop resumed",
63
+ "severity": "info",
64
+ },
65
+ ]
66
+ (FIXTURE_DIR / "sample_events.json").write_text(json.dumps(events, indent=2))
67
+ graph = {
68
+ "nodes": [
69
+ {"id": "n/robot1/teleop", "ns": "/robot1", "name": "teleop", "kind": "node"},
70
+ {"id": "n/robot1/controller", "ns": "/robot1", "name": "controller", "kind": "node"},
71
+ {"id": "n/robot1/lidar", "ns": "/robot1", "name": "lidar", "kind": "node"},
72
+ ],
73
+ "topics": [
74
+ {"id": "t/robot1/cmd_vel", "name": "/robot1/cmd_vel", "msgType": CMDVEL_SCHEMA, "pubCount": 1, "subCount": 1, "qos": {"summary": "best_effort, depth 5 (fixture metadata)"}},
75
+ {"id": "t/robot1/scan", "name": "/robot1/scan", "msgType": SCAN_SCHEMA, "pubCount": 1, "subCount": 1, "qos": {"summary": "best_effort, depth 10 (fixture metadata)"}},
76
+ ],
77
+ "edges": [
78
+ {"id": "e1", "nodeId": "n/robot1/teleop", "topicId": "t/robot1/cmd_vel", "direction": "pub", "qosCompat": "ok"},
79
+ {"id": "e2", "nodeId": "n/robot1/controller", "topicId": "t/robot1/cmd_vel", "direction": "sub", "qosCompat": "ok"},
80
+ {"id": "e3", "nodeId": "n/robot1/lidar", "topicId": "t/robot1/scan", "direction": "pub", "qosCompat": "ok"},
81
+ {"id": "e4", "nodeId": "n/robot1/controller", "topicId": "t/robot1/scan", "direction": "sub", "qosCompat": "ok"},
82
+ ],
83
+ "activity": [],
84
+ }
85
+ (FIXTURE_DIR / "sample_graph.json").write_text(json.dumps(graph, indent=2))
86
+ tf = {
87
+ "frames": [
88
+ {"frameId": "odom", "authority": "sample_session"},
89
+ {"frameId": "base_link", "parentId": "odom", "translation": [1.0, 0.0, 0.0], "rotation": [0, 0, 0, 1]},
90
+ {"frameId": "laser", "parentId": "base_link", "translation": [0.2, 0.0, 0.3], "rotation": [0, 0, 0, 1]},
91
+ ]
92
+ }
93
+ (FIXTURE_DIR / "sample_tf.json").write_text(json.dumps(tf, indent=2))
94
+ print(f"wrote {path}, events, graph, tf")
95
+ def make_repo_fixtures() -> None:
96
+ """Phase 4 adapter fixtures: ros2bag .db3, lerobot dataset, tracks JSON, world_spec.json, sqlite stores."""
97
+ from pyrobostudio_sidecar.ros2bag import write_db3_fixture
98
+ from pyrobostudio_sidecar.fixtures_env import make_ni_db, make_sf_db
99
+
100
+ for stale in [
101
+ FIXTURE_DIR / "sample_session.db3",
102
+ FIXTURE_DIR / "sample_tracks.json",
103
+ FIXTURE_DIR / "pynetworkintel.db",
104
+ FIXTURE_DIR / "syncfleet.db",
105
+ ]:
106
+ stale.unlink(missing_ok=True)
107
+ import numpy as np
108
+ import pyarrow as pa
109
+ import pyarrow.parquet as pq
110
+ cmd_vel = {"linear": {"x": 0.5, "y": 0.0, "z": 0.0}, "angular": {"x": 0.0, "y": 0.0, "z": 0.1}}
111
+ scan = {
112
+ "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": "laser"},
113
+ "angle_min": 0.0, "angle_max": 1.0, "angle_increment": 0.01,
114
+ "time_increment": 0.0, "scan_time": 0.0, "range_min": 0.2, "range_max": 10.0,
115
+ "ranges": [0.4, 0.6, 0.8], "intensities": [1.0, 1.0, 1.0],
116
+ }
117
+ odom = {
118
+ "header": {"stamp": {"sec": 0, "nanosec": 0}, "frame_id": "odom"},
119
+ "child_frame_id": "base_link",
120
+ "pose": {"pose": {"position": {"x": 1.0, "y": 0.0, "z": 0.0},
121
+ "orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}},
122
+ "covariance": [0.0] * 36},
123
+ "twist": {"twist": {"linear": {"x": 0.5, "y": 0.0, "z": 0.0},
124
+ "angular": {"x": 0.0, "y": 0.0, "z": 0.1}},
125
+ "covariance": [0.0] * 36},
126
+ }
127
+ db3 = FIXTURE_DIR / "sample_session.db3"
128
+ write_db3_fixture(
129
+ db3,
130
+ topics={
131
+ "/robot1/cmd_vel": "geometry_msgs/msg/Twist",
132
+ "/robot1/scan": "sensor_msgs/msg/LaserScan",
133
+ "/odom": "nav_msgs/msg/Odometry",
134
+ },
135
+ timed_messages={
136
+ "/robot1/cmd_vel": [(0, cmd_vel), (200_000_000, cmd_vel)],
137
+ "/robot1/scan": [(100_000_000, scan)],
138
+ "/odom": [(50_000_000, odom)],
139
+ },
140
+ )
141
+ lc = FIXTURE_DIR / "warehouse_demo"
142
+ (lc / "meta" / "episodes" / "chunk-000").mkdir(parents=True, exist_ok=True)
143
+ (lc / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
144
+ flat = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]]
145
+ info = {
146
+ "codebase_version": "v3.0", "robot_type": "warehouse_bot", "fps": 10,
147
+ "total_episodes": 2, "total_frames": 3, "chunks_size": 1000,
148
+ "data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
149
+ "video_path": None, "video_codec": None, "video_crf": None,
150
+ "features": {"observation.state": {"dtype": "float32", "shape": [3]},
151
+ "action": {"dtype": "float32", "shape": [3]}},
152
+ }
153
+ (lc / "meta" / "info.json").write_text(json.dumps(info))
154
+ eps = pa.table({
155
+ "episode_index": pa.array([0, 1], pa.int64()),
156
+ "length": pa.array([2, 1], pa.int64()),
157
+ "dataset_from_index": pa.array([0, 2], pa.int64()),
158
+ "dataset_to_index": pa.array([2, 3], pa.int64()),
159
+ "data/chunk_index": pa.array([0, 0], pa.int64()),
160
+ "data/file_index": pa.array([0, 0], pa.int64()),
161
+ })
162
+ pq.write_table(eps, lc / "meta" / "episodes" / "chunk-000" / "file-000.parquet")
163
+ d = 3
164
+ table = pa.table({
165
+ "observation.state": pa.FixedSizeListArray.from_arrays(np.array(flat, dtype=np.float32).ravel(), d),
166
+ "action": pa.FixedSizeListArray.from_arrays(np.array([[x * 0.5 for x in s] for s in flat], dtype=np.float32).ravel(), d),
167
+ })
168
+ pq.write_table(table, lc / "data" / "chunk-000" / "file-000.parquet")
169
+ (lc / "meta" / "stats.json").write_text(json.dumps({
170
+ "observation.state": {"mean": [0.0] * d, "std": [1.0] * d, "min": [0.0] * d, "max": [1.0] * d},
171
+ "action": {"mean": [0.0] * d, "std": [0.5] * d, "min": [0.0] * d, "max": [0.5] * d},
172
+ }))
173
+ (lc / ".versions.json").write_text(json.dumps({"versions": [{"tag": "v1", "created": "2026-11-01", "episodes": 2}], "current": "v1"}))
174
+ tracks = {
175
+ "schema_version": "1",
176
+ "clip_id": "clip-warehouse-1",
177
+ "max_age": 3,
178
+ "frames": [
179
+ {"frame": 0, "timestampNs": 0, "tracks": [
180
+ {"track_id": 1, "bbox": [10, 10, 60, 120], "class_id": 0, "confidence": 0.93,
181
+ "time_since_update": 0, "hit_streak": 4, "is_confirmed": True},
182
+ {"track_id": 2, "bbox": [200, 80, 260, 190], "class_id": 1, "confidence": 0.44,
183
+ "time_since_update": 2, "hit_streak": 1, "is_confirmed": False}]},
184
+ {"frame": 1, "timestampNs": 200_000_000, "tracks": [
185
+ {"track_id": 1, "bbox": [12, 10, 64, 120], "class_id": 0, "confidence": 0.91,
186
+ "time_since_update": 0, "hit_streak": 5, "is_confirmed": True}]},
187
+ ],
188
+ }
189
+ (FIXTURE_DIR / "sample_tracks.json").write_text(json.dumps(tracks, indent=2))
190
+ world_dir = FIXTURE_DIR / "warehouse_sim"
191
+ world_dir.mkdir(exist_ok=True)
192
+ world_spec = {
193
+ "metadata": {"seed": "66", "name": "warehouse_sim"},
194
+ "scene_bounds_min": [-50.0, -50.0, 0.0],
195
+ "scene_bounds_max": [50.0, 50.0, 40.0],
196
+ "lighting": {"sun_intensity": 1.2, "sun_angle_elevation": 55.0, "sun_angle_azimuth": 200.0,
197
+ "ambient_intensity": 0.5, "shadow_distance": 300},
198
+ "weather": {"rain_intensity": 0.0, "cloud_coverage": 0.4, "fog_density": 0.05,
199
+ "fog_color_rgb": [0.8, 0.8, 0.9], "fog_distance": 900, "wind_speed": 0.8,
200
+ "temperature_celsius": 21},
201
+ "time_of_day": {"hour": 10, "minute": 0, "day_of_year": 1, "season": "summer"},
202
+ "rendering": {"profile": "edge", "resolution_width": 960, "resolution_height": 540, "fps": 30,
203
+ "ray_tracing": False, "motion_blur": False, "depth_of_field": False},
204
+ "sensors": {"rgb_enabled": True, "depth_enabled": True, "lidar_enabled": True, "thermal_enabled": False,
205
+ "lidar_channels": 16, "lidar_range_max": 100, "save_to_disk": True,
206
+ "output_format_rgb": "png", "output_format_depth": "npy", "output_format_lidar": "pcd"},
207
+ "objects": [
208
+ {"id": "crate_a", "name": "crateA", "type": "crate", "position": [2, 1, 0], "rotation": [0, 0, 0],
209
+ "scale": [1, 1, 1], "material": "wood", "physics_enabled": True},
210
+ {"id": "shelf_1", "name": "shelf", "type": "shelf", "position": [-6, 0, 0], "rotation": [0, 0, 90],
211
+ "scale": [2, 1, 3], "material": "metal", "physics_enabled": True},
212
+ ],
213
+ }
214
+ (world_dir / "world_spec.json").write_text(json.dumps(world_spec, indent=2))
215
+ make_ni_db(FIXTURE_DIR / "pynetworkintel.db")
216
+ make_sf_db(FIXTURE_DIR / "syncfleet.db")
217
+ print(f"wrote adapter fixtures in {FIXTURE_DIR}: sample_session.db3, warehouse_demo/, sample_tracks.json, warehouse_sim/, pynetworkintel.db, syncfleet.db")
218
+
219
+ def make_mission_manifest() -> None:
220
+ """mission.json v1 joining the adapter fixtures with real sha256 hashes."""
221
+ import hashlib
222
+
223
+ from pyrobostudio_sidecar.mission import sha256_of
224
+
225
+ def h(name: str) -> str:
226
+ return sha256_of(FIXTURE_DIR / name)
227
+
228
+ manifest = {
229
+ "schema_version": "1",
230
+ "mission_id": "mission-warehouse-001",
231
+ "run_id": "run-2026-11-04-a",
232
+ "robots": ["robot1", "robot2"],
233
+ "time_base": {"domain": "session_ns", "start_ns": 0, "end_ns": 10_000_000_000},
234
+ "artifacts": [
235
+ {"kind": "rosbag", "id": "sample_session", "uri": "sample_session.mcap", "sha256": h("sample_session.mcap"), "origin": "recorded"},
236
+ {"kind": "rosbag_db3", "id": "sample_session_db3", "uri": "sample_session.db3", "sha256": h("sample_session.db3"), "origin": "recorded"},
237
+ {"kind": "vision_tracks", "id": "clip-warehouse-1", "uri": "sample_tracks.json", "sha256": h("sample_tracks.json"), "origin": "recorded"},
238
+ {"kind": "world_spec", "id": "warehouse_sim", "uri": "warehouse_sim/world_spec.json", "sha256": h("warehouse_sim/world_spec.json"), "origin": "simulated"},
239
+ {"kind": "networkintel", "id": "pynetworkintel", "uri": "pynetworkintel.db", "sha256": h("pynetworkintel.db"), "origin": "simulated"},
240
+ {"kind": "syncfleet", "id": "syncfleet", "uri": "syncfleet.db", "sha256": h("syncfleet.db"), "origin": "simulated"},
241
+ ],
242
+ "events": [
243
+ {"id": "ev-gap", "timestamp_ns": 1_000_000_000, "kind": "communication_gap", "label": "cmd_vel gap 0.8s (i=3..5)", "severity": "warn", "artifact": "sample_session"},
244
+ {"id": "ev-scan-anomaly", "timestamp_ns": 6_000_000_000, "kind": "scan_range_spike", "label": "scan ranges spike 2.0x", "severity": "error", "artifact": "sample_session"},
245
+ {"id": "ev-recovered", "timestamp_ns": 9_000_000_000, "kind": "recovery", "label": "teleop resumed", "severity": "info", "artifact": "sample_session"},
246
+ ],
247
+ }
248
+ (FIXTURE_DIR / "mission_warehouse.json").write_text(json.dumps(manifest, indent=2))
249
+ print(f"wrote {FIXTURE_DIR / 'mission_warehouse.json'}")
250
+
251
+
252
+ if __name__ == "__main__":
253
+ make_fixture()
254
+ make_repo_fixtures()
255
+ make_mission_manifest()
@@ -0,0 +1,85 @@
1
+ """Mission manifest v1 (competitive-analysis Gap 1 MVP).
2
+
3
+ A versioned mission.json joins PyRobo* artifacts (bags, tracks, world specs,
4
+ fleet stores) under one mission/run identity with real sha256 hashes and a
5
+ shared time base. Read-only; served from sidecar/fixtures.
6
+ """
7
+ import hashlib
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ REQUIRED_TOP = {"schema_version", "mission_id", "run_id", "robots", "time_base", "artifacts", "events"}
13
+ REQUIRED_ARTIFACT = {"kind", "id", "uri", "sha256", "origin"}
14
+ KNOWN_KINDS = {"rosbag", "rosbag_db3", "vision_tracks", "world_spec", "networkintel", "syncfleet", "terrain"}
15
+ KNOWN_ORIGINS = {"live", "recorded", "simulated", "derived"}
16
+
17
+
18
+ class MissionError(ValueError):
19
+ pass
20
+
21
+
22
+ def sha256_of(path: Path) -> str:
23
+ h = hashlib.sha256()
24
+ h.update(path.read_bytes())
25
+ return h.hexdigest()
26
+
27
+
28
+ def validate_manifest(data: dict[str, Any], base_dir: Path) -> dict[str, Any]:
29
+ missing = REQUIRED_TOP - set(data)
30
+ if missing:
31
+ raise MissionError(f"manifest missing keys {sorted(missing)}")
32
+ if data["schema_version"] != "1":
33
+ raise MissionError(f"unsupported manifest schema_version {data['schema_version']!r}")
34
+ tb = data["time_base"]
35
+ if not isinstance(tb, dict) or not {"domain", "start_ns", "end_ns"} <= set(tb):
36
+ raise MissionError("time_base must have domain/start_ns/end_ns")
37
+ if tb["end_ns"] <= tb["start_ns"]:
38
+ raise MissionError("time_base end_ns must be > start_ns")
39
+ seen: set[str] = set()
40
+ for art in data["artifacts"]:
41
+ miss = REQUIRED_ARTIFACT - set(art)
42
+ if miss:
43
+ raise MissionError(f"artifact missing keys {sorted(miss)}")
44
+ if art["kind"] not in KNOWN_KINDS:
45
+ raise MissionError(f"unknown artifact kind {art['kind']!r}")
46
+ if art["origin"] not in KNOWN_ORIGINS:
47
+ raise MissionError(f"unknown artifact origin {art['origin']!r}")
48
+ if art["id"] in seen:
49
+ raise MissionError(f"duplicate artifact id {art['id']!r}")
50
+ seen.add(art["id"])
51
+ p = base_dir / art["uri"]
52
+ if not p.exists():
53
+ raise MissionError(f"artifact uri not found: {art['uri']}")
54
+ actual = sha256_of(p)
55
+ if actual != art["sha256"]:
56
+ raise MissionError(f"artifact {art['id']}: sha256 mismatch (manifest stale)")
57
+ for ev in data["events"]:
58
+ if not {"id", "timestamp_ns", "kind", "label", "severity"} <= set(ev):
59
+ raise MissionError(f"event missing keys: {ev}")
60
+ if ev["severity"] not in {"info", "warn", "error"}:
61
+ raise MissionError(f"event {ev['id']}: bad severity {ev['severity']!r}")
62
+ if not tb["start_ns"] <= ev["timestamp_ns"] <= tb["end_ns"]:
63
+ raise MissionError(f"event {ev['id']}: timestamp outside mission time base")
64
+ return data
65
+
66
+
67
+ def load_manifest(path: Path) -> dict[str, Any]:
68
+ data = json.loads(path.read_text())
69
+ return validate_manifest(data, path.parent)
70
+
71
+
72
+ def mission_summary(data: dict[str, Any]) -> dict[str, Any]:
73
+ return {
74
+ "missionId": data["mission_id"],
75
+ "runId": data["run_id"],
76
+ "robots": data["robots"],
77
+ "artifactCount": len(data["artifacts"]),
78
+ "eventCount": len(data["events"]),
79
+ "timeBase": data["time_base"],
80
+ }
81
+
82
+
83
+ def manifest_out(data: dict[str, Any]) -> dict[str, Any]:
84
+ arts = [{**a, "sha256Short": a["sha256"][:12]} for a in data["artifacts"]]
85
+ return {**data, "artifacts": arts}